Spaces:
Running
Running
Clear main before restore
Browse files- README.md +0 -6
- ai_ext.py +0 -574
- ai_patch.py +0 -917
- app_v2_entry.py +0 -2166
- main.py +0 -556
README.md
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
tags:
|
| 3 |
-
- ml-intern
|
| 4 |
-
---
|
| 5 |
-
|
| 6 |
-
# bep40/VNEWS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_ext.py
DELETED
|
@@ -1,574 +0,0 @@
|
|
| 1 |
-
"""VNEWS AI Extension - rewrite + auto short video generation.
|
| 2 |
-
Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
|
| 3 |
-
/api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
|
| 4 |
-
|
| 5 |
-
Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
|
| 6 |
-
TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
|
| 7 |
-
"""
|
| 8 |
-
import os, re, json, time, random, html as html_lib, subprocess, asyncio
|
| 9 |
-
from urllib.parse import quote_plus, quote, urlparse, urljoin
|
| 10 |
-
from typing import Optional, List, Dict
|
| 11 |
-
import requests
|
| 12 |
-
from bs4 import BeautifulSoup
|
| 13 |
-
from fastapi import Request, Query
|
| 14 |
-
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
-
|
| 16 |
-
# Try to import main app, but don't fail if it doesn't exist
|
| 17 |
-
try:
|
| 18 |
-
from main import app
|
| 19 |
-
except ImportError:
|
| 20 |
-
# Create a minimal FastAPI app for standalone testing
|
| 21 |
-
try:
|
| 22 |
-
from fastapi import FastAPI
|
| 23 |
-
app = FastAPI()
|
| 24 |
-
except Exception:
|
| 25 |
-
app = None
|
| 26 |
-
|
| 27 |
-
# Import wall store from main.py so we read/write the SAME file
|
| 28 |
-
try:
|
| 29 |
-
from main import _load_wall, _save_wall, _web_context # noqa: F401
|
| 30 |
-
except ImportError:
|
| 31 |
-
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
| 32 |
-
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 33 |
-
def _load_wall():
|
| 34 |
-
try:
|
| 35 |
-
if os.path.exists(_wall_file):
|
| 36 |
-
with open(_wall_file, "r", encoding="utf-8") as f:
|
| 37 |
-
return json.load(f)
|
| 38 |
-
except Exception:
|
| 39 |
-
pass
|
| 40 |
-
return []
|
| 41 |
-
def _save_wall(posts):
|
| 42 |
-
try:
|
| 43 |
-
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 44 |
-
tmp = _wall_file + ".tmp"
|
| 45 |
-
with open(tmp, "w", encoding="utf-8") as f:
|
| 46 |
-
json.dump(posts[:100], f, ensure_ascii=False)
|
| 47 |
-
os.replace(tmp, _wall_file)
|
| 48 |
-
except Exception:
|
| 49 |
-
pass
|
| 50 |
-
def _web_context(topic):
|
| 51 |
-
return ""
|
| 52 |
-
|
| 53 |
-
# ai_ext alias for backward compatibility
|
| 54 |
-
_load_ai_wall = _load_wall
|
| 55 |
-
_save_ai_wall = _save_wall
|
| 56 |
-
|
| 57 |
-
try:
|
| 58 |
-
from huggingface_hub import AsyncInferenceClient
|
| 59 |
-
except Exception:
|
| 60 |
-
AsyncInferenceClient = None
|
| 61 |
-
try:
|
| 62 |
-
from gtts import gTTS
|
| 63 |
-
except Exception:
|
| 64 |
-
gTTS = None
|
| 65 |
-
try:
|
| 66 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 67 |
-
except Exception:
|
| 68 |
-
Image = ImageDraw = ImageFont = None
|
| 69 |
-
try:
|
| 70 |
-
import edge_tts
|
| 71 |
-
except Exception:
|
| 72 |
-
edge_tts = None
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _hf_token():
|
| 76 |
-
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
| 77 |
-
v = os.getenv(k, "").strip()
|
| 78 |
-
if v:
|
| 79 |
-
return v
|
| 80 |
-
return ""
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _clean_text(s: str) -> str:
|
| 84 |
-
"""Clean text for processing."""
|
| 85 |
-
s = html_lib.unescape(s or "")
|
| 86 |
-
s = re.sub(r"\s+", " ", s)
|
| 87 |
-
return s.strip()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _domain(url: str) -> str:
|
| 91 |
-
"""Extract domain from URL."""
|
| 92 |
-
try:
|
| 93 |
-
return urlparse(url or "").netloc.replace("www.", "")
|
| 94 |
-
except Exception:
|
| 95 |
-
return ""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
-
"""Generate text using Qwen models via Hugging Face Inference API.
|
| 100 |
-
|
| 101 |
-
This function provides a resilient implementation that:
|
| 102 |
-
1. First tries the SDK-based inference client if available
|
| 103 |
-
2. Falls back to REST API calls to HF router endpoint
|
| 104 |
-
3. Returns a fallback summary if all else fails
|
| 105 |
-
"""
|
| 106 |
-
token = _hf_token()
|
| 107 |
-
errors = []
|
| 108 |
-
|
| 109 |
-
# Try HF router API with multiple models
|
| 110 |
-
if token:
|
| 111 |
-
models = [
|
| 112 |
-
os.getenv("QWEN_VL_MODEL", ""),
|
| 113 |
-
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 114 |
-
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 115 |
-
"Qwen/Qwen2.5-7B-Instruct",
|
| 116 |
-
"Qwen/Qwen2.5-3B-Instruct",
|
| 117 |
-
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 118 |
-
"Qwen/Qwen2.5-72B-Instruct",
|
| 119 |
-
"meta-llama/Llama-3.3-70B-Instruct",
|
| 120 |
-
]
|
| 121 |
-
# Deduplicate while preserving order
|
| 122 |
-
seen = set()
|
| 123 |
-
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 124 |
-
|
| 125 |
-
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 126 |
-
|
| 127 |
-
for model in models:
|
| 128 |
-
try:
|
| 129 |
-
is_vl = "VL" in model and image_url
|
| 130 |
-
if is_vl:
|
| 131 |
-
user_content = [
|
| 132 |
-
{"type": "image_url", "image_url": {"url": image_url}},
|
| 133 |
-
{"type": "text", "text": prompt}
|
| 134 |
-
]
|
| 135 |
-
else:
|
| 136 |
-
user_content = prompt
|
| 137 |
-
|
| 138 |
-
payload = {
|
| 139 |
-
"model": model,
|
| 140 |
-
"messages": [
|
| 141 |
-
{"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
|
| 142 |
-
{"role": "user", "content": user_content},
|
| 143 |
-
],
|
| 144 |
-
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 145 |
-
"temperature": 0.35,
|
| 146 |
-
"top_p": 0.85,
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
r = requests.post(
|
| 150 |
-
"https://router.huggingface.co/v1/chat/completions",
|
| 151 |
-
headers=headers,
|
| 152 |
-
json=payload,
|
| 153 |
-
timeout=95
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
-
if r.status_code >= 300:
|
| 157 |
-
errors.append(f"{model}: HTTP {r.status_code}")
|
| 158 |
-
continue
|
| 159 |
-
|
| 160 |
-
j = r.json()
|
| 161 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 162 |
-
|
| 163 |
-
if txt:
|
| 164 |
-
return txt
|
| 165 |
-
|
| 166 |
-
errors.append(f"{model}: empty response")
|
| 167 |
-
|
| 168 |
-
except Exception as e:
|
| 169 |
-
errors.append(f"{model}: {type(e).__name__}")
|
| 170 |
-
|
| 171 |
-
# Fallback: extractive summary from prompt
|
| 172 |
-
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 173 |
-
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
| 177 |
-
"""Generate a simple fallback summary when AI is unavailable."""
|
| 178 |
-
text = prompt or ""
|
| 179 |
-
for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
|
| 180 |
-
if marker in text:
|
| 181 |
-
text = text.split(marker, 1)[1]
|
| 182 |
-
break
|
| 183 |
-
text = re.sub(r"https?://\S+", "", text)
|
| 184 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 185 |
-
|
| 186 |
-
# Split into sentences - extract ALL valid sentences, not just first few
|
| 187 |
-
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 188 |
-
units = []
|
| 189 |
-
for s in sentences:
|
| 190 |
-
s = _clean_text(s)
|
| 191 |
-
if len(s) >= 30: # Lower threshold to capture more content
|
| 192 |
-
units.append(s)
|
| 193 |
-
|
| 194 |
-
if units:
|
| 195 |
-
# Take up to max_units valid sentences
|
| 196 |
-
result_units = units[:max_units]
|
| 197 |
-
return "\n".join("• " + u for u in result_units)
|
| 198 |
-
if text:
|
| 199 |
-
# Fallback: take chunks if no sentence boundaries found
|
| 200 |
-
chunks = []
|
| 201 |
-
for i in range(0, min(len(text), max_units * 300), 280):
|
| 202 |
-
chunk = _clean_text(text[i:i+300])
|
| 203 |
-
if chunk and chunk not in chunks:
|
| 204 |
-
chunks.append(chunk)
|
| 205 |
-
if len(chunks) >= max_units:
|
| 206 |
-
break
|
| 207 |
-
if chunks:
|
| 208 |
-
return "\n".join("• " + c for c in chunks)
|
| 209 |
-
return "• Không có đủ nội dung để tóm tắt."
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
HF_TOKEN = _hf_token()
|
| 213 |
-
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
| 214 |
-
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
| 215 |
-
"QWEN_TEXT_MODELS",
|
| 216 |
-
"Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
|
| 217 |
-
).split(",") if m.strip()]
|
| 218 |
-
_WORKING_MODEL_TEXT = None
|
| 219 |
-
_WORKING_MODEL_VL = None
|
| 220 |
-
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 221 |
-
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 222 |
-
HEADERS = {
|
| 223 |
-
"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",
|
| 224 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
|
| 225 |
-
}
|
| 226 |
-
LAST_QWEN_ERROR = ""
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
# ===== MULTILINGUAL VOICES FOR TTS =====
|
| 230 |
-
# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
|
| 231 |
-
MULTILINGUAL_VOICES = {
|
| 232 |
-
# Vietnamese - Native voices
|
| 233 |
-
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
|
| 234 |
-
"vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 235 |
-
"hoaimy": "vi-VN-HoaiMyNeural",
|
| 236 |
-
"namminh": "vi-VN-NamMinhNeural",
|
| 237 |
-
"vi_female": "vi-VN-HoaiMyNeural",
|
| 238 |
-
"vi_male": "vi-VN-NamMinhNeural",
|
| 239 |
-
"nu": "vi-VN-HoaiMyNeural",
|
| 240 |
-
"male": "vi-VN-NamMinhNeural",
|
| 241 |
-
"female": "vi-VN-HoaiMyNeural",
|
| 242 |
-
"mien-nam": "vi-VN-HoaiMyNeural",
|
| 243 |
-
# English - Multilingual
|
| 244 |
-
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 245 |
-
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 246 |
-
"en_andrew": "en-US-AndrewMultilingualNeural",
|
| 247 |
-
"andrew": "en-US-AndrewMultilingualNeural",
|
| 248 |
-
"en_jenny": "en-US-AndrewMultilingualNeural",
|
| 249 |
-
"jenny": "en-US-AndrewMultilingualNeural",
|
| 250 |
-
# Portuguese - Thalita Multilingual ONLY
|
| 251 |
-
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 252 |
-
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 253 |
-
"thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 254 |
-
"pt_francisco": "pt-BR-ThalitaMultilingualNeural",
|
| 255 |
-
"pt": "pt-BR-ThalitaMultilingualNeural",
|
| 256 |
-
# French - Multilingual
|
| 257 |
-
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 258 |
-
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 259 |
-
"fr_denise": "fr-FR-VivienneMultilingualNeural",
|
| 260 |
-
"denise": "fr-FR-VivienneMultilingualNeural",
|
| 261 |
-
"fr": "fr-FR-VivienneMultilingualNeural",
|
| 262 |
-
# German - Multilingual
|
| 263 |
-
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 264 |
-
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 265 |
-
"de_katja": "de-DE-SeraphinaMultilingualNeural",
|
| 266 |
-
"katja": "de-DE-SeraphinaMultilingualNeural",
|
| 267 |
-
"de": "de-DE-SeraphinaMultilingualNeural",
|
| 268 |
-
# Korean - Hyunsu Multilingual (NOT SunHee)
|
| 269 |
-
"ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 270 |
-
"ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 271 |
-
"sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 272 |
-
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 273 |
-
# Italian - Multilingual
|
| 274 |
-
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 275 |
-
# Spanish (fallback to English multilingual)
|
| 276 |
-
"es_ela": "en-US-AndrewMultilingualNeural",
|
| 277 |
-
"ela": "en-US-AndrewMultilingualNeural",
|
| 278 |
-
"es_carlos": "en-US-AndrewMultilingualNeural",
|
| 279 |
-
"es": "en-US-AndrewMultilingualNeural",
|
| 280 |
-
# Japanese (fallback to English multilingual)
|
| 281 |
-
"ja_nanami": "en-US-AndrewMultilingualNeural",
|
| 282 |
-
"nanami": "en-US-AndrewMultilingualNeural",
|
| 283 |
-
"ja": "en-US-AndrewMultilingualNeural",
|
| 284 |
-
# Chinese (fallback to English multilingual)
|
| 285 |
-
"zh_xiaochen": "en-US-AndrewMultilingualNeural",
|
| 286 |
-
"xiaochen": "en-US-AndrewMultilingualNeural",
|
| 287 |
-
"zh": "en-US-AndrewMultilingualNeural",
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
def _detect_voice_emotion(title, text):
|
| 292 |
-
"""Detect appropriate voice and emotion based on content for multilingual TTS."""
|
| 293 |
-
content = ((title or "") + " " + (text or "")).lower()
|
| 294 |
-
|
| 295 |
-
# World Cup / Football content - use Andrew multilingual
|
| 296 |
-
if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
|
| 297 |
-
return ("andrew", "excited")
|
| 298 |
-
|
| 299 |
-
# News categories - choose appropriate voice
|
| 300 |
-
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
|
| 301 |
-
return ("jenny", "calm")
|
| 302 |
-
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
|
| 303 |
-
return ("thalita", "serious")
|
| 304 |
-
if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
|
| 305 |
-
return ("ela", "happy")
|
| 306 |
-
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
|
| 307 |
-
return ("katja", "excited")
|
| 308 |
-
|
| 309 |
-
# Default Vietnamese
|
| 310 |
-
return ("hoaimy", "trung_tinh")
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
def _safe_name(s: str) -> str:
|
| 314 |
-
"""Create safe filename from string."""
|
| 315 |
-
s = re.sub(r"[^\w\-.]", "_", s)
|
| 316 |
-
return s[:100] if len(s) > 100 else s
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
|
| 320 |
-
"""Download image from URL to path."""
|
| 321 |
-
if not url:
|
| 322 |
-
return False
|
| 323 |
-
try:
|
| 324 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 325 |
-
if r.status_code == 200:
|
| 326 |
-
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 327 |
-
with open(out_path, "wb") as f:
|
| 328 |
-
f.write(r.content)
|
| 329 |
-
return True
|
| 330 |
-
except Exception:
|
| 331 |
-
pass
|
| 332 |
-
return False
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
def pollination_image_url(topic: str) -> str:
|
| 336 |
-
"""Generate image URL from Pollinations.ai."""
|
| 337 |
-
return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
# Use the same wall file as app_v2_entry.py for consistency
|
| 341 |
-
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
|
| 342 |
-
|
| 343 |
-
def _load_ai_wall():
|
| 344 |
-
"""Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 345 |
-
try:
|
| 346 |
-
if os.path.exists(WALL_FILE):
|
| 347 |
-
with open(WALL_FILE, "r", encoding="utf-8") as f:
|
| 348 |
-
return json.load(f)
|
| 349 |
-
except Exception:
|
| 350 |
-
pass
|
| 351 |
-
return []
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
def _save_ai_wall(posts):
|
| 355 |
-
"""Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 356 |
-
try:
|
| 357 |
-
os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
|
| 358 |
-
tmp = WALL_FILE + ".tmp"
|
| 359 |
-
with open(tmp, "w", encoding="utf-8") as f:
|
| 360 |
-
json.dump(posts[:100], f, ensure_ascii=False)
|
| 361 |
-
os.replace(tmp, WALL_FILE)
|
| 362 |
-
except Exception:
|
| 363 |
-
pass
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
# Helper functions for wall operations
|
| 367 |
-
def _load_wall_posts():
|
| 368 |
-
"""Alias for _load_ai_wall for consistency with app_v2_entry.py."""
|
| 369 |
-
return _load_ai_wall()
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
def _save_wall_posts(posts):
|
| 373 |
-
"""Alias for _save_ai_wall for consistency with app_v2_entry.py."""
|
| 374 |
-
return _save_ai_wall(posts)
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
|
| 378 |
-
"""Create a post dict with standard fields."""
|
| 379 |
-
return {
|
| 380 |
-
"id": str(int(time.time() * 1000)),
|
| 381 |
-
"title": title,
|
| 382 |
-
"text": text,
|
| 383 |
-
"img": img,
|
| 384 |
-
"url": url,
|
| 385 |
-
"kind": kind,
|
| 386 |
-
"sources": sources or [],
|
| 387 |
-
"ts": int(time.time())
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
def _short_script(post) -> str:
|
| 392 |
-
"""Extract clean text for TTS from post."""
|
| 393 |
-
text = post.get("text", "") or post.get("title", "")
|
| 394 |
-
text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
|
| 395 |
-
text = re.sub(r"\s*\n\s*", ". ", text)
|
| 396 |
-
return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
|
| 400 |
-
def scrape_any_url(url: str) -> dict:
|
| 401 |
-
"""Scrape any URL and extract article content.
|
| 402 |
-
|
| 403 |
-
Returns dict with: title, summary, text, image, og_image, via (domain)
|
| 404 |
-
"""
|
| 405 |
-
try:
|
| 406 |
-
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
|
| 407 |
-
r.encoding = 'utf-8'
|
| 408 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 409 |
-
|
| 410 |
-
# Remove scripts, styles, nav, footer
|
| 411 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 412 |
-
tag.decompose()
|
| 413 |
-
|
| 414 |
-
# Extract title
|
| 415 |
-
h1 = soup.find('h1')
|
| 416 |
-
ogt = soup.find('meta', property='og:title')
|
| 417 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
|
| 418 |
-
|
| 419 |
-
# Extract OG image
|
| 420 |
-
ogi = soup.find('meta', property='og:image')
|
| 421 |
-
og_image = ogi.get('content', '') if ogi else ''
|
| 422 |
-
|
| 423 |
-
# Extract article body
|
| 424 |
-
block = None
|
| 425 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 426 |
-
el = soup.select_one(sel)
|
| 427 |
-
if el and len(el.find_all('p')) >= 2:
|
| 428 |
-
block = el
|
| 429 |
-
break
|
| 430 |
-
if not block:
|
| 431 |
-
block = soup.body or soup
|
| 432 |
-
|
| 433 |
-
# Extract text from paragraphs
|
| 434 |
-
paragraphs = []
|
| 435 |
-
for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
|
| 436 |
-
t = _clean_text(el.get_text(strip=True))
|
| 437 |
-
if t and len(t) > 40:
|
| 438 |
-
paragraphs.append(t)
|
| 439 |
-
|
| 440 |
-
# Extract images
|
| 441 |
-
images = []
|
| 442 |
-
for el in block.find_all(['figure', 'img'], recursive=True):
|
| 443 |
-
im = el if el.name == 'img' else el.find('img')
|
| 444 |
-
if im:
|
| 445 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 446 |
-
if src and 'base64' not in src:
|
| 447 |
-
if src.startswith('//'):
|
| 448 |
-
src = 'https:' + src
|
| 449 |
-
images.append(src)
|
| 450 |
-
|
| 451 |
-
# Prefer OG image as main image
|
| 452 |
-
image = og_image or (images[0] if images else '')
|
| 453 |
-
|
| 454 |
-
return {
|
| 455 |
-
'title': title,
|
| 456 |
-
'summary': paragraphs[0] if paragraphs else '',
|
| 457 |
-
'text': '\n'.join(paragraphs),
|
| 458 |
-
'image': image,
|
| 459 |
-
'og_image': og_image,
|
| 460 |
-
'via': _domain(url),
|
| 461 |
-
'images': images
|
| 462 |
-
}
|
| 463 |
-
except Exception as e:
|
| 464 |
-
return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
def web_context(topic: str, limit: int = 5) -> tuple:
|
| 468 |
-
"""Get web context for a topic. Returns (context_text, sources_list)."""
|
| 469 |
-
sources = []
|
| 470 |
-
try:
|
| 471 |
-
# Try Google News RSS
|
| 472 |
-
rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
|
| 473 |
-
r = requests.get(rss_url, headers=HEADERS, timeout=15)
|
| 474 |
-
r.encoding = 'utf-8'
|
| 475 |
-
soup = BeautifulSoup(r.text, 'xml')
|
| 476 |
-
for it in soup.find_all('item')[:limit]:
|
| 477 |
-
title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
|
| 478 |
-
link = it.find('link').get_text(strip=True) if it.find('link') else ''
|
| 479 |
-
if title and link:
|
| 480 |
-
sources.append({'title': title, 'url': link, 'via': _domain(link)})
|
| 481 |
-
except Exception:
|
| 482 |
-
pass
|
| 483 |
-
|
| 484 |
-
context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
|
| 485 |
-
return context, sources
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
|
| 489 |
-
def _make_short_frame(post, img_path, out_path):
|
| 490 |
-
"""Create a short video frame from post and image.
|
| 491 |
-
|
| 492 |
-
Called by ai_patch.py _make_short_frame_full when Image is available.
|
| 493 |
-
"""
|
| 494 |
-
if Image is None:
|
| 495 |
-
# Create a minimal frame without PIL - just return success
|
| 496 |
-
# The caller should handle this case
|
| 497 |
-
return False
|
| 498 |
-
|
| 499 |
-
W, H = 1080, 1920
|
| 500 |
-
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 501 |
-
|
| 502 |
-
try:
|
| 503 |
-
im = Image.open(img_path).convert("RGB")
|
| 504 |
-
target = (1080, 760)
|
| 505 |
-
im_ratio = im.width / max(1, im.height)
|
| 506 |
-
target_ratio = target[0] / target[1]
|
| 507 |
-
|
| 508 |
-
if im_ratio > target_ratio:
|
| 509 |
-
new_h = target[1]
|
| 510 |
-
new_w = int(new_h * im_ratio)
|
| 511 |
-
else:
|
| 512 |
-
new_w = target[0]
|
| 513 |
-
new_h = int(new_w / im_ratio)
|
| 514 |
-
|
| 515 |
-
im = im.resize((new_w, new_h))
|
| 516 |
-
left = (new_w - target[0]) // 2
|
| 517 |
-
top = (new_h - target[1]) // 2
|
| 518 |
-
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 519 |
-
bg.paste(im, (0, 0))
|
| 520 |
-
except Exception:
|
| 521 |
-
pass
|
| 522 |
-
|
| 523 |
-
draw = ImageDraw.Draw(bg)
|
| 524 |
-
|
| 525 |
-
try:
|
| 526 |
-
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 527 |
-
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 528 |
-
except Exception:
|
| 529 |
-
font_title = font_body = None
|
| 530 |
-
|
| 531 |
-
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 532 |
-
margin = 48
|
| 533 |
-
maxw = W - margin * 2
|
| 534 |
-
|
| 535 |
-
y = 830
|
| 536 |
-
for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
|
| 537 |
-
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 538 |
-
y += 66
|
| 539 |
-
|
| 540 |
-
y += 18
|
| 541 |
-
text = post.get("text", "")
|
| 542 |
-
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 543 |
-
body_lines = _wrap_text(draw, text, font_body, maxw, 14)
|
| 544 |
-
for ln in body_lines:
|
| 545 |
-
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 546 |
-
y += 50
|
| 547 |
-
if y > 1640:
|
| 548 |
-
break
|
| 549 |
-
|
| 550 |
-
bg.save(out_path, quality=92)
|
| 551 |
-
return True
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
def _wrap_text(draw, text, font, max_width, max_lines):
|
| 555 |
-
"""Helper for wrapping text in frames."""
|
| 556 |
-
words = _clean_text(text).split()
|
| 557 |
-
lines, cur = [], ""
|
| 558 |
-
for w in words:
|
| 559 |
-
test = (cur + " " + w).strip()
|
| 560 |
-
try:
|
| 561 |
-
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 562 |
-
except Exception:
|
| 563 |
-
width = len(test) * 20
|
| 564 |
-
if width <= max_width:
|
| 565 |
-
cur = test
|
| 566 |
-
else:
|
| 567 |
-
if cur:
|
| 568 |
-
lines.append(cur)
|
| 569 |
-
cur = w
|
| 570 |
-
if len(lines) >= max_lines:
|
| 571 |
-
break
|
| 572 |
-
if cur and len(lines) < max_lines:
|
| 573 |
-
lines.append(cur)
|
| 574 |
-
return lines
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_patch.py
DELETED
|
@@ -1,917 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import re
|
| 3 |
-
import time
|
| 4 |
-
import random
|
| 5 |
-
import json
|
| 6 |
-
import html as html_lib
|
| 7 |
-
import subprocess
|
| 8 |
-
import requests
|
| 9 |
-
import hashlib
|
| 10 |
-
import ai_ext as base
|
| 11 |
-
from ai_ext import app
|
| 12 |
-
from fastapi import Request
|
| 13 |
-
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 14 |
-
from bs4 import BeautifulSoup
|
| 15 |
-
from urllib.parse import quote_plus
|
| 16 |
-
|
| 17 |
-
try:
|
| 18 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 19 |
-
except Exception:
|
| 20 |
-
Image = ImageDraw = ImageFont = None
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _clean(s):
|
| 24 |
-
s = html_lib.unescape(s or "")
|
| 25 |
-
s = re.sub(r"[ \t]+", " ", s)
|
| 26 |
-
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 27 |
-
return s.strip()
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _norm(s):
|
| 31 |
-
s = s.lower()
|
| 32 |
-
s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
|
| 33 |
-
s = re.sub(r"\s+", " ", s).strip()
|
| 34 |
-
return s
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _similar(a, b):
|
| 38 |
-
ta = set(_norm(a).split())
|
| 39 |
-
tb = set(_norm(b).split())
|
| 40 |
-
if not ta or not tb:
|
| 41 |
-
return False
|
| 42 |
-
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _dedupe_units(units, max_units=25):
|
| 46 |
-
"""Deduplicate units - only skip exact matches to ensure all bullet points are read."""
|
| 47 |
-
out, seen = [], set()
|
| 48 |
-
for u in units:
|
| 49 |
-
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 50 |
-
if len(u) < 18:
|
| 51 |
-
continue
|
| 52 |
-
nu = _norm(u)
|
| 53 |
-
# Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
|
| 54 |
-
if nu in seen:
|
| 55 |
-
continue
|
| 56 |
-
seen.add(nu)
|
| 57 |
-
out.append(u)
|
| 58 |
-
if len(out) >= max_units:
|
| 59 |
-
break
|
| 60 |
-
return out
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _postprocess_ai_text(text, max_units=20):
|
| 64 |
-
text = _clean(text)
|
| 65 |
-
if not text:
|
| 66 |
-
return text
|
| 67 |
-
drop_prefixes = (
|
| 68 |
-
"dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
|
| 69 |
-
"tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
|
| 70 |
-
)
|
| 71 |
-
raw_lines = []
|
| 72 |
-
for line in re.split(r"\n+", text):
|
| 73 |
-
line = _clean(line)
|
| 74 |
-
if not line:
|
| 75 |
-
continue
|
| 76 |
-
low = line.lower().strip()
|
| 77 |
-
if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
|
| 78 |
-
continue
|
| 79 |
-
raw_lines.append(line)
|
| 80 |
-
units = []
|
| 81 |
-
for line in raw_lines:
|
| 82 |
-
# KEEP FULL bullet point - don't truncate or split into segments
|
| 83 |
-
if len(line) >= 18:
|
| 84 |
-
units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
|
| 85 |
-
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
-
if not units:
|
| 87 |
-
return text[:900]
|
| 88 |
-
title = ""
|
| 89 |
-
if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
|
| 90 |
-
title = raw_lines[0]
|
| 91 |
-
units = [u for u in units if not _similar(u, title)]
|
| 92 |
-
body = "\n".join("• " + u for u in units[:max_units])
|
| 93 |
-
return (title + "\n\n" + body).strip() if title else body
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _fallback_summary_from_prompt(prompt, max_units=6):
|
| 97 |
-
text = prompt or ""
|
| 98 |
-
for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
|
| 99 |
-
if marker in text:
|
| 100 |
-
text = text.split(marker, 1)[1]
|
| 101 |
-
break
|
| 102 |
-
text = re.sub(r"https?://\S+", "", text)
|
| 103 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 104 |
-
sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 105 |
-
candidates = []
|
| 106 |
-
for s in sentences:
|
| 107 |
-
s = _clean(s)
|
| 108 |
-
if 45 <= len(s) <= 260:
|
| 109 |
-
candidates.append(s)
|
| 110 |
-
units = _dedupe_units(candidates, max_units=max_units)
|
| 111 |
-
if units:
|
| 112 |
-
return "\n".join("• " + u for u in units)
|
| 113 |
-
if text:
|
| 114 |
-
return "• " + text[:700].rsplit(" ", 1)[0]
|
| 115 |
-
return "• Không có đủ nội dung nguồn để tóm tắt."
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _source_line(sources):
|
| 119 |
-
names = []
|
| 120 |
-
for s in (sources or [])[:5]:
|
| 121 |
-
via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
|
| 122 |
-
if via and via not in names:
|
| 123 |
-
names.append(via)
|
| 124 |
-
return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _make_summary_prompt(title, raw, source_hint=""):
|
| 128 |
-
return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
|
| 129 |
-
|
| 130 |
-
NHIỆM VỤ BẮT BUỘC:
|
| 131 |
-
- Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
|
| 132 |
-
- Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
|
| 133 |
-
- Không thêm thông tin ngoài nguồn.
|
| 134 |
-
- Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
|
| 135 |
-
- Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
|
| 136 |
-
- Không viết phần mở bài dài, không viết văn kể lại.
|
| 137 |
-
|
| 138 |
-
Tiêu đề nguồn: {title}
|
| 139 |
-
Nguồn: {source_hint}
|
| 140 |
-
|
| 141 |
-
Nội dung nguồn:
|
| 142 |
-
{raw[:14000]}
|
| 143 |
-
"""
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _direct_news_rss(topic, limit=10):
|
| 147 |
-
out = []
|
| 148 |
-
try:
|
| 149 |
-
url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
|
| 150 |
-
r = requests.get(url, headers=base.HEADERS, timeout=15)
|
| 151 |
-
r.encoding = "utf-8"
|
| 152 |
-
soup = BeautifulSoup(r.text, "xml")
|
| 153 |
-
for it in soup.find_all("item")[:limit]:
|
| 154 |
-
title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
|
| 155 |
-
link = it.find("link").get_text(strip=True) if it.find("link") else ""
|
| 156 |
-
src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
|
| 157 |
-
if title and link:
|
| 158 |
-
out.append({"title": title, "url": link, "via": src, "excerpt": title})
|
| 159 |
-
except Exception:
|
| 160 |
-
pass
|
| 161 |
-
return out
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def _topic_source_articles(topic, limit=5):
|
| 165 |
-
"""Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
|
| 166 |
-
try:
|
| 167 |
-
_ctx, sources = base.web_context(topic, limit=limit)
|
| 168 |
-
except Exception:
|
| 169 |
-
sources = []
|
| 170 |
-
if not sources:
|
| 171 |
-
sources = _direct_news_rss(topic, limit=10)
|
| 172 |
-
out, seen = [], set()
|
| 173 |
-
for s in (sources or [])[:limit * 3]:
|
| 174 |
-
url = s.get("url") or ""
|
| 175 |
-
if not url.startswith("http") or url in seen:
|
| 176 |
-
continue
|
| 177 |
-
seen.add(url)
|
| 178 |
-
try:
|
| 179 |
-
page = base.scrape_any_url(url)
|
| 180 |
-
raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
|
| 181 |
-
if len(raw) < 180:
|
| 182 |
-
continue
|
| 183 |
-
title = page.get("title") or s.get("title") or url
|
| 184 |
-
via = page.get("via") or s.get("via") or base._domain(url)
|
| 185 |
-
out.append({
|
| 186 |
-
"title": title,
|
| 187 |
-
"url": url,
|
| 188 |
-
"raw": raw,
|
| 189 |
-
"image": page.get("image") or "",
|
| 190 |
-
"via": via,
|
| 191 |
-
"source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
|
| 192 |
-
})
|
| 193 |
-
if len(out) >= limit:
|
| 194 |
-
break
|
| 195 |
-
except Exception:
|
| 196 |
-
continue
|
| 197 |
-
if not out:
|
| 198 |
-
for s in (sources or _direct_news_rss(topic, 6))[:limit]:
|
| 199 |
-
title = s.get("title") or topic
|
| 200 |
-
excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
|
| 201 |
-
url = s.get("url", "")
|
| 202 |
-
via = s.get("via") or base._domain(url)
|
| 203 |
-
out.append({
|
| 204 |
-
"title": title,
|
| 205 |
-
"url": url,
|
| 206 |
-
"raw": excerpt,
|
| 207 |
-
"image": base.pollinations_image_url(title),
|
| 208 |
-
"via": via,
|
| 209 |
-
"source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
|
| 210 |
-
})
|
| 211 |
-
return out[:limit]
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
|
| 215 |
-
errors = []
|
| 216 |
-
token = base._hf_token()
|
| 217 |
-
try:
|
| 218 |
-
original = getattr(base, "_original_qwen_generate", None)
|
| 219 |
-
if original:
|
| 220 |
-
txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
|
| 221 |
-
if txt:
|
| 222 |
-
base.LAST_QWEN_ERROR = ""
|
| 223 |
-
return txt
|
| 224 |
-
if getattr(base, "LAST_QWEN_ERROR", ""):
|
| 225 |
-
errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
|
| 226 |
-
except Exception as e:
|
| 227 |
-
errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
|
| 228 |
-
if token:
|
| 229 |
-
models = []
|
| 230 |
-
for m in [
|
| 231 |
-
os.getenv("QWEN_VL_MODEL", ""),
|
| 232 |
-
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 233 |
-
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 234 |
-
"Qwen/Qwen2.5-7B-Instruct",
|
| 235 |
-
"Qwen/Qwen2.5-3B-Instruct",
|
| 236 |
-
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 237 |
-
]:
|
| 238 |
-
if m and m not in models:
|
| 239 |
-
models.append(m)
|
| 240 |
-
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
| 241 |
-
for model in models:
|
| 242 |
-
try:
|
| 243 |
-
is_vl = "VL" in model and bool(image_url)
|
| 244 |
-
user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
|
| 245 |
-
payload = {
|
| 246 |
-
"model": model,
|
| 247 |
-
"messages": [
|
| 248 |
-
{"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
|
| 249 |
-
{"role": "user", "content": user_content},
|
| 250 |
-
],
|
| 251 |
-
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 252 |
-
"temperature": 0.35,
|
| 253 |
-
"top_p": 0.85,
|
| 254 |
-
}
|
| 255 |
-
r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
|
| 256 |
-
if r.status_code >= 300:
|
| 257 |
-
errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
|
| 258 |
-
continue
|
| 259 |
-
j = r.json()
|
| 260 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 261 |
-
if txt:
|
| 262 |
-
base.LAST_QWEN_ERROR = ""
|
| 263 |
-
return txt
|
| 264 |
-
errors.append(f"{model}: empty response")
|
| 265 |
-
except Exception as e:
|
| 266 |
-
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 267 |
-
else:
|
| 268 |
-
errors.append("missing HF_TOKEN")
|
| 269 |
-
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 270 |
-
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 271 |
-
return _fallback_summary_from_prompt(prompt, max_units=12)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
if not hasattr(base, "_original_qwen_generate"):
|
| 275 |
-
base._original_qwen_generate = base.qwen_generate
|
| 276 |
-
base.qwen_generate = qwen_generate_resilient
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
@app.get('/api/wall')
|
| 280 |
-
def compat_wall():
|
| 281 |
-
return JSONResponse({'posts': base._load_ai_wall()[:80]})
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
_PATCHED_PATHS = {
|
| 285 |
-
('/api/topic_post', 'POST'),
|
| 286 |
-
('/api/url_wall', 'POST'),
|
| 287 |
-
('/api/rewrite_share', 'POST'),
|
| 288 |
-
('/api/ai/short/{post_id}', 'POST'),
|
| 289 |
-
}
|
| 290 |
-
app.router.routes = [
|
| 291 |
-
r for r in app.router.routes
|
| 292 |
-
if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
|
| 293 |
-
]
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
@app.post('/api/topic_post')
|
| 297 |
-
async def compat_topic_post(request: Request):
|
| 298 |
-
body = await request.json()
|
| 299 |
-
topic = base._clean_text(body.get('topic', ''))
|
| 300 |
-
if not topic:
|
| 301 |
-
return JSONResponse({'error': 'missing topic'}, status_code=400)
|
| 302 |
-
articles = _topic_source_articles(topic, limit=4)
|
| 303 |
-
if not articles:
|
| 304 |
-
return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
|
| 305 |
-
new_posts = []
|
| 306 |
-
posts = base._load_ai_wall()
|
| 307 |
-
for art in articles:
|
| 308 |
-
prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
|
| 309 |
-
|
| 310 |
-
Chủ đề lọc: {topic}
|
| 311 |
-
Tiêu đề bài nguồn: {art['title']}
|
| 312 |
-
Nguồn: {art['via']}
|
| 313 |
-
|
| 314 |
-
Yêu cầu bắt buộc:
|
| 315 |
-
- Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
|
| 316 |
-
- Không trộn với bài khác.
|
| 317 |
-
- Không viết lại toàn bộ bài.
|
| 318 |
-
- Không lặp ý.
|
| 319 |
-
- 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 320 |
-
- Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
|
| 321 |
-
|
| 322 |
-
Nội dung bài:
|
| 323 |
-
{art['raw'][:14000]}"""
|
| 324 |
-
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
|
| 325 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 326 |
-
src = [art['source']]
|
| 327 |
-
if 'Nguồn tham khảo:' not in text:
|
| 328 |
-
text += "\n\n" + _source_line(src)
|
| 329 |
-
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
| 330 |
-
|
| 331 |
-
# Generate slides for this post so they persist after page reload
|
| 332 |
-
try:
|
| 333 |
-
page_data = _scrape_article_images(art.get('url', ''))
|
| 334 |
-
if page_data and page_data.get('paragraphs'):
|
| 335 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 336 |
-
if key_points:
|
| 337 |
-
relevant_imgs = page_data.get('images', [])
|
| 338 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 339 |
-
relevant_imgs = [page_data['og_img']]
|
| 340 |
-
slides = []
|
| 341 |
-
for i, point in enumerate(key_points):
|
| 342 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 343 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 344 |
-
post['slides'] = slides
|
| 345 |
-
except Exception:
|
| 346 |
-
pass
|
| 347 |
-
|
| 348 |
-
new_posts.append(post)
|
| 349 |
-
posts = new_posts + posts
|
| 350 |
-
base._save_ai_wall(posts)
|
| 351 |
-
return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
@app.post('/api/url_wall')
|
| 355 |
-
async def compat_url_wall(request: Request):
|
| 356 |
-
body = await request.json()
|
| 357 |
-
url = base._clean_text(body.get('url', ''))
|
| 358 |
-
if not url.startswith('http'):
|
| 359 |
-
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 360 |
-
try:
|
| 361 |
-
data = base.scrape_any_url(url)
|
| 362 |
-
except Exception as e:
|
| 363 |
-
return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
|
| 364 |
-
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 365 |
-
if len(raw) < 120:
|
| 366 |
-
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 367 |
-
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 368 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 369 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 370 |
-
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 371 |
-
if 'Nguồn tham khảo:' not in text:
|
| 372 |
-
text += "\n\n" + _source_line(src)
|
| 373 |
-
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 374 |
-
|
| 375 |
-
# Generate slides so they persist after page reload
|
| 376 |
-
slides = []
|
| 377 |
-
try:
|
| 378 |
-
page_data = _scrape_article_images(url)
|
| 379 |
-
if page_data and page_data.get('paragraphs'):
|
| 380 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 381 |
-
if key_points:
|
| 382 |
-
relevant_imgs = page_data.get('images', [])
|
| 383 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 384 |
-
relevant_imgs = [page_data['og_img']]
|
| 385 |
-
for i, point in enumerate(key_points):
|
| 386 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 387 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 388 |
-
except Exception:
|
| 389 |
-
pass
|
| 390 |
-
post['slides'] = slides
|
| 391 |
-
|
| 392 |
-
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 393 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
def _is_relevant_image(img_url, title, text):
|
| 397 |
-
"""Check if an image is relevant to the article content."""
|
| 398 |
-
if not img_url:
|
| 399 |
-
return False
|
| 400 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 401 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 402 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 403 |
-
img_lower = img_url.lower()
|
| 404 |
-
for p in skip_patterns:
|
| 405 |
-
if p in img_lower:
|
| 406 |
-
return False
|
| 407 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 408 |
-
return False
|
| 409 |
-
return True
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 413 |
-
"""Filter and rank images by relevance to article content."""
|
| 414 |
-
if not images:
|
| 415 |
-
return []
|
| 416 |
-
seen = set()
|
| 417 |
-
relevant = []
|
| 418 |
-
for img in images:
|
| 419 |
-
if img in seen:
|
| 420 |
-
continue
|
| 421 |
-
seen.add(img)
|
| 422 |
-
if _is_relevant_image(img, title, text):
|
| 423 |
-
relevant.append(img)
|
| 424 |
-
return relevant[:max_images]
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
def _extract_key_points_for_slides(paragraphs, max_points=12):
|
| 428 |
-
"""Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
|
| 429 |
-
points = []
|
| 430 |
-
for p in paragraphs:
|
| 431 |
-
if len(points) >= max_points:
|
| 432 |
-
break
|
| 433 |
-
p = _clean(p)
|
| 434 |
-
if not p:
|
| 435 |
-
continue
|
| 436 |
-
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 437 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 438 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 439 |
-
|
| 440 |
-
for sentence in sentences:
|
| 441 |
-
if len(points) >= max_points:
|
| 442 |
-
break
|
| 443 |
-
sentence = _clean(sentence)
|
| 444 |
-
if len(sentence) < 30:
|
| 445 |
-
continue
|
| 446 |
-
if any(sentence[:60] in existing for existing in points):
|
| 447 |
-
continue
|
| 448 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 449 |
-
sentence = sentence + '.'
|
| 450 |
-
points.append(sentence)
|
| 451 |
-
return points
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
def _scrape_article_images(url):
|
| 455 |
-
"""Scrape article page and return only relevant images."""
|
| 456 |
-
try:
|
| 457 |
-
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 458 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 459 |
-
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 460 |
-
r.encoding = 'utf-8'
|
| 461 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 462 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 463 |
-
tag.decompose()
|
| 464 |
-
h1 = soup.find('h1')
|
| 465 |
-
ogt = soup.find('meta', property='og:title')
|
| 466 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 467 |
-
ogi = soup.find('meta', property='og:image')
|
| 468 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 469 |
-
if og_img and og_img.startswith('//'):
|
| 470 |
-
og_img = 'https:' + og_img
|
| 471 |
-
block = None
|
| 472 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 473 |
-
el = soup.select_one(sel)
|
| 474 |
-
if el and len(el.find_all('p')) >= 2:
|
| 475 |
-
block = el
|
| 476 |
-
break
|
| 477 |
-
if not block:
|
| 478 |
-
block = soup.body or soup
|
| 479 |
-
paragraphs = []
|
| 480 |
-
all_images = []
|
| 481 |
-
seen_imgs = set()
|
| 482 |
-
if og_img and og_img not in seen_imgs:
|
| 483 |
-
all_images.append(og_img)
|
| 484 |
-
seen_imgs.add(og_img)
|
| 485 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 486 |
-
if el.name == 'p':
|
| 487 |
-
t = _clean(el.get_text(strip=True))
|
| 488 |
-
if t and len(t) > 40:
|
| 489 |
-
paragraphs.append(t)
|
| 490 |
-
elif el.name in ('figure', 'img'):
|
| 491 |
-
im = el if el.name == 'img' else el.find('img')
|
| 492 |
-
if im:
|
| 493 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 494 |
-
if src and 'base64' not in src:
|
| 495 |
-
if src.startswith('//'):
|
| 496 |
-
src = 'https:' + src
|
| 497 |
-
if src not in seen_imgs:
|
| 498 |
-
all_images.append(src)
|
| 499 |
-
seen_imgs.add(src)
|
| 500 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 501 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 502 |
-
except Exception:
|
| 503 |
-
return None
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
@app.post('/api/rewrite_share')
|
| 507 |
-
async def compat_rewrite_share(request: Request):
|
| 508 |
-
body = await request.json()
|
| 509 |
-
url = base._clean_text(body.get('url', ''))
|
| 510 |
-
if not url.startswith('http'):
|
| 511 |
-
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 512 |
-
try:
|
| 513 |
-
data = base.scrape_any_url(url)
|
| 514 |
-
except Exception as e:
|
| 515 |
-
return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
|
| 516 |
-
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 517 |
-
if len(raw) < 120:
|
| 518 |
-
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 519 |
-
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 520 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 521 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 522 |
-
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 523 |
-
if 'Nguồn tham khảo:' not in text:
|
| 524 |
-
text += "\n\n" + _source_line(src)
|
| 525 |
-
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 526 |
-
|
| 527 |
-
# Generate slides with relevant images only
|
| 528 |
-
slides = []
|
| 529 |
-
page_data = _scrape_article_images(url)
|
| 530 |
-
if page_data and page_data.get('paragraphs'):
|
| 531 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 532 |
-
if key_points:
|
| 533 |
-
relevant_imgs = page_data.get('images', [])
|
| 534 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 535 |
-
relevant_imgs = [page_data['og_img']]
|
| 536 |
-
for i, point in enumerate(key_points):
|
| 537 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 538 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 539 |
-
|
| 540 |
-
# FIX: Save slides into post so they persist after page reload
|
| 541 |
-
post['slides'] = slides
|
| 542 |
-
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 543 |
-
|
| 544 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
def _emotion_script(text, emotion):
|
| 548 |
-
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 549 |
-
|
| 550 |
-
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 551 |
-
The emotion is still used for voice selection but content is read cleanly.
|
| 552 |
-
"""
|
| 553 |
-
text = _clean(text)
|
| 554 |
-
# REMOVED: No prefix added to keep content clean and natural
|
| 555 |
-
return text
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
def _tts_script_smart(post, emotion):
|
| 559 |
-
raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
|
| 560 |
-
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 561 |
-
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 562 |
-
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 563 |
-
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 564 |
-
# REMOVED: _emotion_script call - read content cleanly without prefix
|
| 565 |
-
# INCREASED to 3000 to read full content of all bullet points
|
| 566 |
-
if len(raw) > 3000:
|
| 567 |
-
raw = raw[:3000]
|
| 568 |
-
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 569 |
-
if cut > 700:
|
| 570 |
-
raw = raw[:cut + 1]
|
| 571 |
-
return raw
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
def _split_subtitle_sentences(script):
|
| 575 |
-
parts = []
|
| 576 |
-
for line in script.splitlines():
|
| 577 |
-
line = _clean(line)
|
| 578 |
-
if not line:
|
| 579 |
-
continue
|
| 580 |
-
for s in re.split(r"(?<=[\.\!\?])\s+", line):
|
| 581 |
-
s = _clean(s)
|
| 582 |
-
if 8 <= len(s) <= 140:
|
| 583 |
-
parts.append(s)
|
| 584 |
-
return parts[:12]
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
def _srt_time(sec):
|
| 588 |
-
ms = int((sec - int(sec)) * 1000)
|
| 589 |
-
sec = int(sec)
|
| 590 |
-
h = sec // 3600
|
| 591 |
-
m = (sec % 3600) // 60
|
| 592 |
-
s = sec % 60
|
| 593 |
-
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
def _write_srt(script, path, total_duration=30):
|
| 597 |
-
subs = _split_subtitle_sentences(script)
|
| 598 |
-
if not subs:
|
| 599 |
-
subs = [script[:120]]
|
| 600 |
-
dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
|
| 601 |
-
cur = 0.3
|
| 602 |
-
with open(path, 'w', encoding='utf-8') as f:
|
| 603 |
-
for i, s in enumerate(subs, 1):
|
| 604 |
-
start = cur
|
| 605 |
-
end = cur + dur
|
| 606 |
-
cur = end + 0.15
|
| 607 |
-
f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
def _wrap_text_px(draw, text, font, max_width, max_lines):
|
| 611 |
-
words = _clean(text).split()
|
| 612 |
-
lines, cur = [], ""
|
| 613 |
-
for w in words:
|
| 614 |
-
test = (cur + " " + w).strip()
|
| 615 |
-
try:
|
| 616 |
-
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 617 |
-
except Exception:
|
| 618 |
-
width = len(test) * 20
|
| 619 |
-
if width <= max_width:
|
| 620 |
-
cur = test
|
| 621 |
-
else:
|
| 622 |
-
if cur:
|
| 623 |
-
lines.append(cur)
|
| 624 |
-
cur = w
|
| 625 |
-
if len(lines) >= max_lines:
|
| 626 |
-
break
|
| 627 |
-
if cur and len(lines) < max_lines:
|
| 628 |
-
lines.append(cur)
|
| 629 |
-
return lines
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
def _make_short_frame_full(post, img_path, out_path):
|
| 633 |
-
if Image is None:
|
| 634 |
-
return base._make_short_frame(post, img_path, out_path)
|
| 635 |
-
W, H = 1080, 1920
|
| 636 |
-
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 637 |
-
try:
|
| 638 |
-
im = Image.open(img_path).convert("RGB")
|
| 639 |
-
target = (1080, 760)
|
| 640 |
-
im_ratio = im.width / im.height
|
| 641 |
-
target_ratio = target[0] / target[1]
|
| 642 |
-
if im_ratio > target_ratio:
|
| 643 |
-
new_h = target[1]
|
| 644 |
-
new_w = int(new_h * im_ratio)
|
| 645 |
-
else:
|
| 646 |
-
new_w = target[0]
|
| 647 |
-
new_h = int(new_w / im_ratio)
|
| 648 |
-
im = im.resize((new_w, new_h))
|
| 649 |
-
left = (new_w - target[0]) // 2
|
| 650 |
-
top = (new_h - target[1]) // 2
|
| 651 |
-
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 652 |
-
bg.paste(im, (0, 0))
|
| 653 |
-
except Exception:
|
| 654 |
-
pass
|
| 655 |
-
draw = ImageDraw.Draw(bg)
|
| 656 |
-
try:
|
| 657 |
-
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 658 |
-
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 659 |
-
font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
|
| 660 |
-
except Exception:
|
| 661 |
-
font_title = font_body = font_label = None
|
| 662 |
-
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 663 |
-
margin = 48
|
| 664 |
-
maxw = W - margin * 2
|
| 665 |
-
draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
|
| 666 |
-
y = 830
|
| 667 |
-
for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
|
| 668 |
-
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 669 |
-
y += 66
|
| 670 |
-
y += 18
|
| 671 |
-
text = post.get("text", "")
|
| 672 |
-
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 673 |
-
body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
|
| 674 |
-
for ln in body_lines:
|
| 675 |
-
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 676 |
-
y += 50
|
| 677 |
-
if y > 1640:
|
| 678 |
-
break
|
| 679 |
-
bg.save(out_path, quality=92)
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
def _summary_segments_from_post(post, max_segments=25):
|
| 685 |
-
raw = _clean(post.get('text') or post.get('title') or '')
|
| 686 |
-
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 687 |
-
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
| 688 |
-
lines=[]
|
| 689 |
-
for ln in raw.splitlines():
|
| 690 |
-
ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
|
| 691 |
-
if not ln: continue
|
| 692 |
-
low=ln.lower()
|
| 693 |
-
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 694 |
-
if len(ln)>=18: lines.append(ln)
|
| 695 |
-
if len(lines)<3:
|
| 696 |
-
lines=[]
|
| 697 |
-
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 698 |
-
s=_clean(s)
|
| 699 |
-
if len(s)>=25: lines.append(s)
|
| 700 |
-
segs=_dedupe_units(lines, max_units=max_segments)
|
| 701 |
-
return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
|
| 705 |
-
if Image is None:
|
| 706 |
-
return _make_short_frame_full(post, img_path, out_path)
|
| 707 |
-
W,H=1080,1920
|
| 708 |
-
bg=Image.new('RGB',(W,H),(10,10,10))
|
| 709 |
-
try:
|
| 710 |
-
im=Image.open(img_path).convert('RGB')
|
| 711 |
-
ratio=im.width/max(1,im.height); target=W/H
|
| 712 |
-
if ratio>target:
|
| 713 |
-
nh=H; nw=int(nh*ratio)
|
| 714 |
-
else:
|
| 715 |
-
nw=W; nh=int(nw/ratio)
|
| 716 |
-
cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
|
| 717 |
-
cover=cover.crop((left,top,left+W,top+H))
|
| 718 |
-
bg.paste(cover,(0,0))
|
| 719 |
-
bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
|
| 720 |
-
hero_h=720; target=W/hero_h
|
| 721 |
-
if ratio>target:
|
| 722 |
-
nh=hero_h; nw=int(nh*ratio)
|
| 723 |
-
else:
|
| 724 |
-
nw=W; nh=int(nw/ratio)
|
| 725 |
-
hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
|
| 726 |
-
hero=hero.crop((left,top,left+W,top+hero_h))
|
| 727 |
-
bg.paste(hero,(0,0))
|
| 728 |
-
except Exception:
|
| 729 |
-
pass
|
| 730 |
-
draw=ImageDraw.Draw(bg)
|
| 731 |
-
try:
|
| 732 |
-
font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
|
| 733 |
-
font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
|
| 734 |
-
font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 735 |
-
font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
|
| 736 |
-
except Exception:
|
| 737 |
-
font_brand=font_small=font_seg=font_title=None
|
| 738 |
-
draw.rectangle((0,680,W,H), fill=(12,12,12))
|
| 739 |
-
dot_x=48; dot_y=742
|
| 740 |
-
for i in range(total):
|
| 741 |
-
fill=(92,184,122) if i==idx else (70,70,70)
|
| 742 |
-
draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
|
| 743 |
-
draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
|
| 744 |
-
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 745 |
-
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 746 |
-
y=940; maxw=W-96
|
| 747 |
-
# INCREASED from 12 to 18 for full content display - each key point can span multiple lines
|
| 748 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
|
| 749 |
-
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 750 |
-
y+=74
|
| 751 |
-
if y>1500: break
|
| 752 |
-
y2=1640
|
| 753 |
-
draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
|
| 754 |
-
for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
|
| 755 |
-
draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
|
| 756 |
-
y2+=46
|
| 757 |
-
bg.save(out_path, quality=92)
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
def _estimate_audio_duration(path, fallback=15.0):
|
| 761 |
-
"""Estimate audio duration with 15s minimum per segment for complete bullet reading."""
|
| 762 |
-
try:
|
| 763 |
-
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 764 |
-
return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
|
| 765 |
-
except Exception:
|
| 766 |
-
return fallback
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
@app.post('/api/ai/short/{post_id}')
|
| 770 |
-
async def patched_ai_short(post_id: str, request: Request):
|
| 771 |
-
try:
|
| 772 |
-
body = await request.json()
|
| 773 |
-
except Exception:
|
| 774 |
-
body = {}
|
| 775 |
-
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 776 |
-
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 777 |
-
speed = float(body.get('speed', 1.0) or 1.0)
|
| 778 |
-
speed = max(0.85, min(1.35, speed))
|
| 779 |
-
|
| 780 |
-
posts = base._load_ai_wall()
|
| 781 |
-
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 782 |
-
if not post:
|
| 783 |
-
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 784 |
-
|
| 785 |
-
segments = _summary_segments_from_post(post, max_segments=25)
|
| 786 |
-
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 787 |
-
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 788 |
-
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
| 789 |
-
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 790 |
-
if os.path.exists(out_mp4):
|
| 791 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 792 |
-
post['short_voice'] = voice
|
| 793 |
-
post['short_emotion'] = emotion
|
| 794 |
-
post['short_speed'] = speed
|
| 795 |
-
post['short_segments'] = segments
|
| 796 |
-
post['short_subtitles'] = False
|
| 797 |
-
base._save_ai_wall(posts)
|
| 798 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 799 |
-
if base.gTTS is None:
|
| 800 |
-
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 801 |
-
|
| 802 |
-
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 803 |
-
os.makedirs(work, exist_ok=True)
|
| 804 |
-
img = os.path.join(work, 'image.jpg')
|
| 805 |
-
try:
|
| 806 |
-
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 807 |
-
edge_voice = {
|
| 808 |
-
# Vietnamese
|
| 809 |
-
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 810 |
-
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 811 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 812 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 813 |
-
'nam': 'vi-VN-NamMinhNeural',
|
| 814 |
-
'male': 'vi-VN-NamMinhNeural',
|
| 815 |
-
'nu': 'vi-VN-HoaiMyNeural',
|
| 816 |
-
'female': 'vi-VN-HoaiMyNeural',
|
| 817 |
-
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 818 |
-
# English - Multilingual
|
| 819 |
-
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 820 |
-
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 821 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 822 |
-
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 823 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 824 |
-
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 825 |
-
# Portuguese - Multilingual (ONLY Thalita)
|
| 826 |
-
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 827 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 828 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 829 |
-
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 830 |
-
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 831 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 832 |
-
# French - Multilingual
|
| 833 |
-
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 834 |
-
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 835 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 836 |
-
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 837 |
-
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 838 |
-
# German - Multilingual
|
| 839 |
-
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 840 |
-
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 841 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 842 |
-
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 843 |
-
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 844 |
-
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 845 |
-
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 846 |
-
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 847 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 848 |
-
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 849 |
-
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 850 |
-
# Italian - Multilingual
|
| 851 |
-
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 852 |
-
# Spanish (keep for backward compat)
|
| 853 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 854 |
-
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 855 |
-
'es': 'en-US-AndrewMultilingualNeural',
|
| 856 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 857 |
-
# Japanese (keep for backward compat)
|
| 858 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 859 |
-
'ja': 'en-US-AndrewMultilingualNeural',
|
| 860 |
-
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 861 |
-
# Chinese (keep for backward compat)
|
| 862 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 863 |
-
'zh': 'en-US-AndrewMultilingualNeural',
|
| 864 |
-
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 865 |
-
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 866 |
-
part_files=[]
|
| 867 |
-
for idx, seg in enumerate(segments):
|
| 868 |
-
frame=os.path.join(work,f'frame_{idx:02d}.jpg')
|
| 869 |
-
aud=os.path.join(work,f'voice_{idx:02d}.mp3')
|
| 870 |
-
aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
|
| 871 |
-
part=os.path.join(work,f'part_{idx:02d}.mp4')
|
| 872 |
-
_make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
|
| 873 |
-
spoken=_emotion_script(seg, emotion)
|
| 874 |
-
try:
|
| 875 |
-
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 876 |
-
except Exception:
|
| 877 |
-
tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
|
| 878 |
-
try:
|
| 879 |
-
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 880 |
-
except TypeError:
|
| 881 |
-
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 882 |
-
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 883 |
-
dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
|
| 884 |
-
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 885 |
-
part_files.append(part)
|
| 886 |
-
concat=os.path.join(work,'concat.txt')
|
| 887 |
-
with open(concat,'w',encoding='utf-8') as f:
|
| 888 |
-
for p in part_files:
|
| 889 |
-
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 890 |
-
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 891 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 892 |
-
post['short_voice'] = voice
|
| 893 |
-
post['short_emotion'] = emotion
|
| 894 |
-
post['short_speed'] = speed
|
| 895 |
-
post['short_segments'] = segments
|
| 896 |
-
post['short_subtitles'] = False
|
| 897 |
-
base._save_ai_wall(posts)
|
| 898 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 899 |
-
except Exception as e:
|
| 900 |
-
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
@app.get('/api/ai/short-file/{file_id}')
|
| 904 |
-
def patched_ai_short_file(file_id: str):
|
| 905 |
-
path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
|
| 906 |
-
if not os.path.exists(path):
|
| 907 |
-
return JSONResponse({'error': 'not found'}, status_code=404)
|
| 908 |
-
return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
@app.get('/api/ai_shorts')
|
| 912 |
-
def api_ai_shorts():
|
| 913 |
-
posts = [p for p in base._load_ai_wall() if p.get('video')]
|
| 914 |
-
return JSONResponse({'posts': posts[:80]})
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app_v2_entry.py
DELETED
|
@@ -1,2166 +0,0 @@
|
|
| 1 |
-
"""VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS"""
|
| 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 |
-
try:
|
| 11 |
-
import ai_patch
|
| 12 |
-
except Exception as e:
|
| 13 |
-
print(f"[WARN] ai_patch import failed: {e}")
|
| 14 |
-
|
| 15 |
-
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 16 |
-
from fastapi.staticfiles import StaticFiles
|
| 17 |
-
from starlette.routing import Mount
|
| 18 |
-
from fastapi import Query, Request, UploadFile, File, Form
|
| 19 |
-
import requests as req
|
| 20 |
-
from bs4 import BeautifulSoup
|
| 21 |
-
import re, html as html_lib, json, threading, time, uuid
|
| 22 |
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 23 |
-
from urllib.parse import quote
|
| 24 |
-
import asyncio
|
| 25 |
-
|
| 26 |
-
HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
|
| 27 |
-
|
| 28 |
-
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 29 |
-
SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
|
| 30 |
-
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()))]
|
| 31 |
-
app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
|
| 32 |
-
app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
|
| 33 |
-
|
| 34 |
-
def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
|
| 35 |
-
|
| 36 |
-
# Cache for match details (5 min TTL)
|
| 37 |
-
_match_cache = {}
|
| 38 |
-
|
| 39 |
-
# === FAST BONGDA PROXY ENDPOINT ===
|
| 40 |
-
def _get_match_detail(event_id, slug=None):
|
| 41 |
-
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
|
| 42 |
-
if slug:
|
| 43 |
-
url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
|
| 44 |
-
else:
|
| 45 |
-
url = f"https://bongda.com.vn/tran-dau/{event_id}"
|
| 46 |
-
resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 47 |
-
if resp.status_code != 200:
|
| 48 |
-
return None
|
| 49 |
-
soup = BeautifulSoup(resp.text, 'html.parser')
|
| 50 |
-
result = {"event_id": event_id, "found": False, "sections": []}
|
| 51 |
-
info = {}
|
| 52 |
-
tel = soup.select_one('.teams')
|
| 53 |
-
if tel:
|
| 54 |
-
he = tel.select_one('.team.home')
|
| 55 |
-
if he:
|
| 56 |
-
p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
|
| 57 |
-
if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
|
| 58 |
-
lo = he.select_one('img')
|
| 59 |
-
if lo: info['home_logo'] = lo.get('src', '')
|
| 60 |
-
ae = tel.select_one('.team.away')
|
| 61 |
-
if ae:
|
| 62 |
-
p_tags = ae.select('p')
|
| 63 |
-
team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
|
| 64 |
-
if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
|
| 65 |
-
lo = ae.select_one('img')
|
| 66 |
-
if lo: info['away_logo'] = lo.get('src', '')
|
| 67 |
-
sc = tel.select_one('.score')
|
| 68 |
-
if sc:
|
| 69 |
-
parts = [_clean(p.get_text()) for p in sc.select('p')]
|
| 70 |
-
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 71 |
-
lb = sc.select_one('.label')
|
| 72 |
-
if lb: info['status_label'] = _clean(lb.get_text())
|
| 73 |
-
if info.get('home_team') and info.get('away_team'):
|
| 74 |
-
result['info'] = info
|
| 75 |
-
result['found'] = True
|
| 76 |
-
result['sections'].append('info')
|
| 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 |
-
parent = ev.parent
|
| 82 |
-
if parent:
|
| 83 |
-
h2 = parent.find('h2')
|
| 84 |
-
if h2: ev_data['period'] = _clean(h2.get_text())
|
| 85 |
-
if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
|
| 86 |
-
elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
|
| 87 |
-
elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
|
| 88 |
-
elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
|
| 89 |
-
players_el = ev.select_one('.players')
|
| 90 |
-
if players_el:
|
| 91 |
-
pl_text = _clean(players_el.get_text(' ', strip=True))
|
| 92 |
-
m = re.match(r"(\d+)'(.*)", pl_text)
|
| 93 |
-
if m:
|
| 94 |
-
ev_data['time'] = f"{m.group(1)}'"
|
| 95 |
-
ev_data['players'] = m.group(2)
|
| 96 |
-
else:
|
| 97 |
-
ev_data['players'] = pl_text
|
| 98 |
-
events.append(ev_data)
|
| 99 |
-
if events:
|
| 100 |
-
result['events'] = events
|
| 101 |
-
result['sections'].append('events')
|
| 102 |
-
pred = soup.select_one('.prediction-card')
|
| 103 |
-
if pred:
|
| 104 |
-
team_info = pred.select_one('.team-info')
|
| 105 |
-
if team_info:
|
| 106 |
-
teams = team_info.select('.team')
|
| 107 |
-
pred_data = {}
|
| 108 |
-
if len(teams) >= 2:
|
| 109 |
-
pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
|
| 110 |
-
pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
|
| 111 |
-
divider = team_info.select_one('.divider')
|
| 112 |
-
if divider: pred_data['result'] = _clean(divider.get_text())
|
| 113 |
-
vc = pred.select_one('.vote-count')
|
| 114 |
-
if vc: pred_data['vote_count'] = _clean(vc.get_text())
|
| 115 |
-
result['prediction'] = pred_data
|
| 116 |
-
recent = []
|
| 117 |
-
ml = soup.select_one('.matches-list')
|
| 118 |
-
if ml:
|
| 119 |
-
for item in ml.select('.match-detail, .match-item, li'):
|
| 120 |
-
de = item.select_one('.date, .time')
|
| 121 |
-
le = item.select_one('.league')
|
| 122 |
-
he_item = item.select_one('.home, .team-home')
|
| 123 |
-
ae_item = item.select_one('.away, .team-away')
|
| 124 |
-
se = item.select_one('.score, .result')
|
| 125 |
-
if he_item or ae_item:
|
| 126 |
-
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'})
|
| 127 |
-
if recent:
|
| 128 |
-
result['recent_matches'] = recent
|
| 129 |
-
result['sections'].append('recent')
|
| 130 |
-
try:
|
| 131 |
-
api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
|
| 132 |
-
ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
|
| 133 |
-
if ar.status_code == 200:
|
| 134 |
-
ad = ar.json()
|
| 135 |
-
if ad.get('status') == 'success' and ad.get('html'):
|
| 136 |
-
asp = BeautifulSoup(ad['html'], 'html.parser')
|
| 137 |
-
ast = {}
|
| 138 |
-
for row in asp.select('li, tr'):
|
| 139 |
-
cells = row.select('td, span, p')
|
| 140 |
-
if len(cells) >= 3:
|
| 141 |
-
lb = _clean(cells[0].get_text())
|
| 142 |
-
if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
|
| 143 |
-
if ast:
|
| 144 |
-
result['h2h_stats_parsed'] = ast
|
| 145 |
-
result['sections'].append('h2h_stats')
|
| 146 |
-
except: pass
|
| 147 |
-
return result
|
| 148 |
-
|
| 149 |
-
@app.get('/api/proxy/bongda')
|
| 150 |
-
def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
|
| 151 |
-
if event_id is None:
|
| 152 |
-
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 153 |
-
cache_key = f"{event_id}_{slug}"
|
| 154 |
-
now = time.time()
|
| 155 |
-
cached = _match_cache.get(cache_key)
|
| 156 |
-
if cached and now - cached.get('_ts', 0) < 300:
|
| 157 |
-
return JSONResponse(cached)
|
| 158 |
-
try:
|
| 159 |
-
result = _get_match_detail(event_id, slug)
|
| 160 |
-
if result:
|
| 161 |
-
result['_ts'] = now
|
| 162 |
-
_match_cache[cache_key] = result
|
| 163 |
-
return JSONResponse(result)
|
| 164 |
-
except Exception as e:
|
| 165 |
-
err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
|
| 166 |
-
_match_cache[cache_key] = err
|
| 167 |
-
return JSONResponse(err)
|
| 168 |
-
return JSONResponse({"event_id": event_id, "found": False})
|
| 169 |
-
|
| 170 |
-
@app.get('/api/match/{event_id}/detail')
|
| 171 |
-
def api_match_detail(event_id: int, url: str = Query(default=None)):
|
| 172 |
-
slug = None
|
| 173 |
-
if url:
|
| 174 |
-
m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
|
| 175 |
-
if m:
|
| 176 |
-
slug = m.group(1)
|
| 177 |
-
cache_key = f"{event_id}_{slug or ''}"
|
| 178 |
-
now = time.time()
|
| 179 |
-
cached = _match_cache.get(cache_key)
|
| 180 |
-
if cached and now - cached.get('_ts', 0) < 300:
|
| 181 |
-
return JSONResponse(cached)
|
| 182 |
-
try:
|
| 183 |
-
if not slug:
|
| 184 |
-
try:
|
| 185 |
-
home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
| 186 |
-
if home_r.status_code == 200:
|
| 187 |
-
home_soup = BeautifulSoup(home_r.text, 'html.parser')
|
| 188 |
-
for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
|
| 189 |
-
href = a.get('href', '')
|
| 190 |
-
m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
|
| 191 |
-
if m:
|
| 192 |
-
slug = m.group(1)
|
| 193 |
-
cache_key = f"{event_id}_{slug}"
|
| 194 |
-
break
|
| 195 |
-
except: pass
|
| 196 |
-
result = _get_match_detail(event_id, slug)
|
| 197 |
-
if result:
|
| 198 |
-
result['_ts'] = now
|
| 199 |
-
_match_cache[cache_key] = result
|
| 200 |
-
return JSONResponse(result)
|
| 201 |
-
except Exception as e:
|
| 202 |
-
err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
|
| 203 |
-
_match_cache[cache_key] = err
|
| 204 |
-
return JSONResponse(err)
|
| 205 |
-
return JSONResponse({"event_id": event_id, "found": False})
|
| 206 |
-
|
| 207 |
-
_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())
|
| 208 |
-
|
| 209 |
-
def _has_kw(topic,title):
|
| 210 |
-
tl=topic.lower();tt=(title or'').lower()
|
| 211 |
-
if tl in tt:return True
|
| 212 |
-
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
|
| 213 |
-
if not words:return True
|
| 214 |
-
return any(w in tt for w in words)
|
| 215 |
-
|
| 216 |
-
def _s_vnexpress(topic,limit=8):
|
| 217 |
-
items=[]
|
| 218 |
-
try:
|
| 219 |
-
r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
|
| 220 |
-
for art in soup.select('article.item-news')[:limit]:
|
| 221 |
-
a=art.select_one('h2 a, h3 a')
|
| 222 |
-
if a and a.get('href'):
|
| 223 |
-
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 224 |
-
if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
|
| 225 |
-
except:pass
|
| 226 |
-
return items
|
| 227 |
-
|
| 228 |
-
def _s_dantri(topic,limit=8):
|
| 229 |
-
items=[]
|
| 230 |
-
try:
|
| 231 |
-
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')
|
| 232 |
-
for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
|
| 233 |
-
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 234 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 235 |
-
if not href.startswith('http'):href='https://dantri.com.vn'+href
|
| 236 |
-
items.append({'title':t,'url':href,'via':'Dân Trí'})
|
| 237 |
-
if len(items)>=limit:break
|
| 238 |
-
except:pass
|
| 239 |
-
return items
|
| 240 |
-
|
| 241 |
-
def _s_vietnamnet(topic,limit=6):
|
| 242 |
-
items=[]
|
| 243 |
-
try:
|
| 244 |
-
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')
|
| 245 |
-
for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
|
| 246 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 247 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 248 |
-
if not href.startswith('http'):href='https://vietnamnet.vn'+href
|
| 249 |
-
items.append({'title':t,'url':href,'via':'VietNamNet'})
|
| 250 |
-
if len(items)>=limit:break
|
| 251 |
-
except:pass
|
| 252 |
-
return items
|
| 253 |
-
|
| 254 |
-
def _s_bongda(topic,limit=5):
|
| 255 |
-
items=[]
|
| 256 |
-
try:
|
| 257 |
-
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')
|
| 258 |
-
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 259 |
-
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 260 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 261 |
-
if not href.startswith('http'):href='https://bongda.com.vn'+href
|
| 262 |
-
items.append({'title':t,'url':href,'via':'Bóng Đá'})
|
| 263 |
-
if len(items)>=limit:break
|
| 264 |
-
except:pass
|
| 265 |
-
return items
|
| 266 |
-
|
| 267 |
-
def _s_genk(topic,limit=5):
|
| 268 |
-
items=[]
|
| 269 |
-
try:
|
| 270 |
-
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')
|
| 271 |
-
for a in soup.select('a[href$=".chn"]')[:limit*3]:
|
| 272 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 273 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 274 |
-
if href.startswith('/'):href='https://genk.vn'+href
|
| 275 |
-
items.append({'title':t,'url':href,'via':'GenK'})
|
| 276 |
-
if len(items)>=limit:break
|
| 277 |
-
except:pass
|
| 278 |
-
return items
|
| 279 |
-
|
| 280 |
-
def _s_thanhnien(topic,limit=6):
|
| 281 |
-
items=[]
|
| 282 |
-
try:
|
| 283 |
-
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')
|
| 284 |
-
for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
|
| 285 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 286 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 287 |
-
if not href.startswith('http'):href='https://thanhnien.vn'+href
|
| 288 |
-
items.append({'title':t,'url':href,'via':'Thanh Niên'})
|
| 289 |
-
if len(items)>=limit:break
|
| 290 |
-
except:pass
|
| 291 |
-
return items
|
| 292 |
-
|
| 293 |
-
def _s_tuoitre(topic,limit=6):
|
| 294 |
-
items=[]
|
| 295 |
-
try:
|
| 296 |
-
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')
|
| 297 |
-
for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
|
| 298 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 299 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 300 |
-
if not href.startswith('http'):href='https://tuoitre.vn'+href
|
| 301 |
-
items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
|
| 302 |
-
if len(items)>=limit:break
|
| 303 |
-
except:pass
|
| 304 |
-
return items
|
| 305 |
-
|
| 306 |
-
def _s_thethaovanhoa(topic,limit=5):
|
| 307 |
-
items=[]
|
| 308 |
-
try:
|
| 309 |
-
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')
|
| 310 |
-
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 311 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 312 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 313 |
-
if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
|
| 314 |
-
items.append({'title':t,'url':href,'via':'TT&VH'})
|
| 315 |
-
if len(items)>=limit:break
|
| 316 |
-
except:pass
|
| 317 |
-
return items
|
| 318 |
-
|
| 319 |
-
def _search_all(topic,limit=36):
|
| 320 |
-
results={}
|
| 321 |
-
with ThreadPoolExecutor(8) as ex:
|
| 322 |
-
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'}
|
| 323 |
-
for f in as_completed(futs,timeout=14):
|
| 324 |
-
try:results[futs[f]]=f.result()
|
| 325 |
-
except:results[futs[f]]=[]
|
| 326 |
-
srcs=list(results.values());out=[];seen=set()
|
| 327 |
-
for i in range(max((len(s) for s in srcs),default=0)):
|
| 328 |
-
for s in srcs:
|
| 329 |
-
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])
|
| 330 |
-
return out[:limit]
|
| 331 |
-
|
| 332 |
-
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
| 333 |
-
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 334 |
-
|
| 335 |
-
_article_cache = {}
|
| 336 |
-
_article_cache_ttl = 1800
|
| 337 |
-
|
| 338 |
-
_art_session = None
|
| 339 |
-
_art_lock = threading.Lock()
|
| 340 |
-
def _get_art_session():
|
| 341 |
-
global _art_session
|
| 342 |
-
if _art_session is None:
|
| 343 |
-
with _art_lock:
|
| 344 |
-
if _art_session is None:
|
| 345 |
-
_art_session = req.Session()
|
| 346 |
-
_art_session.headers.update({
|
| 347 |
-
"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",
|
| 348 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 349 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 350 |
-
})
|
| 351 |
-
return _art_session
|
| 352 |
-
|
| 353 |
-
def _scrape_article_fast(url):
|
| 354 |
-
from urllib.parse import urlparse
|
| 355 |
-
domain = urlparse(url).netloc
|
| 356 |
-
sess = _get_art_session()
|
| 357 |
-
uas = [
|
| 358 |
-
{"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
|
| 359 |
-
{"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"},
|
| 360 |
-
]
|
| 361 |
-
for ua in uas:
|
| 362 |
-
try:
|
| 363 |
-
r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
|
| 364 |
-
if not r or r.status_code != 200:
|
| 365 |
-
continue
|
| 366 |
-
r.encoding = 'utf-8'
|
| 367 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 368 |
-
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
|
| 369 |
-
tag.decompose()
|
| 370 |
-
title = summary = og_img = ""
|
| 371 |
-
ogt = soup.find('meta', property='og:title')
|
| 372 |
-
if ogt: title = ogt.get('content', '')
|
| 373 |
-
ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
|
| 374 |
-
if ogd: summary = ogd.get('content', '')[:500]
|
| 375 |
-
ogi = soup.find('meta', property='og:image')
|
| 376 |
-
if ogi:
|
| 377 |
-
og_img = ogi.get('content', '')
|
| 378 |
-
if og_img.startswith('//'): og_img = 'https:' + og_img
|
| 379 |
-
h1 = soup.find('h1')
|
| 380 |
-
if not title and h1: title = h1.get_text(strip=True)[:200]
|
| 381 |
-
body = []
|
| 382 |
-
selectors = [
|
| 383 |
-
'.fck_detail', '.sidebar-1',
|
| 384 |
-
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
|
| 385 |
-
'.content-detail', '.main-content-detail', '.box-content',
|
| 386 |
-
'.knc-content', '.article-body', '.detail-body',
|
| 387 |
-
'.article-detail', '.detail-content',
|
| 388 |
-
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 389 |
-
'.entry-content', '#content', '.article-text', '.story-body',
|
| 390 |
-
]
|
| 391 |
-
for sel in selectors:
|
| 392 |
-
el = soup.select_one(sel)
|
| 393 |
-
if el and len(el.find_all('p')) >= 2:
|
| 394 |
-
seen_imgs = set()
|
| 395 |
-
for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
|
| 396 |
-
if child.name == 'p':
|
| 397 |
-
t = child.get_text(strip=True)
|
| 398 |
-
if t and len(t) > 15:
|
| 399 |
-
body.append({'type': 'p', 'text': t})
|
| 400 |
-
elif child.name in ('h2','h3'):
|
| 401 |
-
t = child.get_text(strip=True)
|
| 402 |
-
if t:
|
| 403 |
-
body.append({'type': 'heading', 'text': t})
|
| 404 |
-
elif child.name in ('figure','img'):
|
| 405 |
-
im = child if child.name == 'img' else child.find('img')
|
| 406 |
-
if im:
|
| 407 |
-
src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
|
| 408 |
-
if src and 'base64' not in src and src not in seen_imgs:
|
| 409 |
-
seen_imgs.add(src)
|
| 410 |
-
if src.startswith('//'): src = 'https:' + src
|
| 411 |
-
body.append({'type': 'img', 'src': src})
|
| 412 |
-
if child.name == 'figure':
|
| 413 |
-
cap = child.find('figcaption')
|
| 414 |
-
if cap:
|
| 415 |
-
ct = cap.get_text(strip=True)
|
| 416 |
-
if ct: body.append({'type': 'p', 'text': ct})
|
| 417 |
-
if len(body) >= 2:
|
| 418 |
-
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 419 |
-
'body': body[:50], 'source': domain, 'url': url}
|
| 420 |
-
if title and (summary or og_img):
|
| 421 |
-
fallback = []
|
| 422 |
-
if og_img: fallback.append({'type': 'img', 'src': og_img})
|
| 423 |
-
if summary: fallback.append({'type': 'p', 'text': summary})
|
| 424 |
-
if fallback:
|
| 425 |
-
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 426 |
-
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
| 427 |
-
if title:
|
| 428 |
-
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 429 |
-
'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
|
| 430 |
-
'source': domain, 'url': url, 'fallback': True}
|
| 431 |
-
break
|
| 432 |
-
except Exception:
|
| 433 |
-
continue
|
| 434 |
-
return None
|
| 435 |
-
|
| 436 |
-
@app.get('/api/article')
|
| 437 |
-
def api_article_v2(url: str = Query(...)):
|
| 438 |
-
from urllib.parse import unquote
|
| 439 |
-
safe_url = unquote(url)
|
| 440 |
-
try:
|
| 441 |
-
now = time.time()
|
| 442 |
-
cached = _article_cache.get(safe_url)
|
| 443 |
-
if cached and now - cached['t'] < _article_cache_ttl:
|
| 444 |
-
resp = JSONResponse(cached['d'])
|
| 445 |
-
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 446 |
-
return resp
|
| 447 |
-
data = _scrape_article_fast(safe_url)
|
| 448 |
-
if data and data.get('body'):
|
| 449 |
-
_article_cache[safe_url] = {'d': data, 't': now}
|
| 450 |
-
resp = JSONResponse(data)
|
| 451 |
-
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 452 |
-
return resp
|
| 453 |
-
result = {'error': 'Không đọc được', 'url': safe_url}
|
| 454 |
-
resp = JSONResponse(result)
|
| 455 |
-
resp.headers["Cache-Control"] = "public, max-age=60"
|
| 456 |
-
return resp
|
| 457 |
-
except Exception as e:
|
| 458 |
-
return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
|
| 459 |
-
|
| 460 |
-
_hot_cache={'t':0,'d':[]}
|
| 461 |
-
def _get_hot_topics():
|
| 462 |
-
now=time.time()
|
| 463 |
-
if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
|
| 464 |
-
freq={};display={}
|
| 465 |
-
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']
|
| 466 |
-
for feed_url in feeds:
|
| 467 |
-
try:
|
| 468 |
-
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
|
| 469 |
-
for item in soup.find_all('item')[:12]:
|
| 470 |
-
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 471 |
-
if not title:continue
|
| 472 |
-
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]
|
| 473 |
-
if len(words)<2:continue
|
| 474 |
-
for n in(3,4,2):
|
| 475 |
-
for i in range(max(0,len(words)-n+1)):
|
| 476 |
-
phrase=' '.join(words[i:i+n])
|
| 477 |
-
if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
|
| 478 |
-
except:continue
|
| 479 |
-
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
|
| 480 |
-
for key,count in ranked:
|
| 481 |
-
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)
|
| 482 |
-
if is_dup:continue
|
| 483 |
-
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
|
| 484 |
-
if len(topics)>=20:break
|
| 485 |
-
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']:
|
| 486 |
-
if len(topics)>=24:break
|
| 487 |
-
if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
|
| 488 |
-
_hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
|
| 489 |
-
|
| 490 |
-
@app.get('/api/hot_topics')
|
| 491 |
-
def api_hot_topics():
|
| 492 |
-
resp = JSONResponse({'topics':_get_hot_topics()})
|
| 493 |
-
resp.headers["Cache-Control"] = "public, max-age=120"
|
| 494 |
-
return resp
|
| 495 |
-
@app.get('/')
|
| 496 |
-
async def serve_index():
|
| 497 |
-
p=os.path.join(STATIC_DIR,'index_v2.html')
|
| 498 |
-
if os.path.exists(p):return FileResponse(p,media_type='text/html')
|
| 499 |
-
return HTMLResponse('<h1>VNEWS</h1>')
|
| 500 |
-
@app.get('/api/hashtag/sources')
|
| 501 |
-
def _ht(topic:str=Query(...),page:int=Query(default=0)):
|
| 502 |
-
items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
|
| 503 |
-
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
| 504 |
-
@app.get('/api/categories')
|
| 505 |
-
def _cat():return JSONResponse([])
|
| 506 |
-
@app.get('/api/storage_status')
|
| 507 |
-
def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
| 508 |
-
# ===== SHARE HELPERS: render content pages for shared links =====
|
| 509 |
-
def _render_slides_page(post, safe_title, safe_img, safe_url):
|
| 510 |
-
slides = post.get('slides', [])
|
| 511 |
-
# Get image from post.img or first slide's image
|
| 512 |
-
if not safe_img and slides and slides[0].get('image'):
|
| 513 |
-
safe_img = slides[0].get('image', '')
|
| 514 |
-
# Use text for description if available
|
| 515 |
-
description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
|
| 516 |
-
|
| 517 |
-
# Build canonical URL preserving original query format if url was provided
|
| 518 |
-
if safe_url and safe_url != '/':
|
| 519 |
-
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 520 |
-
else:
|
| 521 |
-
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 522 |
-
|
| 523 |
-
h = f'''<!DOCTYPE html>
|
| 524 |
-
<html lang="vi">
|
| 525 |
-
<head>
|
| 526 |
-
<meta charset="utf-8">
|
| 527 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 528 |
-
<title>{_clean(safe_title)}</title>
|
| 529 |
-
<meta property="og:title" content="{_clean(safe_title)}">
|
| 530 |
-
<meta property="og:image" content="{_clean(safe_img)}">
|
| 531 |
-
<meta property="og:description" content="{description}">
|
| 532 |
-
<meta property="og:url" content="{canonical_url}">
|
| 533 |
-
<link rel="canonical" href="{canonical_url}">
|
| 534 |
-
<style>
|
| 535 |
-
*{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:12px}}
|
| 536 |
-
.slide-card{{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px;max-width:600px;margin-left:auto;margin-right:auto}}
|
| 537 |
-
.slide-num{{color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px}}
|
| 538 |
-
.slide-img{{width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px}}
|
| 539 |
-
.slide-text{{color:#ddd;font-size:14px;line-height:1.6;margin:0}}
|
| 540 |
-
</style>
|
| 541 |
-
</head>
|
| 542 |
-
<body>'''
|
| 543 |
-
for s in slides:
|
| 544 |
-
img_src = s.get('image', '')
|
| 545 |
-
if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src):
|
| 546 |
-
img_tag = f'<img src="/api/proxy/img?url={quote(img_src, safe="")}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
|
| 547 |
-
else:
|
| 548 |
-
img_tag = f'<img src="{_clean(img_src)}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">' if img_src else ''
|
| 549 |
-
h += f'<div class="slide-card"><div class="slide-num">Slide {s.get("index",1)}/{len(slides)}</div>{img_tag}<p class="slide-text">{_clean(s.get("text",""))}</p></div>'
|
| 550 |
-
h += '</body></html>'
|
| 551 |
-
return HTMLResponse(h)
|
| 552 |
-
|
| 553 |
-
def _render_video_page(post, safe_title, safe_img, safe_url):
|
| 554 |
-
video_url = post.get('video', '')
|
| 555 |
-
# Use text for description if available
|
| 556 |
-
description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
|
| 557 |
-
|
| 558 |
-
# Build canonical URL preserving original query format if url was provided
|
| 559 |
-
if safe_url and safe_url != '/':
|
| 560 |
-
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 561 |
-
else:
|
| 562 |
-
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 563 |
-
|
| 564 |
-
h = f'''<!DOCTYPE html>
|
| 565 |
-
<html lang="vi">
|
| 566 |
-
<head>
|
| 567 |
-
<meta charset="utf-8">
|
| 568 |
-
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
| 569 |
-
<title>{_clean(safe_title)}</title>
|
| 570 |
-
<meta property="og:title" content="{_clean(safe_title)}">
|
| 571 |
-
<meta property="og:image" content="{_clean(safe_img)}">
|
| 572 |
-
<meta property="og:description" content="{description}">
|
| 573 |
-
<meta property="og:url" content="{canonical_url}">
|
| 574 |
-
<link rel="canonical" href="{canonical_url}">
|
| 575 |
-
<meta name="twitter:card" content="player">
|
| 576 |
-
<meta name="twitter:player" content="{video_url}">
|
| 577 |
-
<style>
|
| 578 |
-
*{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:0;overflow:hidden}}
|
| 579 |
-
.video-container{{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000}}
|
| 580 |
-
video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#000}}
|
| 581 |
-
.title-bar{{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));padding:40px 16px 16px;text-align:center}}
|
| 582 |
-
.title-text{{color:#fff;font-size:13px;line-height:1.4;max-width:600px;margin:0 auto}}
|
| 583 |
-
</style>
|
| 584 |
-
</head>
|
| 585 |
-
<body>
|
| 586 |
-
<div class="video-container">
|
| 587 |
-
<video src="{_clean(video_url)}" controls autoplay playsinline loop></video>
|
| 588 |
-
<div class="title-bar"><div class="title-text">{_clean(safe_title)}</div></div>
|
| 589 |
-
</div>
|
| 590 |
-
</body></html>'''
|
| 591 |
-
return HTMLResponse(h)
|
| 592 |
-
|
| 593 |
-
@app.get('/s/{slug}')
|
| 594 |
-
async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
|
| 595 |
-
"""SEO-friendly share endpoint with slug in URL path.
|
| 596 |
-
Shows slide content when slug matches a wall post ID, otherwise redirects.
|
| 597 |
-
"""
|
| 598 |
-
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
| 599 |
-
safe_img = _clean(img) if img else ''
|
| 600 |
-
safe_url = _clean(url) if url else '/'
|
| 601 |
-
|
| 602 |
-
# Try to find post by slug (post ID)
|
| 603 |
-
post = None
|
| 604 |
-
try:
|
| 605 |
-
if slug and len(slug) > 5: # Likely a post ID
|
| 606 |
-
posts = _load_wall_posts()
|
| 607 |
-
for p in posts:
|
| 608 |
-
if p.get('id') == slug:
|
| 609 |
-
post = p
|
| 610 |
-
safe_title = p.get('title', safe_title) or safe_title
|
| 611 |
-
safe_img = p.get('img', safe_img) or safe_img
|
| 612 |
-
safe_url = p.get('url', safe_url) or safe_url
|
| 613 |
-
break
|
| 614 |
-
except:
|
| 615 |
-
pass
|
| 616 |
-
|
| 617 |
-
if post and post.get('slides'):
|
| 618 |
-
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 619 |
-
|
| 620 |
-
if post and post.get('video'):
|
| 621 |
-
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 622 |
-
|
| 623 |
-
# Otherwise redirect
|
| 624 |
-
return HTMLResponse(f'''<!DOCTYPE html>
|
| 625 |
-
<html lang="vi">
|
| 626 |
-
<head>
|
| 627 |
-
<meta charset="utf-8">
|
| 628 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 629 |
-
<title>{_clean(safe_title)}</title>
|
| 630 |
-
<meta property="og:title" content="{_clean(safe_title)}">
|
| 631 |
-
<meta property="og:image" content="{_clean(safe_img)}">
|
| 632 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 633 |
-
<meta property="og:url" content="{SPACE}/s/{slug}">
|
| 634 |
-
<link rel="canonical" href="{SPACE}/s/{slug}">
|
| 635 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 636 |
-
</head><body></body></html>''')
|
| 637 |
-
|
| 638 |
-
@app.get('/s')
|
| 639 |
-
async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
| 640 |
-
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
| 641 |
-
safe_img = _clean(img) if img else ''
|
| 642 |
-
safe_url = _clean(url) if url else '/'
|
| 643 |
-
|
| 644 |
-
# Try to find wall post by post_id or URL (prioritize posts with slides/video)
|
| 645 |
-
post = None
|
| 646 |
-
try:
|
| 647 |
-
posts = _load_wall_posts()
|
| 648 |
-
if post_id:
|
| 649 |
-
for p in posts:
|
| 650 |
-
if p.get('id') == post_id:
|
| 651 |
-
post = p
|
| 652 |
-
safe_title = p.get('title', safe_title) or safe_title
|
| 653 |
-
safe_img = p.get('img', safe_img) or safe_img
|
| 654 |
-
safe_url = p.get('url', safe_url) or safe_url
|
| 655 |
-
break
|
| 656 |
-
elif url:
|
| 657 |
-
# Find matching URL - prioritize posts with slides or video
|
| 658 |
-
for p in posts:
|
| 659 |
-
if p.get('url') == url and p.get('slides'):
|
| 660 |
-
post = p
|
| 661 |
-
safe_title = p.get('title', safe_title) or safe_title
|
| 662 |
-
safe_img = p.get('img', safe_img) or safe_img
|
| 663 |
-
safe_url = p.get('url', safe_url) or safe_url
|
| 664 |
-
break
|
| 665 |
-
if not post:
|
| 666 |
-
# Fallback: find any matching URL
|
| 667 |
-
for p in posts:
|
| 668 |
-
if p.get('url') == url:
|
| 669 |
-
post = p
|
| 670 |
-
safe_title = p.get('title', safe_title) or safe_title
|
| 671 |
-
safe_img = p.get('img', safe_img) or safe_img
|
| 672 |
-
safe_url = p.get('url', safe_url) or safe_url
|
| 673 |
-
break
|
| 674 |
-
except:
|
| 675 |
-
pass
|
| 676 |
-
|
| 677 |
-
if post and post.get('slides'):
|
| 678 |
-
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 679 |
-
|
| 680 |
-
if post and post.get('video'):
|
| 681 |
-
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 682 |
-
|
| 683 |
-
# Fallback: redirect to original URL
|
| 684 |
-
return HTMLResponse(f'''<!DOCTYPE html>
|
| 685 |
-
<html lang="vi">
|
| 686 |
-
<head>
|
| 687 |
-
<meta charset="utf-8">
|
| 688 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 689 |
-
<title>{safe_title}</title>
|
| 690 |
-
<meta property="og:title" content="{safe_title}">
|
| 691 |
-
<meta property="og:image" content="{safe_img}">
|
| 692 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 693 |
-
<meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
|
| 694 |
-
<link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
|
| 695 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 696 |
-
</head><body></body></html>''')
|
| 697 |
-
|
| 698 |
-
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
|
| 699 |
-
|
| 700 |
-
_xlb_cache = {}
|
| 701 |
-
_xlb_lock = threading.Lock()
|
| 702 |
-
|
| 703 |
-
def _xlb_scrape(path):
|
| 704 |
-
url = f"https://xemlaibongda.top/{path}"
|
| 705 |
-
r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
|
| 706 |
-
if r.status_code != 200:
|
| 707 |
-
return []
|
| 708 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 709 |
-
vids = []
|
| 710 |
-
seen = set()
|
| 711 |
-
for a in soup.select('a[href*="/video/"]'):
|
| 712 |
-
href = a.get('href', '')
|
| 713 |
-
if not href or href in seen:
|
| 714 |
-
continue
|
| 715 |
-
seen.add(href)
|
| 716 |
-
if not href.startswith('http'):
|
| 717 |
-
href = 'https://xemlaibongda.top' + href
|
| 718 |
-
img = a.select_one('img')
|
| 719 |
-
p = a.parent
|
| 720 |
-
for _ in range(4):
|
| 721 |
-
if img:
|
| 722 |
-
break
|
| 723 |
-
if p:
|
| 724 |
-
img = p.select_one('img')
|
| 725 |
-
p = p.parent
|
| 726 |
-
img_src = ''
|
| 727 |
-
if img:
|
| 728 |
-
img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
|
| 729 |
-
if img_src.startswith('//'):
|
| 730 |
-
img_src = 'https:' + img_src
|
| 731 |
-
elif img_src.startswith('/'):
|
| 732 |
-
img_src = 'https://xemlaibongda.top' + img_src
|
| 733 |
-
title = ''
|
| 734 |
-
for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
|
| 735 |
-
t = a.select_one(sel)
|
| 736 |
-
if t:
|
| 737 |
-
title = _clean(t.get_text())
|
| 738 |
-
break
|
| 739 |
-
if not title:
|
| 740 |
-
title = _clean(a.get('title',''))
|
| 741 |
-
if not title:
|
| 742 |
-
img_alt = a.select_one('img')
|
| 743 |
-
if img_alt:
|
| 744 |
-
title = _clean(img_alt.get('alt',''))
|
| 745 |
-
if not title:
|
| 746 |
-
parent = a.parent
|
| 747 |
-
if parent:
|
| 748 |
-
pt = _clean(parent.get_text(' ',strip=True))
|
| 749 |
-
if 5 < len(pt) < 120:
|
| 750 |
-
title = pt
|
| 751 |
-
if not title or len(title) < 3:
|
| 752 |
-
continue
|
| 753 |
-
vids.append({"link": href, "img": img_src, "title": title})
|
| 754 |
-
if len(vids) >= 30:
|
| 755 |
-
break
|
| 756 |
-
return vids
|
| 757 |
-
|
| 758 |
-
@app.get('/api/proxy/xlb')
|
| 759 |
-
def proxy_xlb(path: str = Query(default="")):
|
| 760 |
-
now = time.time()
|
| 761 |
-
cache_key = f"xlb:{path}"
|
| 762 |
-
with _xlb_lock:
|
| 763 |
-
cached = _xlb_cache.get(cache_key)
|
| 764 |
-
if cached and now - cached['t'] < 120:
|
| 765 |
-
return JSONResponse(cached['d'])
|
| 766 |
-
try:
|
| 767 |
-
vids = _xlb_scrape(path)
|
| 768 |
-
result = {"videos": vids, "count": len(vids)}
|
| 769 |
-
with _xlb_lock:
|
| 770 |
-
_xlb_cache[cache_key] = {'t': now, 'd': result}
|
| 771 |
-
return JSONResponse(result)
|
| 772 |
-
except Exception as e:
|
| 773 |
-
return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
|
| 774 |
-
|
| 775 |
-
@app.get('/api/wc2026')
|
| 776 |
-
def _w():return JSONResponse(get_wc2026_all())
|
| 777 |
-
@app.get('/api/wc2026/fixtures')
|
| 778 |
-
def _wf():return JSONResponse(scrape_fixtures())
|
| 779 |
-
@app.get('/api/wc2026/standings')
|
| 780 |
-
def _ws():return JSONResponse(scrape_standings())
|
| 781 |
-
@app.get('/api/wc2026/stats')
|
| 782 |
-
def _wst():return JSONResponse(scrape_stats())
|
| 783 |
-
@app.get('/api/wc2026/history')
|
| 784 |
-
def _whi():return JSONResponse(scrape_history())
|
| 785 |
-
@app.get('/api/wc2026/news')
|
| 786 |
-
def _wn():return JSONResponse(scrape_wc_news())
|
| 787 |
-
@app.get('/api/wc2026/road')
|
| 788 |
-
def _wr():return JSONResponse(scrape_road_to_wc())
|
| 789 |
-
@app.get('/api/wc2026/h2h/{eid}')
|
| 790 |
-
def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
|
| 791 |
-
@app.get('/api/wc2026/lineups/{eid}')
|
| 792 |
-
def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
|
| 793 |
-
@app.get('/api/wc2026/match/{eid}')
|
| 794 |
-
def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
|
| 795 |
-
|
| 796 |
-
DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
|
| 797 |
-
os.makedirs(DATA_DIR,exist_ok=True)
|
| 798 |
-
IF=os.path.join(DATA_DIR,'interactions_v2.json')
|
| 799 |
-
CF=os.path.join(DATA_DIR,'comments_v2.json')
|
| 800 |
-
WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
|
| 801 |
-
WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
|
| 802 |
-
os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
|
| 803 |
-
|
| 804 |
-
_il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
|
| 805 |
-
def _lj(p):
|
| 806 |
-
try:
|
| 807 |
-
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
| 808 |
-
except:pass
|
| 809 |
-
return{}
|
| 810 |
-
def _sj(p,d):
|
| 811 |
-
try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
|
| 812 |
-
except:pass
|
| 813 |
-
|
| 814 |
-
@app.post('/api/v2/interact')
|
| 815 |
-
async def _int(request:Request):
|
| 816 |
-
b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
|
| 817 |
-
if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
|
| 818 |
-
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])
|
| 819 |
-
|
| 820 |
-
@app.get('/api/v2/interactions')
|
| 821 |
-
def _gi(id:str=Query(...)):
|
| 822 |
-
with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
|
| 823 |
-
|
| 824 |
-
@app.get('/api/v2/comments')
|
| 825 |
-
def _gc(id:str=Query(...)):
|
| 826 |
-
with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
|
| 827 |
-
|
| 828 |
-
@app.post('/api/v2/comment')
|
| 829 |
-
async def _pc(request:Request):
|
| 830 |
-
b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
|
| 831 |
-
if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
|
| 832 |
-
c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 833 |
-
with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
|
| 834 |
-
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 835 |
-
return JSONResponse({'comments':cms})
|
| 836 |
-
|
| 837 |
-
def _load_wall_posts():
|
| 838 |
-
with _wl_lock:
|
| 839 |
-
return _lj(WALL_FILE)
|
| 840 |
-
|
| 841 |
-
def _save_wall_posts(posts):
|
| 842 |
-
with _wl_lock:
|
| 843 |
-
_sj(WALL_FILE, posts)
|
| 844 |
-
|
| 845 |
-
@app.get('/api/wall')
|
| 846 |
-
def api_wall():
|
| 847 |
-
posts = _load_wall_posts()
|
| 848 |
-
if not posts:
|
| 849 |
-
return JSONResponse({"posts": []})
|
| 850 |
-
return JSONResponse({"posts": posts})
|
| 851 |
-
|
| 852 |
-
@app.post('/api/wall')
|
| 853 |
-
async def api_wall_post(request: Request):
|
| 854 |
-
content_type = request.headers.get('content-type', '')
|
| 855 |
-
if 'multipart/form-data' in content_type:
|
| 856 |
-
try:
|
| 857 |
-
form = await request.form()
|
| 858 |
-
except Exception as e:
|
| 859 |
-
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 860 |
-
title = form.get('title', 'Video mới') or 'Video mới'
|
| 861 |
-
text = form.get('text', '') or ''
|
| 862 |
-
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 863 |
-
video_file = form.get('video')
|
| 864 |
-
post_id = str(uuid.uuid4())[:12]
|
| 865 |
-
video_url = None
|
| 866 |
-
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 867 |
-
fname = video_file.filename.lower()
|
| 868 |
-
if fname.endswith('.mp4'):
|
| 869 |
-
ext = '.mp4'
|
| 870 |
-
elif fname.endswith('.webm'):
|
| 871 |
-
ext = '.webm'
|
| 872 |
-
else:
|
| 873 |
-
ext = '.webm'
|
| 874 |
-
video_filename = f"wall_{post_id}{ext}"
|
| 875 |
-
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 876 |
-
try:
|
| 877 |
-
content = await video_file.read()
|
| 878 |
-
if not content:
|
| 879 |
-
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 880 |
-
with open(video_path, 'wb') as f:
|
| 881 |
-
f.write(content)
|
| 882 |
-
file_size_mb = len(content) / 1024 / 1024
|
| 883 |
-
if file_size_mb > 50:
|
| 884 |
-
os.remove(video_path)
|
| 885 |
-
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 886 |
-
video_url = f"/api/wall/video/{video_filename}"
|
| 887 |
-
except Exception as e:
|
| 888 |
-
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 889 |
-
post = {
|
| 890 |
-
"id": post_id,
|
| 891 |
-
"title": title[:200],
|
| 892 |
-
"text": text[:2000],
|
| 893 |
-
"source": source,
|
| 894 |
-
"video": video_url,
|
| 895 |
-
"img": None,
|
| 896 |
-
"images": [],
|
| 897 |
-
"created": int(time.time()),
|
| 898 |
-
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 899 |
-
}
|
| 900 |
-
posts = _load_wall_posts()
|
| 901 |
-
if not isinstance(posts, list):
|
| 902 |
-
posts = []
|
| 903 |
-
posts.insert(0, post)
|
| 904 |
-
posts = posts[:200]
|
| 905 |
-
_save_wall_posts(posts)
|
| 906 |
-
return JSONResponse({"post": post, "ok": True})
|
| 907 |
-
try:
|
| 908 |
-
body = await request.json()
|
| 909 |
-
except:
|
| 910 |
-
body = {}
|
| 911 |
-
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 912 |
-
text = body.get('text', '') or ''
|
| 913 |
-
img = body.get('img', None)
|
| 914 |
-
source = body.get('source', 'user') or 'user'
|
| 915 |
-
post_id = str(uuid.uuid4())[:12]
|
| 916 |
-
post = {
|
| 917 |
-
"id": post_id,
|
| 918 |
-
"title": title[:200],
|
| 919 |
-
"text": text[:2000],
|
| 920 |
-
"source": source,
|
| 921 |
-
"video": None,
|
| 922 |
-
"img": img,
|
| 923 |
-
"images": [],
|
| 924 |
-
"created": int(time.time()),
|
| 925 |
-
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 926 |
-
}
|
| 927 |
-
posts = _load_wall_posts()
|
| 928 |
-
if not isinstance(posts, list):
|
| 929 |
-
posts = []
|
| 930 |
-
posts.insert(0, post)
|
| 931 |
-
posts = posts[:200]
|
| 932 |
-
_save_wall_posts(posts)
|
| 933 |
-
return JSONResponse({"post": post, "ok": True})
|
| 934 |
-
|
| 935 |
-
@app.get('/api/wall/video/{filename}')
|
| 936 |
-
def api_wall_video(filename: str):
|
| 937 |
-
if '..' in filename or '/' in filename:
|
| 938 |
-
return Response(status_code=403)
|
| 939 |
-
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
| 940 |
-
if not os.path.exists(video_path):
|
| 941 |
-
return Response(status_code=404)
|
| 942 |
-
ext = os.path.splitext(filename)[1].lower()
|
| 943 |
-
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 944 |
-
return FileResponse(video_path, media_type=media_type)
|
| 945 |
-
|
| 946 |
-
@app.delete('/api/wall/{post_id}')
|
| 947 |
-
def api_wall_delete(post_id: str):
|
| 948 |
-
posts = _load_wall_posts()
|
| 949 |
-
if not isinstance(posts, list):
|
| 950 |
-
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 951 |
-
for i, p in enumerate(posts):
|
| 952 |
-
if p.get('id') == post_id:
|
| 953 |
-
if p.get('video'):
|
| 954 |
-
video_name = p['video'].split('/')[-1]
|
| 955 |
-
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
| 956 |
-
if os.path.exists(video_path):
|
| 957 |
-
os.remove(video_path)
|
| 958 |
-
posts.pop(i)
|
| 959 |
-
_save_wall_posts(posts)
|
| 960 |
-
return JSONResponse({"ok": True})
|
| 961 |
-
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 962 |
-
|
| 963 |
-
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 964 |
-
import random as _random2
|
| 965 |
-
from urllib.parse import quote as _quote2
|
| 966 |
-
|
| 967 |
-
_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 968 |
-
|
| 969 |
-
# Unique character markers for language detection
|
| 970 |
-
_UNIQUE_CHARS = {
|
| 971 |
-
'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹé��ễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
|
| 972 |
-
'spanish': set('ñáéíóúü¿¡'),
|
| 973 |
-
'portuguese': set('ãõçáéíóúâêôà'),
|
| 974 |
-
}
|
| 975 |
-
|
| 976 |
-
_STOPWORDS = {
|
| 977 |
-
'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
|
| 978 |
-
'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
|
| 979 |
-
'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
|
| 980 |
-
'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
|
| 981 |
-
}
|
| 982 |
-
|
| 983 |
-
def detect_language(text):
|
| 984 |
-
"""Detect language from text content using stopword + character analysis."""
|
| 985 |
-
if not text:
|
| 986 |
-
return 'vietnamese'
|
| 987 |
-
text_lower = text.lower()
|
| 988 |
-
text_chars = set(text_lower)
|
| 989 |
-
|
| 990 |
-
# Strong signal: Vietnamese unique characters
|
| 991 |
-
vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
|
| 992 |
-
if vn_chars >= 2:
|
| 993 |
-
return 'vietnamese'
|
| 994 |
-
|
| 995 |
-
# Spanish unique chars (ñ, ¿, ¡)
|
| 996 |
-
es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
|
| 997 |
-
pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
|
| 998 |
-
|
| 999 |
-
# Stopword scoring
|
| 1000 |
-
words = set(re.findall(r'\b\w+\b', text_lower))
|
| 1001 |
-
scores = {}
|
| 1002 |
-
for lang, stops in _STOPWORDS.items():
|
| 1003 |
-
scores[lang] = len(words & stops) / max(len(stops), 1)
|
| 1004 |
-
|
| 1005 |
-
# Disambiguate Portuguese vs Spanish
|
| 1006 |
-
pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
|
| 1007 |
-
es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
|
| 1008 |
-
|
| 1009 |
-
pt_overlap = len(words & pt_markers)
|
| 1010 |
-
es_overlap = len(words & es_markers)
|
| 1011 |
-
|
| 1012 |
-
if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
|
| 1013 |
-
return 'portuguese'
|
| 1014 |
-
if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
|
| 1015 |
-
return 'spanish'
|
| 1016 |
-
if scores.get('english', 0) > 0.15:
|
| 1017 |
-
return 'english'
|
| 1018 |
-
|
| 1019 |
-
best = max(scores, key=scores.get)
|
| 1020 |
-
return best if scores[best] > 0.05 else 'vietnamese'
|
| 1021 |
-
|
| 1022 |
-
# Emotion keyword-based detection
|
| 1023 |
-
_EMOTION_KEYWORDS = {
|
| 1024 |
-
'happy': {
|
| 1025 |
-
'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
|
| 1026 |
-
'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
|
| 1027 |
-
'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
|
| 1028 |
-
'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
|
| 1029 |
-
},
|
| 1030 |
-
'sad': {
|
| 1031 |
-
'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
|
| 1032 |
-
'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
|
| 1033 |
-
'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
|
| 1034 |
-
'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'kh���n nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
|
| 1035 |
-
},
|
| 1036 |
-
'excited': {
|
| 1037 |
-
'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
|
| 1038 |
-
'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
|
| 1039 |
-
'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
|
| 1040 |
-
'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
|
| 1041 |
-
},
|
| 1042 |
-
'humorous': {
|
| 1043 |
-
'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
|
| 1044 |
-
'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
|
| 1045 |
-
'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
|
| 1046 |
-
'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
|
| 1047 |
-
},
|
| 1048 |
-
'serious': {
|
| 1049 |
-
'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
|
| 1050 |
-
'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
|
| 1051 |
-
'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
|
| 1052 |
-
'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
|
| 1053 |
-
},
|
| 1054 |
-
}
|
| 1055 |
-
|
| 1056 |
-
def detect_emotion(text, language='vietnamese'):
|
| 1057 |
-
"""Detect emotion from text using keyword matching."""
|
| 1058 |
-
if not text:
|
| 1059 |
-
return 'neutral'
|
| 1060 |
-
text_lower = text.lower()
|
| 1061 |
-
|
| 1062 |
-
scores = {}
|
| 1063 |
-
for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
|
| 1064 |
-
keywords = lang_keywords.get(language, lang_keywords.get('en', []))
|
| 1065 |
-
score = sum(1 for kw in keywords if kw in text_lower)
|
| 1066 |
-
scores[emotion] = score
|
| 1067 |
-
|
| 1068 |
-
if max(scores.values()) == 0:
|
| 1069 |
-
return 'neutral'
|
| 1070 |
-
|
| 1071 |
-
return max(scores, key=scores.get)
|
| 1072 |
-
|
| 1073 |
-
def detect_language_and_emotion(title, text):
|
| 1074 |
-
"""Detect both language and emotion from article content."""
|
| 1075 |
-
combined = f"{title} {text}"
|
| 1076 |
-
lang = detect_language(combined)
|
| 1077 |
-
emotion = detect_emotion(combined, lang)
|
| 1078 |
-
return lang, emotion
|
| 1079 |
-
|
| 1080 |
-
# Voice selection based on language and emotion (using MultilingualNeural voices)
|
| 1081 |
-
VOICE_BY_LANG_EMOTION = {
|
| 1082 |
-
'vietnamese': {
|
| 1083 |
-
'happy': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 1084 |
-
'sad': ('vi-VN-NamMinhNeural', 'buồn'),
|
| 1085 |
-
'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
|
| 1086 |
-
'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 1087 |
-
'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
|
| 1088 |
-
'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
|
| 1089 |
-
},
|
| 1090 |
-
'portuguese': {
|
| 1091 |
-
'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
|
| 1092 |
-
'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
|
| 1093 |
-
'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
|
| 1094 |
-
'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
|
| 1095 |
-
'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
|
| 1096 |
-
'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
|
| 1097 |
-
},
|
| 1098 |
-
'english': {
|
| 1099 |
-
'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
|
| 1100 |
-
'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
|
| 1101 |
-
'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
|
| 1102 |
-
'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
|
| 1103 |
-
'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
|
| 1104 |
-
'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
|
| 1105 |
-
},
|
| 1106 |
-
'french': {
|
| 1107 |
-
'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
|
| 1108 |
-
'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
|
| 1109 |
-
'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
|
| 1110 |
-
'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
|
| 1111 |
-
'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
|
| 1112 |
-
'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
|
| 1113 |
-
},
|
| 1114 |
-
'german': {
|
| 1115 |
-
'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
|
| 1116 |
-
'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
|
| 1117 |
-
'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
|
| 1118 |
-
'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
|
| 1119 |
-
'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
|
| 1120 |
-
'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
|
| 1121 |
-
},
|
| 1122 |
-
'korean': {
|
| 1123 |
-
'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
|
| 1124 |
-
'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
|
| 1125 |
-
'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
|
| 1126 |
-
'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
|
| 1127 |
-
'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
|
| 1128 |
-
'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
|
| 1129 |
-
},
|
| 1130 |
-
'italian': {
|
| 1131 |
-
'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
|
| 1132 |
-
'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
|
| 1133 |
-
'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
|
| 1134 |
-
'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
|
| 1135 |
-
'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
|
| 1136 |
-
'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
|
| 1137 |
-
},
|
| 1138 |
-
}
|
| 1139 |
-
|
| 1140 |
-
# All valid voice IDs (new MultilingualNeural format)
|
| 1141 |
-
VALID_VOICES = {
|
| 1142 |
-
'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
|
| 1143 |
-
'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
|
| 1144 |
-
'pt-BR-ThalitaMultilingualNeural',
|
| 1145 |
-
'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
|
| 1146 |
-
'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
|
| 1147 |
-
'ko-KR-HyunsuMultilingualNeural',
|
| 1148 |
-
'it-IT-GiuseppeMultilingualNeural',
|
| 1149 |
-
}
|
| 1150 |
-
|
| 1151 |
-
def get_voice_for_content(title, text, preferred_voice=None):
|
| 1152 |
-
"""Get appropriate voice based on content language and emotion."""
|
| 1153 |
-
# Accept the new MultilingualNeural voices directly
|
| 1154 |
-
if preferred_voice and preferred_voice in VALID_VOICES:
|
| 1155 |
-
return preferred_voice
|
| 1156 |
-
|
| 1157 |
-
# Also accept old shorthand voice IDs and map them to new format
|
| 1158 |
-
old_voice_map = {
|
| 1159 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 1160 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 1161 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 1162 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 1163 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 1164 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 1165 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 1166 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 1167 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 1168 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 1169 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 1170 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 1171 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 1172 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 1173 |
-
}
|
| 1174 |
-
if preferred_voice and preferred_voice in old_voice_map:
|
| 1175 |
-
return old_voice_map[preferred_voice]
|
| 1176 |
-
|
| 1177 |
-
lang, emotion = detect_language_and_emotion(title, text)
|
| 1178 |
-
lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
|
| 1179 |
-
voice, _ = lang_map.get(emotion, lang_map['neutral'])
|
| 1180 |
-
return voice
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
def _is_relevant_image(img_url, title, text):
|
| 1184 |
-
"""Check if an image is relevant to the article content."""
|
| 1185 |
-
if not img_url:
|
| 1186 |
-
return False
|
| 1187 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 1188 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 1189 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 1190 |
-
img_lower = img_url.lower()
|
| 1191 |
-
for p in skip_patterns:
|
| 1192 |
-
if p in img_lower:
|
| 1193 |
-
return False
|
| 1194 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 1195 |
-
return False
|
| 1196 |
-
return True
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 1200 |
-
"""Filter and rank images by relevance to article content."""
|
| 1201 |
-
if not images:
|
| 1202 |
-
return []
|
| 1203 |
-
seen = set()
|
| 1204 |
-
relevant = []
|
| 1205 |
-
for img in images:
|
| 1206 |
-
if img in seen:
|
| 1207 |
-
continue
|
| 1208 |
-
seen.add(img)
|
| 1209 |
-
if _is_relevant_image(img, title, text):
|
| 1210 |
-
relevant.append(img)
|
| 1211 |
-
return relevant[:max_images]
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
def _scrape_article_for_rewrite(url):
|
| 1215 |
-
"""Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
|
| 1216 |
-
try:
|
| 1217 |
-
r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
|
| 1218 |
-
r.encoding = 'utf-8'
|
| 1219 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 1220 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 1221 |
-
tag.decompose()
|
| 1222 |
-
h1 = soup.find('h1')
|
| 1223 |
-
ogt = soup.find('meta', property='og:title')
|
| 1224 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 1225 |
-
ogi = soup.find('meta', property='og:image')
|
| 1226 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 1227 |
-
if og_img and og_img.startswith('//'):
|
| 1228 |
-
og_img = 'https:' + og_img
|
| 1229 |
-
block = None
|
| 1230 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 1231 |
-
el = soup.select_one(sel)
|
| 1232 |
-
if el and len(el.find_all('p')) >= 2:
|
| 1233 |
-
block = el
|
| 1234 |
-
break
|
| 1235 |
-
if not block:
|
| 1236 |
-
block = soup.body or soup
|
| 1237 |
-
paragraphs = []
|
| 1238 |
-
all_images = []
|
| 1239 |
-
seen_imgs = set()
|
| 1240 |
-
if og_img and og_img not in seen_imgs:
|
| 1241 |
-
all_images.append(og_img)
|
| 1242 |
-
seen_imgs.add(og_img)
|
| 1243 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 1244 |
-
if el.name == 'p':
|
| 1245 |
-
t = _clean(el.get_text(strip=True))
|
| 1246 |
-
if t and len(t) > 40:
|
| 1247 |
-
paragraphs.append(t)
|
| 1248 |
-
elif el.name in ('figure', 'img'):
|
| 1249 |
-
im = el if el.name == 'img' else el.find('img')
|
| 1250 |
-
if im:
|
| 1251 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 1252 |
-
if src and 'base64' not in src:
|
| 1253 |
-
if src.startswith('//'):
|
| 1254 |
-
src = 'https:' + src
|
| 1255 |
-
if src not in seen_imgs:
|
| 1256 |
-
all_images.append(src)
|
| 1257 |
-
seen_imgs.add(src)
|
| 1258 |
-
# Filter to relevant images only
|
| 1259 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 1260 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 1261 |
-
except Exception:
|
| 1262 |
-
return None
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
def _extract_key_points_rw(paragraphs, max_points=5):
|
| 1266 |
-
r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
|
| 1267 |
-
|
| 1268 |
-
Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
|
| 1269 |
-
Now splits on all sentence boundaries and takes valid sentences until max_points.
|
| 1270 |
-
"""
|
| 1271 |
-
points = []
|
| 1272 |
-
|
| 1273 |
-
for p in paragraphs:
|
| 1274 |
-
if len(points) >= max_points:
|
| 1275 |
-
break
|
| 1276 |
-
|
| 1277 |
-
p = _clean(p)
|
| 1278 |
-
if not p:
|
| 1279 |
-
continue
|
| 1280 |
-
|
| 1281 |
-
# Split paragraph into sentences using Vietnamese + English punctuation
|
| 1282 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 1283 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 1284 |
-
|
| 1285 |
-
for sentence in sentences:
|
| 1286 |
-
if len(points) >= max_points:
|
| 1287 |
-
break
|
| 1288 |
-
|
| 1289 |
-
# Clean sentence - remove extra whitespace
|
| 1290 |
-
sentence = _clean(sentence)
|
| 1291 |
-
|
| 1292 |
-
if len(sentence) < 30:
|
| 1293 |
-
continue
|
| 1294 |
-
|
| 1295 |
-
# Check for duplicates
|
| 1296 |
-
if any(sentence[:60] in existing for existing in points):
|
| 1297 |
-
continue
|
| 1298 |
-
|
| 1299 |
-
# Ensure sentence ends with punctuation
|
| 1300 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 1301 |
-
sentence = sentence + '.'
|
| 1302 |
-
|
| 1303 |
-
points.append(sentence)
|
| 1304 |
-
|
| 1305 |
-
# If no valid sentences found, take chunks from raw text
|
| 1306 |
-
if not points:
|
| 1307 |
-
raw = '\n'.join(paragraphs)
|
| 1308 |
-
for i in range(0, min(len(raw), max_points * 300), 280):
|
| 1309 |
-
chunk = _clean(raw[i:i+280])
|
| 1310 |
-
if len(chunk) >= 30 and chunk not in points:
|
| 1311 |
-
points.append(chunk + ('.' if not chunk.endswith('.') else ''))
|
| 1312 |
-
if len(points) >= max_points:
|
| 1313 |
-
break
|
| 1314 |
-
|
| 1315 |
-
return points
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
-
@app.post("/api/rewrite_slide")
|
| 1319 |
-
async def api_rewrite_slide(request: Request):
|
| 1320 |
-
"""Fast rewrite as SLIDES - no AI needed, instant response."""
|
| 1321 |
-
body = await request.json()
|
| 1322 |
-
url = _clean(body.get("url", ""))
|
| 1323 |
-
context = body.get("context", "")
|
| 1324 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1325 |
-
if not url and not context:
|
| 1326 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1327 |
-
data = None
|
| 1328 |
-
if url and url.startswith("http"):
|
| 1329 |
-
data = _scrape_article_for_rewrite(url)
|
| 1330 |
-
if not data and context:
|
| 1331 |
-
paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
|
| 1332 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1333 |
-
if not data or not data.get('paragraphs'):
|
| 1334 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1335 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1336 |
-
if not points:
|
| 1337 |
-
return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
|
| 1338 |
-
images = data.get('images', [])
|
| 1339 |
-
slides = []
|
| 1340 |
-
for i, point in enumerate(points):
|
| 1341 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 1342 |
-
if img and 'cdnphoto.dantri' in img:
|
| 1343 |
-
img = '/api/proxy/img?url=' + _quote2(img, safe='')
|
| 1344 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 1345 |
-
summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
|
| 1346 |
-
|
| 1347 |
-
# Auto-detect language and emotion
|
| 1348 |
-
lang, emotion = detect_language_and_emotion(data['title'], summary_text)
|
| 1349 |
-
# Use preferred voice if provided, otherwise auto-detect
|
| 1350 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
|
| 1351 |
-
|
| 1352 |
-
post = {
|
| 1353 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 1354 |
-
"title": data['title'],
|
| 1355 |
-
"text": summary_text,
|
| 1356 |
-
"img": images[0] if images else '',
|
| 1357 |
-
"url": url,
|
| 1358 |
-
"kind": "slide_summary",
|
| 1359 |
-
"slides": slides,
|
| 1360 |
-
"images": images[:10],
|
| 1361 |
-
"video": "",
|
| 1362 |
-
"voice": voice,
|
| 1363 |
-
"emotion": emotion,
|
| 1364 |
-
"language": lang,
|
| 1365 |
-
"ts": int(time.time())
|
| 1366 |
-
}
|
| 1367 |
-
posts = _load_wall_posts()
|
| 1368 |
-
posts.insert(0, post)
|
| 1369 |
-
_save_wall_posts(posts)
|
| 1370 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
@app.post("/api/rewrite_share")
|
| 1374 |
-
async def api_rewrite_share(request: Request):
|
| 1375 |
-
"""Rewrite article and post to Tường AI with SLIDES + AI text."""
|
| 1376 |
-
body = await request.json()
|
| 1377 |
-
url = _clean(body.get("url", ""))
|
| 1378 |
-
ctx = _clean(body.get("context", ""))
|
| 1379 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1380 |
-
if not url and not ctx:
|
| 1381 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1382 |
-
data = None
|
| 1383 |
-
if url and url.startswith("http"):
|
| 1384 |
-
data = _scrape_article_for_rewrite(url)
|
| 1385 |
-
if not data and ctx:
|
| 1386 |
-
paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
|
| 1387 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1388 |
-
if not data or not data.get('paragraphs'):
|
| 1389 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1390 |
-
raw_text = '\n'.join(data['paragraphs'])
|
| 1391 |
-
if len(raw_text) < 50:
|
| 1392 |
-
raw_text = ctx[:14000]
|
| 1393 |
-
if len(raw_text) < 50:
|
| 1394 |
-
return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
|
| 1395 |
-
domain = ''
|
| 1396 |
-
try:
|
| 1397 |
-
from urllib.parse import urlparse
|
| 1398 |
-
domain = urlparse(url).netloc.replace('www.', '')
|
| 1399 |
-
except:
|
| 1400 |
-
pass
|
| 1401 |
-
|
| 1402 |
-
# Generate AI summary text
|
| 1403 |
-
ai_text = None
|
| 1404 |
-
try:
|
| 1405 |
-
import ai_ext
|
| 1406 |
-
if hasattr(ai_ext, 'qwen_generate'):
|
| 1407 |
-
prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
|
| 1408 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
|
| 1409 |
-
except Exception:
|
| 1410 |
-
pass
|
| 1411 |
-
if not ai_text or len(ai_text) < 80:
|
| 1412 |
-
key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1413 |
-
if key_pts:
|
| 1414 |
-
ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
|
| 1415 |
-
else:
|
| 1416 |
-
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1417 |
-
|
| 1418 |
-
# Build slides from key points (FIX: include slides in rewrite_share too!)
|
| 1419 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1420 |
-
images = data.get('images', [])
|
| 1421 |
-
slides = []
|
| 1422 |
-
for i, point in enumerate(points):
|
| 1423 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 1424 |
-
if img and 'cdnphoto.dantri' in img:
|
| 1425 |
-
img = '/api/proxy/img?url=' + _quote2(img, safe='')
|
| 1426 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 1427 |
-
|
| 1428 |
-
# Auto-detect language and emotion
|
| 1429 |
-
lang, emotion = detect_language_and_emotion(data['title'], ai_text)
|
| 1430 |
-
# Use preferred voice if provided, otherwise auto-detect
|
| 1431 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
|
| 1432 |
-
|
| 1433 |
-
post = {
|
| 1434 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 1435 |
-
"title": data['title'],
|
| 1436 |
-
"text": ai_text,
|
| 1437 |
-
"img": images[0] if images else '',
|
| 1438 |
-
"url": url,
|
| 1439 |
-
"kind": "rewrite",
|
| 1440 |
-
"slides": slides,
|
| 1441 |
-
"images": images[:10],
|
| 1442 |
-
"video": "",
|
| 1443 |
-
"voice": voice,
|
| 1444 |
-
"emotion": emotion,
|
| 1445 |
-
"language": lang,
|
| 1446 |
-
"ts": int(time.time())
|
| 1447 |
-
}
|
| 1448 |
-
posts = _load_wall_posts()
|
| 1449 |
-
posts.insert(0, post)
|
| 1450 |
-
_save_wall_posts(posts)
|
| 1451 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1452 |
-
|
| 1453 |
-
|
| 1454 |
-
@app.post("/api/url_wall")
|
| 1455 |
-
async def api_url_wall(request: Request):
|
| 1456 |
-
"""Submit URL to add to Tường AI."""
|
| 1457 |
-
body = await request.json()
|
| 1458 |
-
url = _clean(body.get("url", ""))
|
| 1459 |
-
if not url or not url.startswith('http'):
|
| 1460 |
-
return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
|
| 1461 |
-
# Reuse rewrite_share logic
|
| 1462 |
-
req._body = json.dumps({"url": url}).encode()
|
| 1463 |
-
return await api_rewrite_share(request)
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
# ===== PERSONAL OPINION POST v2: AI tổng hợp bài viết từ quan điểm + nguồn tin HOT =====
|
| 1467 |
-
@app.post("/api/personal_post/preview")
|
| 1468 |
-
async def api_personal_post_preview(request: Request):
|
| 1469 |
-
"""Preview personal post: fetch full articles, let AI compose logical article with images."""
|
| 1470 |
-
body = await request.json()
|
| 1471 |
-
opinion = _clean(body.get("opinion", ""))
|
| 1472 |
-
selected_topics = body.get("selected_topics", []) or []
|
| 1473 |
-
selected_sources = body.get("selected_sources", []) or []
|
| 1474 |
-
|
| 1475 |
-
if not opinion or len(opinion) < 10:
|
| 1476 |
-
return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
|
| 1477 |
-
|
| 1478 |
-
# Lấy HOT topics nếu ko có selected
|
| 1479 |
-
if not selected_topics:
|
| 1480 |
-
hot = _get_hot_topics()
|
| 1481 |
-
selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
|
| 1482 |
-
|
| 1483 |
-
# Tìm nguồn tin
|
| 1484 |
-
all_sources = []
|
| 1485 |
-
seen_urls = set()
|
| 1486 |
-
for topic in selected_topics[:3]:
|
| 1487 |
-
sources = _search_all(topic, limit=5)
|
| 1488 |
-
for s in sources:
|
| 1489 |
-
if s.get("url") and s["url"] not in seen_urls:
|
| 1490 |
-
seen_urls.add(s["url"])
|
| 1491 |
-
all_sources.append(s)
|
| 1492 |
-
if len(all_sources) >= 6:
|
| 1493 |
-
break
|
| 1494 |
-
if len(all_sources) >= 6:
|
| 1495 |
-
break
|
| 1496 |
-
|
| 1497 |
-
for src in selected_sources:
|
| 1498 |
-
if src.get("url") and src["url"] not in seen_urls:
|
| 1499 |
-
all_sources.insert(0, src)
|
| 1500 |
-
|
| 1501 |
-
# Scrape nội dung đầy đủ từng nguồn (paragraphs + images)
|
| 1502 |
-
source_details = []
|
| 1503 |
-
source_images = []
|
| 1504 |
-
for src in all_sources[:5]:
|
| 1505 |
-
url = src.get("url", "")
|
| 1506 |
-
if not url:
|
| 1507 |
-
continue
|
| 1508 |
-
try:
|
| 1509 |
-
art = _scrape_article_for_rewrite(url)
|
| 1510 |
-
if art:
|
| 1511 |
-
src_detail = {
|
| 1512 |
-
"title": art.get("title", src.get("title", "")),
|
| 1513 |
-
"url": url,
|
| 1514 |
-
"via": src.get("via", ""),
|
| 1515 |
-
"paragraphs": art.get("paragraphs", [])[:8],
|
| 1516 |
-
"images": art.get("images", [])[:3],
|
| 1517 |
-
"og_image": art.get("og_img", "")
|
| 1518 |
-
}
|
| 1519 |
-
source_details.append(src_detail)
|
| 1520 |
-
# Collect images for proxy
|
| 1521 |
-
for img in art.get("images", [])[:2]:
|
| 1522 |
-
if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
|
| 1523 |
-
img = "/api/proxy/img?url=" + _quote2(img, safe="")
|
| 1524 |
-
source_images.append(img)
|
| 1525 |
-
except:
|
| 1526 |
-
pass
|
| 1527 |
-
if len(source_details) >= 5:
|
| 1528 |
-
break
|
| 1529 |
-
|
| 1530 |
-
# Tạo title từ opinion
|
| 1531 |
-
opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
|
| 1532 |
-
title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
|
| 1533 |
-
title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
|
| 1534 |
-
title = title[:80]
|
| 1535 |
-
|
| 1536 |
-
# AI sinh bài viết hoàn chỉnh
|
| 1537 |
-
ai_text = None
|
| 1538 |
-
try:
|
| 1539 |
-
import ai_ext
|
| 1540 |
-
if hasattr(ai_ext, 'qwen_generate'):
|
| 1541 |
-
# Build detailed context from source articles
|
| 1542 |
-
source_context = ""
|
| 1543 |
-
for i, sd in enumerate(source_details[:5]):
|
| 1544 |
-
src_title = sd.get("title", "")
|
| 1545 |
-
src_via = sd.get("via", "")
|
| 1546 |
-
src_paras = sd.get("paragraphs", [])
|
| 1547 |
-
source_context += f"\n=== Nguồn {i+1}: {src_title} ({src_via}) ===\n"
|
| 1548 |
-
for j, p in enumerate(src_paras[:4]):
|
| 1549 |
-
source_context += f" - {p[:300]}\n"
|
| 1550 |
-
|
| 1551 |
-
prompt = (
|
| 1552 |
-
"Bạn là nhà báo chuyên nghiệp tiếng Việt. Hãy viết một bài phân tích dựa trên quan điểm cá nhân và các nguồn tin sau đây.\n\n"
|
| 1553 |
-
"=== QUAN ĐIỂM CÁ NHÂN ===\n" + opinion[:2000] + "\n\n"
|
| 1554 |
-
"=== NGUỒN TIN THAM KHẢO (kèm nội dung chi tiết) ===\n" + source_context + "\n\n"
|
| 1555 |
-
"=== YÊU CẦU BÀI VIẾT ===\n"
|
| 1556 |
-
"1. Mở đầu: Giới thiệu chủ đề và nêu quan điểm cá nhân (1-2 câu)\n"
|
| 1557 |
-
"2. Thân bài: Phân tích luận điểm, dùng dẫn chứng CỤ THỂ từ nguồn tin (trích dẫn nguồn kèm tên báo)\n"
|
| 1558 |
-
"3. Mỗi luận điểm là 1 đoạn ngắn 2-4 câu, có ghi nguồn rõ ràng (VD: Theo VnExpress, ...)\n"
|
| 1559 |
-
"4. Kết luận: Tổng kết quan điểm, gợi mở suy nghĩ\n"
|
| 1560 |
-
"5. Cuối bài: Ghi danh sách nguồn tham khảo\n\n"
|
| 1561 |
-
"Viết tự nhiên, mạch lạc, giọng văn báo chí - phân tích. Độ dài: 300-600 từ."
|
| 1562 |
-
)
|
| 1563 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=2000)
|
| 1564 |
-
except:
|
| 1565 |
-
pass
|
| 1566 |
-
|
| 1567 |
-
if not ai_text or len(ai_text) < 100:
|
| 1568 |
-
# Fallback: build article manually
|
| 1569 |
-
ai_text = "## " + title + "\n\n"
|
| 1570 |
-
ai_text += opinion + "\n\n"
|
| 1571 |
-
for i, sd in enumerate(source_details[:5]):
|
| 1572 |
-
ai_text += "### " + sd.get("title", f"Nguồn {i+1}") + "\n"
|
| 1573 |
-
for p in sd.get("paragraphs", [])[:3]:
|
| 1574 |
-
ai_text += p[:250] + "\n"
|
| 1575 |
-
ai_text += "*Nguồn: " + sd.get("via", "") + "*\n\n"
|
| 1576 |
-
ai_text += "\n---\n*Bài viết tổng hợp từ quan điểm cá nhân và các nguồn tin liên quan*"
|
| 1577 |
-
|
| 1578 |
-
# Tạo slides từ bài viết
|
| 1579 |
-
slide_parts = []
|
| 1580 |
-
if ai_text:
|
| 1581 |
-
paragraphs = [p.strip() for p in ai_text.split("\n") if p.strip() and len(p.strip()) > 40]
|
| 1582 |
-
current_para = ""
|
| 1583 |
-
para_count = 0
|
| 1584 |
-
for p in paragraphs:
|
| 1585 |
-
if p.startswith("## ") or p.startswith("### ") or p.startswith("---"):
|
| 1586 |
-
if current_para and para_count < 6:
|
| 1587 |
-
slide_parts.append(current_para)
|
| 1588 |
-
para_count += 1
|
| 1589 |
-
current_para = ""
|
| 1590 |
-
elif not p.startswith("*") and not p.startswith("- "):
|
| 1591 |
-
if len(p) > 80:
|
| 1592 |
-
if current_para:
|
| 1593 |
-
current_para += "\n\n" + p
|
| 1594 |
-
else:
|
| 1595 |
-
current_para = p
|
| 1596 |
-
if current_para and para_count < 6:
|
| 1597 |
-
slide_parts.append(current_para)
|
| 1598 |
-
|
| 1599 |
-
if len(slide_parts) < 2:
|
| 1600 |
-
# Create slides from opinion + sources
|
| 1601 |
-
slide_parts = [opinion[:300]]
|
| 1602 |
-
for sd in source_details[:4]:
|
| 1603 |
-
slide_parts.append(sd.get("title", "")[:200] + "\n\n" + (sd.get("paragraphs", [""])[0][:200] if sd.get("paragraphs") else ""))
|
| 1604 |
-
|
| 1605 |
-
slides = []
|
| 1606 |
-
total = min(len(slide_parts), 6)
|
| 1607 |
-
for i in range(total):
|
| 1608 |
-
img = source_images[i] if i < len(source_images) else ""
|
| 1609 |
-
slides.append({
|
| 1610 |
-
"text": slide_parts[i],
|
| 1611 |
-
"image": img,
|
| 1612 |
-
"index": i + 1
|
| 1613 |
-
})
|
| 1614 |
-
|
| 1615 |
-
preview = {
|
| 1616 |
-
"title": title,
|
| 1617 |
-
"text": ai_text,
|
| 1618 |
-
"opinion": opinion,
|
| 1619 |
-
"images": source_images[:10],
|
| 1620 |
-
"sources": source_details[:5],
|
| 1621 |
-
"slides": slides
|
| 1622 |
-
}
|
| 1623 |
-
|
| 1624 |
-
return JSONResponse({"preview": preview})
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
@app.post("/api/personal_post")
|
| 1628 |
-
async def api_personal_post(request: Request):
|
| 1629 |
-
"""Create and save personal opinion post."""
|
| 1630 |
-
body = await request.json()
|
| 1631 |
-
opinion = _clean(body.get("opinion", ""))
|
| 1632 |
-
selected_topics = body.get("selected_topics", []) or []
|
| 1633 |
-
selected_sources = body.get("selected_sources", []) or []
|
| 1634 |
-
custom_title = body.get("custom_title", "")
|
| 1635 |
-
custom_slides = body.get("custom_slides", [])
|
| 1636 |
-
|
| 1637 |
-
if not opinion or len(opinion) < 10:
|
| 1638 |
-
return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
|
| 1639 |
-
|
| 1640 |
-
if not selected_topics:
|
| 1641 |
-
hot = _get_hot_topics()
|
| 1642 |
-
selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
|
| 1643 |
-
|
| 1644 |
-
all_sources = []
|
| 1645 |
-
seen_urls = set()
|
| 1646 |
-
for topic in selected_topics[:3]:
|
| 1647 |
-
sources = _search_all(topic, limit=5)
|
| 1648 |
-
for s in sources:
|
| 1649 |
-
if s.get("url") and s["url"] not in seen_urls:
|
| 1650 |
-
seen_urls.add(s["url"])
|
| 1651 |
-
all_sources.append(s)
|
| 1652 |
-
if len(all_sources) >= 6:
|
| 1653 |
-
break
|
| 1654 |
-
if len(all_sources) >= 6:
|
| 1655 |
-
break
|
| 1656 |
-
|
| 1657 |
-
for src in selected_sources:
|
| 1658 |
-
if src.get("url") and src["url"] not in seen_urls:
|
| 1659 |
-
all_sources.insert(0, src)
|
| 1660 |
-
|
| 1661 |
-
source_details = []
|
| 1662 |
-
source_images = []
|
| 1663 |
-
for src in all_sources[:5]:
|
| 1664 |
-
url = src.get("url", "")
|
| 1665 |
-
if not url:
|
| 1666 |
-
continue
|
| 1667 |
-
try:
|
| 1668 |
-
art = _scrape_article_for_rewrite(url)
|
| 1669 |
-
if art:
|
| 1670 |
-
src_detail = {
|
| 1671 |
-
"title": art.get("title", src.get("title", "")),
|
| 1672 |
-
"url": url,
|
| 1673 |
-
"via": src.get("via", ""),
|
| 1674 |
-
"paragraphs": art.get("paragraphs", [])[:6],
|
| 1675 |
-
"images": art.get("images", [])[:2],
|
| 1676 |
-
"og_image": art.get("og_img", "")
|
| 1677 |
-
}
|
| 1678 |
-
source_details.append(src_detail)
|
| 1679 |
-
for img in art.get("images", [])[:2]:
|
| 1680 |
-
if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
|
| 1681 |
-
img = "/api/proxy/img?url=" + _quote2(img, safe="")
|
| 1682 |
-
source_images.append(img)
|
| 1683 |
-
except:
|
| 1684 |
-
pass
|
| 1685 |
-
|
| 1686 |
-
# Title
|
| 1687 |
-
if custom_title:
|
| 1688 |
-
title = custom_title[:80]
|
| 1689 |
-
else:
|
| 1690 |
-
opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
|
| 1691 |
-
title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
|
| 1692 |
-
title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
|
| 1693 |
-
title = title[:80]
|
| 1694 |
-
|
| 1695 |
-
# AI sinh bài
|
| 1696 |
-
ai_text = None
|
| 1697 |
-
try:
|
| 1698 |
-
import ai_ext
|
| 1699 |
-
if hasattr(ai_ext, 'qwen_generate'):
|
| 1700 |
-
source_context = ""
|
| 1701 |
-
for i, sd in enumerate(source_details[:5]):
|
| 1702 |
-
src_title = sd.get("title", "")
|
| 1703 |
-
src_via = sd.get("via", "")
|
| 1704 |
-
src_paras = sd.get("paragraphs", [])
|
| 1705 |
-
source_context += f"\nNguồn {i+1}: {src_title} ({src_via})\n"
|
| 1706 |
-
for j, p in enumerate(src_paras[:3]):
|
| 1707 |
-
source_context += f" - {p[:300]}\n"
|
| 1708 |
-
prompt = (
|
| 1709 |
-
"Bạn là nhà báo tiếng Việt. Viết bài phân tích dựa trên quan điểm cá nhân và các nguồn tin sau.\n\n"
|
| 1710 |
-
"=== QUAN ĐIỂM ===\n" + opinion[:2000] + "\n\n"
|
| 1711 |
-
"=== NGUỒN TIN ===\n" + source_context + "\n\n"
|
| 1712 |
-
"Yêu cầu:\n- Mở đầu: giới thiệu chủ đề + nêu quan điểm\n- Thân bài: phân tích có dẫn chứng từ nguồn (ghi rõ nguồn)\n- Mỗi đoạn 2-4 câu\n- Kết luận: tổng kết\n- Cuối: danh sách nguồn\n\nViết tự nhiên, 300-600 từ."
|
| 1713 |
-
)
|
| 1714 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=2000)
|
| 1715 |
-
except:
|
| 1716 |
-
pass
|
| 1717 |
-
|
| 1718 |
-
if not ai_text or len(ai_text) < 100:
|
| 1719 |
-
ai_text = "## " + title + "\n\n" + opinion + "\n\n"
|
| 1720 |
-
for i, sd in enumerate(source_details[:5]):
|
| 1721 |
-
ai_text += "### " + sd.get("title", "") + "\n"
|
| 1722 |
-
for p in sd.get("paragraphs", [])[:2]:
|
| 1723 |
-
ai_text += p[:250] + "\n"
|
| 1724 |
-
ai_text += "\n---\n*Nguồn: " + sd.get("via", "") + "*\n\n"
|
| 1725 |
-
|
| 1726 |
-
# Tạo slides
|
| 1727 |
-
if custom_slides and len(custom_slides) > 0:
|
| 1728 |
-
slides = []
|
| 1729 |
-
for i, slide in enumerate(custom_slides):
|
| 1730 |
-
slides.append({
|
| 1731 |
-
"text": slide.get("text", ""),
|
| 1732 |
-
"image": slide.get("image", ""),
|
| 1733 |
-
"index": i + 1
|
| 1734 |
-
})
|
| 1735 |
-
else:
|
| 1736 |
-
slide_parts = []
|
| 1737 |
-
if ai_text:
|
| 1738 |
-
paragraphs = [p.strip() for p in ai_text.split("\n") if p.strip() and len(p.strip()) > 40]
|
| 1739 |
-
current = ""
|
| 1740 |
-
count = 0
|
| 1741 |
-
for p in paragraphs:
|
| 1742 |
-
if p.startswith("## ") or p.startswith("### ") or p.startswith("---"):
|
| 1743 |
-
if current and count < 6:
|
| 1744 |
-
slide_parts.append(current)
|
| 1745 |
-
count += 1
|
| 1746 |
-
current = ""
|
| 1747 |
-
elif len(p) > 80:
|
| 1748 |
-
if current:
|
| 1749 |
-
current += "\n\n" + p
|
| 1750 |
-
else:
|
| 1751 |
-
current = p
|
| 1752 |
-
if current and count < 6:
|
| 1753 |
-
slide_parts.append(current)
|
| 1754 |
-
|
| 1755 |
-
if len(slide_parts) < 2:
|
| 1756 |
-
slide_parts = [opinion[:300]]
|
| 1757 |
-
for sd in source_details[:5]:
|
| 1758 |
-
slide_parts.append(sd.get("title", "")[:200] + "\n" + (sd.get("paragraphs", [""])[0][:200] if sd.get("paragraphs") else ""))
|
| 1759 |
-
|
| 1760 |
-
slides = []
|
| 1761 |
-
total = min(len(slide_parts), 6)
|
| 1762 |
-
for i in range(total):
|
| 1763 |
-
img = source_images[i] if i < len(source_images) else ""
|
| 1764 |
-
slides.append({
|
| 1765 |
-
"text": slide_parts[i],
|
| 1766 |
-
"image": img,
|
| 1767 |
-
"index": i + 1
|
| 1768 |
-
})
|
| 1769 |
-
|
| 1770 |
-
lang, emotion = detect_language_and_emotion(title, ai_text)
|
| 1771 |
-
voice = get_voice_for_content(title, ai_text)
|
| 1772 |
-
|
| 1773 |
-
post = {
|
| 1774 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 1775 |
-
"title": title,
|
| 1776 |
-
"text": ai_text,
|
| 1777 |
-
"img": source_images[0] if source_images else "",
|
| 1778 |
-
"url": "",
|
| 1779 |
-
"kind": "personal_opinion",
|
| 1780 |
-
"slides": slides,
|
| 1781 |
-
"images": source_images[:10],
|
| 1782 |
-
"video": "",
|
| 1783 |
-
"voice": voice,
|
| 1784 |
-
"emotion": emotion,
|
| 1785 |
-
"language": lang,
|
| 1786 |
-
"ts": int(time.time()),
|
| 1787 |
-
"sources": source_details[:5]
|
| 1788 |
-
}
|
| 1789 |
-
|
| 1790 |
-
posts = _load_wall_posts()
|
| 1791 |
-
posts.insert(0, post)
|
| 1792 |
-
_save_wall_posts(posts)
|
| 1793 |
-
|
| 1794 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1795 |
-
|
| 1796 |
-
|
| 1797 |
-
# ===== END PERSONAL OPINION POST v2 =====
|
| 1798 |
-
|
| 1799 |
-
def _bg():
|
| 1800 |
-
time.sleep(15)
|
| 1801 |
-
while True:
|
| 1802 |
-
try:get_wc2026_all()
|
| 1803 |
-
except:pass
|
| 1804 |
-
time.sleep(90)
|
| 1805 |
-
threading.Thread(target=_bg,daemon=True).start()
|
| 1806 |
-
|
| 1807 |
-
# ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
|
| 1808 |
-
_AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
|
| 1809 |
-
_AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
|
| 1810 |
-
|
| 1811 |
-
def _load_auto_log():
|
| 1812 |
-
try:
|
| 1813 |
-
if os.path.exists(_AUTO_LOG):
|
| 1814 |
-
with open(_AUTO_LOG, 'r') as f:
|
| 1815 |
-
return json.load(f)
|
| 1816 |
-
except: pass
|
| 1817 |
-
return {}
|
| 1818 |
-
|
| 1819 |
-
def _save_auto_log(log):
|
| 1820 |
-
try:
|
| 1821 |
-
tmp = _AUTO_LOG + '.tmp'
|
| 1822 |
-
with open(tmp, 'w') as f:
|
| 1823 |
-
json.dump(log, f)
|
| 1824 |
-
os.replace(tmp, _AUTO_LOG)
|
| 1825 |
-
except: pass
|
| 1826 |
-
|
| 1827 |
-
async def _auto_fetch_short(post_id):
|
| 1828 |
-
"""Try to auto-generate a short for a post."""
|
| 1829 |
-
try:
|
| 1830 |
-
import httpx
|
| 1831 |
-
async with httpx.AsyncClient(timeout=180) as cl:
|
| 1832 |
-
r = await cl.post(
|
| 1833 |
-
f"http://localhost:7860/api/ai/short/{post_id}",
|
| 1834 |
-
json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
|
| 1835 |
-
headers={"Content-Type":"application/json"}
|
| 1836 |
-
)
|
| 1837 |
-
if r.status_code < 300:
|
| 1838 |
-
sj = r.json()
|
| 1839 |
-
if sj.get('video'):
|
| 1840 |
-
posts = _load_wall_posts()
|
| 1841 |
-
for p in posts:
|
| 1842 |
-
if p.get('id') == post_id:
|
| 1843 |
-
p['video'] = sj['video']
|
| 1844 |
-
break
|
| 1845 |
-
_save_wall_posts(posts)
|
| 1846 |
-
return True
|
| 1847 |
-
except: pass
|
| 1848 |
-
return False
|
| 1849 |
-
|
| 1850 |
-
async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
|
| 1851 |
-
"""Rewrite one topic: find articles, summarize, post to wall, trigger short.
|
| 1852 |
-
used_urls: shared set to avoid duplicate articles across topics.
|
| 1853 |
-
post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
|
| 1854 |
-
from urllib.parse import quote as _q
|
| 1855 |
-
# Get MORE items to support 1-3 posts per topic
|
| 1856 |
-
items = _search_all(topic, limit=12)
|
| 1857 |
-
# Skip URLs already used by another topic
|
| 1858 |
-
if used_urls is not None:
|
| 1859 |
-
filtered = [it for it in items if it.get('url') not in used_urls]
|
| 1860 |
-
if filtered:
|
| 1861 |
-
items = filtered
|
| 1862 |
-
if not items or post_index >= len(items):
|
| 1863 |
-
return False
|
| 1864 |
-
|
| 1865 |
-
# Get article at post_index (0,1,2 for multiple posts)
|
| 1866 |
-
item = items[post_index] # post_index allows multiple articles per topic
|
| 1867 |
-
url = item.get('url', '')
|
| 1868 |
-
title = item.get('title', topic)
|
| 1869 |
-
if url and used_urls is not None:
|
| 1870 |
-
used_urls.add(url)
|
| 1871 |
-
if not url.startswith('http'):
|
| 1872 |
-
return False
|
| 1873 |
-
|
| 1874 |
-
data = _scrape_article_for_rewrite(url)
|
| 1875 |
-
if not data or not data.get('paragraphs'):
|
| 1876 |
-
return False
|
| 1877 |
-
|
| 1878 |
-
raw_text = '\n'.join(data['paragraphs'])
|
| 1879 |
-
ai_text = None
|
| 1880 |
-
|
| 1881 |
-
# Try AI generation
|
| 1882 |
-
try:
|
| 1883 |
-
import ai_ext
|
| 1884 |
-
prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
|
| 1885 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
|
| 1886 |
-
except: pass
|
| 1887 |
-
|
| 1888 |
-
if not ai_text or len(ai_text) < 80:
|
| 1889 |
-
pts = data['paragraphs'][:6]
|
| 1890 |
-
ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
|
| 1891 |
-
via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
|
| 1892 |
-
ai_text += f"\n\nNguồn tham khảo: {via}"
|
| 1893 |
-
|
| 1894 |
-
# Build slides
|
| 1895 |
-
images = data.get('images', [])
|
| 1896 |
-
pts = data['paragraphs'][:10]
|
| 1897 |
-
slides = []
|
| 1898 |
-
for i, p in enumerate(pts[:8]):
|
| 1899 |
-
img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
|
| 1900 |
-
slides.append({'text': p[:300], 'image': img, 'index': i + 1})
|
| 1901 |
-
|
| 1902 |
-
post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
|
| 1903 |
-
post = {
|
| 1904 |
-
"id": post_id, "title": data.get('title', title)[:200],
|
| 1905 |
-
"text": ai_text, "img": images[0] if images else data.get('og_img', ''),
|
| 1906 |
-
"url": url, "kind": "auto_rewrite", "slides": slides,
|
| 1907 |
-
"images": images[:10], "video": "",
|
| 1908 |
-
"voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
|
| 1909 |
-
"language": "vietnamese", "ts": int(time.time()),
|
| 1910 |
-
"auto_scheduled": True, "slot": slot_label,
|
| 1911 |
-
}
|
| 1912 |
-
|
| 1913 |
-
posts = _load_wall_posts()
|
| 1914 |
-
posts.insert(0, post)
|
| 1915 |
-
_save_wall_posts(posts)
|
| 1916 |
-
|
| 1917 |
-
# Trigger short generation async
|
| 1918 |
-
threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
|
| 1919 |
-
return True
|
| 1920 |
-
|
| 1921 |
-
async def _do_scheduled_run(slot_label):
|
| 1922 |
-
"""Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
|
| 1923 |
-
print(f"[auto] Starting scheduled rewrite for {slot_label}")
|
| 1924 |
-
|
| 1925 |
-
# Get top hot topics, skip duplicates
|
| 1926 |
-
all_topics = _get_hot_topics()
|
| 1927 |
-
seen_topics = set()
|
| 1928 |
-
unique_topics = []
|
| 1929 |
-
for t in all_topics:
|
| 1930 |
-
kw = t.get('topic', '').lower().strip()
|
| 1931 |
-
if kw and len(kw) > 5 and kw not in seen_topics:
|
| 1932 |
-
is_dup = False
|
| 1933 |
-
for s in seen_topics:
|
| 1934 |
-
# Check if one topic is substring of another
|
| 1935 |
-
if kw in s or s in kw:
|
| 1936 |
-
is_dup = True
|
| 1937 |
-
break
|
| 1938 |
-
if not is_dup:
|
| 1939 |
-
seen_topics.add(kw)
|
| 1940 |
-
unique_topics.append(t)
|
| 1941 |
-
if len(unique_topics) >= 3:
|
| 1942 |
-
break
|
| 1943 |
-
|
| 1944 |
-
job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
|
| 1945 |
-
if not job_topics:
|
| 1946 |
-
print(f"[auto] No hot topics found, skipping")
|
| 1947 |
-
return
|
| 1948 |
-
|
| 1949 |
-
print(f"[auto] Running 3 topics: {job_topics}")
|
| 1950 |
-
|
| 1951 |
-
# Track used URLs to avoid cross-topic duplicates
|
| 1952 |
-
_used_urls = set()
|
| 1953 |
-
results = []
|
| 1954 |
-
|
| 1955 |
-
# Process each topic, create 1-3 posts per topic
|
| 1956 |
-
for jt in job_topics:
|
| 1957 |
-
for post_idx in range(3): # Try up to 3 posts per topic
|
| 1958 |
-
try:
|
| 1959 |
-
ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
|
| 1960 |
-
if ok:
|
| 1961 |
-
results.append((jt, post_idx, True))
|
| 1962 |
-
print(f"[auto] Created post {post_idx+1} for '{jt}'")
|
| 1963 |
-
else:
|
| 1964 |
-
# No more articles for this topic
|
| 1965 |
-
break
|
| 1966 |
-
except Exception as e:
|
| 1967 |
-
print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
|
| 1968 |
-
results.append((jt, post_idx, False))
|
| 1969 |
-
await asyncio.sleep(1) # Small delay between posts
|
| 1970 |
-
|
| 1971 |
-
# Ensure at least 3 posts total (fallback if needed)
|
| 1972 |
-
successful_posts = sum(1 for _, _, ok in results if ok)
|
| 1973 |
-
print(f"[auto] Done {slot_label}: {successful_posts} posts created")
|
| 1974 |
-
|
| 1975 |
-
# Log
|
| 1976 |
-
from datetime import datetime, timezone, timedelta
|
| 1977 |
-
VN_TZ_SCHED = timezone(timedelta(hours=7))
|
| 1978 |
-
today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
|
| 1979 |
-
log = _load_auto_log()
|
| 1980 |
-
if today_str not in log: log[today_str] = {}
|
| 1981 |
-
log[today_str][slot_label] = {
|
| 1982 |
-
'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
|
| 1983 |
-
'count': successful_posts,
|
| 1984 |
-
'total': len(job_topics),
|
| 1985 |
-
}
|
| 1986 |
-
_save_auto_log(log)
|
| 1987 |
-
|
| 1988 |
-
def _scheduler_loop():
|
| 1989 |
-
"""Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
|
| 1990 |
-
On startup, check for any missed slots today and run them immediately."""
|
| 1991 |
-
time.sleep(35)
|
| 1992 |
-
from datetime import datetime, timezone, timedelta
|
| 1993 |
-
VN_TZ_SCHED = timezone(timedelta(hours=7))
|
| 1994 |
-
|
| 1995 |
-
_last_run_date = ""
|
| 1996 |
-
_last_run_slots = set()
|
| 1997 |
-
|
| 1998 |
-
# On startup: check log for missed slots today
|
| 1999 |
-
try:
|
| 2000 |
-
start_now = datetime.now(VN_TZ_SCHED)
|
| 2001 |
-
today_str = start_now.strftime('%Y-%m-%d')
|
| 2002 |
-
current_hour = start_now.hour
|
| 2003 |
-
current_minute = start_now.minute
|
| 2004 |
-
log = _load_auto_log()
|
| 2005 |
-
today_log = log.get(today_str, {})
|
| 2006 |
-
for h, label in _AUTO_SCHEDULE_TIMES:
|
| 2007 |
-
# Run if slot is past (either strictly earlier hour, or same hour but window has passed)
|
| 2008 |
-
should_run = False
|
| 2009 |
-
if h < current_hour:
|
| 2010 |
-
should_run = True
|
| 2011 |
-
elif h == current_hour and current_minute > 10:
|
| 2012 |
-
should_run = True
|
| 2013 |
-
if should_run and label not in today_log:
|
| 2014 |
-
print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
|
| 2015 |
-
_run_scheduled_sync(label)
|
| 2016 |
-
_last_run_slots.add(label)
|
| 2017 |
-
except Exception as e:
|
| 2018 |
-
print(f"[auto] Catch-up check error: {e}")
|
| 2019 |
-
|
| 2020 |
-
while True:
|
| 2021 |
-
try:
|
| 2022 |
-
now = datetime.now(VN_TZ_SCHED)
|
| 2023 |
-
today = now.strftime('%Y-%m-%d')
|
| 2024 |
-
hour = now.hour
|
| 2025 |
-
minute = now.minute
|
| 2026 |
-
|
| 2027 |
-
if today != _last_run_date:
|
| 2028 |
-
_last_run_date = today
|
| 2029 |
-
_last_run_slots = set()
|
| 2030 |
-
|
| 2031 |
-
slot = None
|
| 2032 |
-
for h, label in _AUTO_SCHEDULE_TIMES:
|
| 2033 |
-
if hour == h and 0 <= minute < 5:
|
| 2034 |
-
slot = label
|
| 2035 |
-
break
|
| 2036 |
-
|
| 2037 |
-
if slot and slot not in _last_run_slots:
|
| 2038 |
-
_last_run_slots.add(slot)
|
| 2039 |
-
_run_scheduled_sync(slot)
|
| 2040 |
-
except Exception as e:
|
| 2041 |
-
print(f"[auto] Loop error: {e}")
|
| 2042 |
-
|
| 2043 |
-
time.sleep(60)
|
| 2044 |
-
|
| 2045 |
-
threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
|
| 2046 |
-
|
| 2047 |
-
@app.get('/api/debug/auto_schedule')
|
| 2048 |
-
async def debug_auto_schedule(slot: str = '07:00'):
|
| 2049 |
-
"""Manually trigger auto scheduler for debugging."""
|
| 2050 |
-
try:
|
| 2051 |
-
# Check if we can access the data directory
|
| 2052 |
-
log = _load_auto_log()
|
| 2053 |
-
topics = _get_hot_topics()[:3]
|
| 2054 |
-
job_topics = [t['topic'] for t in topics if t.get('topic')]
|
| 2055 |
-
return JSONResponse({
|
| 2056 |
-
"slot": slot,
|
| 2057 |
-
"log": log,
|
| 2058 |
-
"hot_topics": job_topics,
|
| 2059 |
-
"wall_posts_count": len(_load_wall_posts()),
|
| 2060 |
-
"data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
|
| 2061 |
-
"data_dir_exists": os.path.isdir(DATA_DIR),
|
| 2062 |
-
})
|
| 2063 |
-
except Exception as e:
|
| 2064 |
-
return JSONResponse({"error": str(e)}, status_code=500)
|
| 2065 |
-
|
| 2066 |
-
def _run_scheduled_sync(slot):
|
| 2067 |
-
"""Run _do_scheduled_run in a separate event loop (for background thread)."""
|
| 2068 |
-
loop = asyncio.new_event_loop()
|
| 2069 |
-
asyncio.set_event_loop(loop)
|
| 2070 |
-
try:
|
| 2071 |
-
loop.run_until_complete(_do_scheduled_run(slot))
|
| 2072 |
-
except Exception as e:
|
| 2073 |
-
print(f"[auto] Background run error: {e}")
|
| 2074 |
-
finally:
|
| 2075 |
-
loop.close()
|
| 2076 |
-
|
| 2077 |
-
@app.get('/api/debug/trigger_auto')
|
| 2078 |
-
async def debug_trigger_auto(slot: str = '19:00'):
|
| 2079 |
-
"""Trigger _do_scheduled_run in background thread (non-blocking)."""
|
| 2080 |
-
threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
|
| 2081 |
-
return JSONResponse({"status": "started", "slot": slot})
|
| 2082 |
-
|
| 2083 |
-
# ===== SHORTS RSS PROXY ENDPOINT =====
|
| 2084 |
-
@app.get("/api/shorts/rss")
|
| 2085 |
-
def shorts_rss():
|
| 2086 |
-
"""Get shorts from YouTube RSS feeds server-side"""
|
| 2087 |
-
import xml.etree.ElementTree as ET
|
| 2088 |
-
import html as html_lib2
|
| 2089 |
-
import re as re2
|
| 2090 |
-
|
| 2091 |
-
YOUTUBE_CHANNELS = {
|
| 2092 |
-
"baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
|
| 2093 |
-
"baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
|
| 2094 |
-
}
|
| 2095 |
-
|
| 2096 |
-
shorts = []
|
| 2097 |
-
seen = set()
|
| 2098 |
-
|
| 2099 |
-
for handle, channel_id in YOUTUBE_CHANNELS.items():
|
| 2100 |
-
try:
|
| 2101 |
-
rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 2102 |
-
r = req.get(rss_url, headers=HEADERS, timeout=15)
|
| 2103 |
-
if r.status_code != 200:
|
| 2104 |
-
continue
|
| 2105 |
-
|
| 2106 |
-
root = ET.fromstring(r.text)
|
| 2107 |
-
ns = {
|
| 2108 |
-
'atom': 'http://www.w3.org/2005/Atom',
|
| 2109 |
-
'yt': 'http://www.youtube.com/xml/schemas/2015',
|
| 2110 |
-
'media': 'http://search.yahoo.com/mrss/'
|
| 2111 |
-
}
|
| 2112 |
-
|
| 2113 |
-
for entry in root.findall('atom:entry', ns)[:30]:
|
| 2114 |
-
title_el = entry.find('atom:title', ns)
|
| 2115 |
-
title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
|
| 2116 |
-
|
| 2117 |
-
link_el = entry.find('atom:link', ns)
|
| 2118 |
-
link = link_el.get('href', '') if link_el is not None else ''
|
| 2119 |
-
|
| 2120 |
-
vid_el = entry.find('yt:videoId', ns)
|
| 2121 |
-
vid = vid_el.text if vid_el is not None else ''
|
| 2122 |
-
|
| 2123 |
-
if not vid or vid in seen:
|
| 2124 |
-
continue
|
| 2125 |
-
|
| 2126 |
-
# Check if it's a short
|
| 2127 |
-
is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
|
| 2128 |
-
|
| 2129 |
-
if not is_short:
|
| 2130 |
-
desc_el = entry.find('media:description', ns)
|
| 2131 |
-
if desc_el is not None and desc_el.text:
|
| 2132 |
-
if '#shorts' in desc_el.text.lower():
|
| 2133 |
-
is_short = True
|
| 2134 |
-
|
| 2135 |
-
if not is_short:
|
| 2136 |
-
continue
|
| 2137 |
-
|
| 2138 |
-
seen.add(vid)
|
| 2139 |
-
|
| 2140 |
-
# Get thumbnail
|
| 2141 |
-
thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
|
| 2142 |
-
media_group = entry.find('media:group', ns)
|
| 2143 |
-
if media_group is not None:
|
| 2144 |
-
thumb_el = media_group.find('media:thumbnail', ns)
|
| 2145 |
-
if thumb_el is not None:
|
| 2146 |
-
thumb = thumb_el.get('url', thumb)
|
| 2147 |
-
|
| 2148 |
-
shorts.append({
|
| 2149 |
-
'id': vid,
|
| 2150 |
-
'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
|
| 2151 |
-
'img': thumb,
|
| 2152 |
-
'link': f'https://www.youtube.com/shorts/{vid}',
|
| 2153 |
-
'channel': handle,
|
| 2154 |
-
'source': 'yt'
|
| 2155 |
-
})
|
| 2156 |
-
|
| 2157 |
-
if len(shorts) >= 40:
|
| 2158 |
-
break
|
| 2159 |
-
|
| 2160 |
-
except Exception as e:
|
| 2161 |
-
print(f"RSS error for {handle}: {e}")
|
| 2162 |
-
continue
|
| 2163 |
-
|
| 2164 |
-
return {"shorts": shorts, "count": len(shorts)}
|
| 2165 |
-
|
| 2166 |
-
app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main.py
DELETED
|
@@ -1,556 +0,0 @@
|
|
| 1 |
-
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels"""
|
| 2 |
-
import re, time, subprocess, json, os, threading
|
| 3 |
-
import html as html_lib
|
| 4 |
-
from datetime import datetime, timezone, timedelta, date
|
| 5 |
-
from collections import defaultdict
|
| 6 |
-
|
| 7 |
-
VN_TZ = timezone(timedelta(hours=7))
|
| 8 |
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
-
from fastapi import FastAPI, Query, Request
|
| 10 |
-
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
|
| 11 |
-
from urllib.parse import quote
|
| 12 |
-
import requests
|
| 13 |
-
from bs4 import BeautifulSoup
|
| 14 |
-
|
| 15 |
-
app = FastAPI()
|
| 16 |
-
|
| 17 |
-
# ===== WORLD CUP 2026 SCRAPER =====
|
| 18 |
-
from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
|
| 19 |
-
|
| 20 |
-
# ===== RATE LIMITING =====
|
| 21 |
-
_rate_limit_data = defaultdict(list)
|
| 22 |
-
_rate_limit_lock = threading.Lock()
|
| 23 |
-
RATE_LIMIT_MAX = 60
|
| 24 |
-
RATE_LIMIT_WINDOW = 60
|
| 25 |
-
|
| 26 |
-
def _check_rate_limit(ip: str) -> bool:
|
| 27 |
-
with _rate_limit_lock:
|
| 28 |
-
now = time.time()
|
| 29 |
-
_rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 30 |
-
if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: return False
|
| 31 |
-
_rate_limit_data[ip].append(now)
|
| 32 |
-
return True
|
| 33 |
-
|
| 34 |
-
@app.middleware("http")
|
| 35 |
-
async def rate_limit_middleware(request: Request, call_next):
|
| 36 |
-
if request.url.path.startswith("/api/"):
|
| 37 |
-
ip = request.client.host
|
| 38 |
-
if not _check_rate_limit(ip): return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
|
| 39 |
-
return await call_next(request)
|
| 40 |
-
|
| 41 |
-
# ===== VTV CHANNELS API =====
|
| 42 |
-
from vtv_api import router as vtv_router
|
| 43 |
-
app.include_router(vtv_router)
|
| 44 |
-
|
| 45 |
-
HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
|
| 46 |
-
BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
|
| 47 |
-
BASE_BDP = "https://bongdaplus.vn"
|
| 48 |
-
_cache = {}
|
| 49 |
-
_cache_ttl = 300
|
| 50 |
-
_cache_ttl_live = 60
|
| 51 |
-
_cache_ttl_yt = 1800
|
| 52 |
-
|
| 53 |
-
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 54 |
-
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 55 |
-
HL_LEAGUES = {
|
| 56 |
-
"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴"},
|
| 57 |
-
"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
|
| 58 |
-
"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},
|
| 59 |
-
"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},
|
| 60 |
-
"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},
|
| 61 |
-
"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},
|
| 62 |
-
"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
|
| 63 |
-
"world-cup":{"path":"the-gioi/world-cup","name":"World Cup 2026","emoji":"🌍"},
|
| 64 |
-
}
|
| 65 |
-
def _cached(key, fn, ttl=None):
|
| 66 |
-
now=time.time(); t=ttl or _cache_ttl
|
| 67 |
-
if key in _cache and now-_cache[key]["t"]<t: return _cache[key]["d"]
|
| 68 |
-
try: data=fn()
|
| 69 |
-
except: data=_cache.get(key,{}).get("d",[])
|
| 70 |
-
_cache[key]={"d":data,"t":now}; return data
|
| 71 |
-
def _get(url, headers=None):
|
| 72 |
-
h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8"
|
| 73 |
-
return BeautifulSoup(r.text,"lxml")
|
| 74 |
-
def fetch_bongda_api(endpoint):
|
| 75 |
-
try:
|
| 76 |
-
r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
|
| 77 |
-
if r.status_code==200:
|
| 78 |
-
data=r.json()
|
| 79 |
-
if data.get("status")=="success": return data.get("html","")
|
| 80 |
-
return ""
|
| 81 |
-
except: return ""
|
| 82 |
-
|
| 83 |
-
def _parse_match_from_li(li, status_type="live"):
|
| 84 |
-
match_div=li.select_one("div.match")
|
| 85 |
-
if not match_div: return None
|
| 86 |
-
home_el=match_div.select_one(".home-team .name"); away_el=match_div.select_one(".away-team .name")
|
| 87 |
-
if not home_el or not away_el: return None
|
| 88 |
-
status_el=match_div.select_one(".status a"); league_el=li.find_previous("strong"); time_el=match_div.select_one(".match-time")
|
| 89 |
-
home_logo=match_div.select_one(".home-team .logo img"); away_logo=match_div.select_one(".away-team .logo img")
|
| 90 |
-
event_id=""
|
| 91 |
-
if status_el:
|
| 92 |
-
href=status_el.get("href",""); m=re.search(r'/tran-dau/(\d+)/',href)
|
| 93 |
-
if m: event_id=m.group(1)
|
| 94 |
-
spans=status_el.find_all("span") if status_el else []; score=""; minute=""
|
| 95 |
-
if len(spans)>=3: score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
|
| 96 |
-
if len(spans)>=4: minute=spans[3].get_text(strip=True)
|
| 97 |
-
if not score and status_el and status_el.select_one(".vs"): score="VS"
|
| 98 |
-
league=league_el.get_text(strip=True) if league_el else ""
|
| 99 |
-
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,"home_logo":home_logo.get("src","") if home_logo else "","away_logo":away_logo.get("src","") if away_logo else "","status":status_type}
|
| 100 |
-
|
| 101 |
-
# ===== VIDEO PROXY =====
|
| 102 |
-
@app.get("/api/proxy/m3u8")
|
| 103 |
-
def proxy_m3u8(url: str = Query(...)):
|
| 104 |
-
try:
|
| 105 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 106 |
-
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 107 |
-
lines = r.text.strip().split('\n'); rewritten = []
|
| 108 |
-
for line in lines:
|
| 109 |
-
if line.startswith('#') or not line.strip(): rewritten.append(line)
|
| 110 |
-
else: rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe=""))
|
| 111 |
-
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"})
|
| 112 |
-
except: return Response(status_code=502, content="proxy error")
|
| 113 |
-
|
| 114 |
-
@app.get("/api/proxy/seg")
|
| 115 |
-
def proxy_segment(url: str = Query(...)):
|
| 116 |
-
try:
|
| 117 |
-
r = requests.get(url, headers=HEADERS, timeout=30)
|
| 118 |
-
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 119 |
-
data = r.content
|
| 120 |
-
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
|
| 121 |
-
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
|
| 122 |
-
except: return Response(status_code=502, content="proxy error")
|
| 123 |
-
|
| 124 |
-
@app.get("/api/proxy/video")
|
| 125 |
-
def proxy_video(url: str = Query(...), request: Request = None):
|
| 126 |
-
try:
|
| 127 |
-
req_headers = dict(HEADERS)
|
| 128 |
-
if request and request.headers.get("range"): req_headers["Range"] = request.headers["range"]
|
| 129 |
-
r = requests.get(url, headers=req_headers, timeout=30, stream=True)
|
| 130 |
-
resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
|
| 131 |
-
if "Content-Range" in r.headers: resp_headers["Content-Range"] = r.headers["Content-Range"]
|
| 132 |
-
if "Content-Length" in r.headers: resp_headers["Content-Length"] = r.headers["Content-Length"]
|
| 133 |
-
return StreamingResponse(r.iter_content(chunk_size=256*1024), status_code=r.status_code, headers=resp_headers)
|
| 134 |
-
except: return Response(status_code=502, content="proxy error")
|
| 135 |
-
|
| 136 |
-
@app.get("/api/proxy/img")
|
| 137 |
-
def proxy_img(url: str = Query(...)):
|
| 138 |
-
try:
|
| 139 |
-
from urllib.parse import urlparse
|
| 140 |
-
_u = urlparse(url); _host = _u.netloc.lower()
|
| 141 |
-
_referer = "https://dantri.com.vn/"
|
| 142 |
-
if "refooty" in _host or "xemlaibongda" in _host: _referer = "https://xemlaibongda.top/"
|
| 143 |
-
elif "ytimg" in _host or "youtube" in _host: _referer = "https://www.youtube.com/"
|
| 144 |
-
elif "vncecdn" in _host or "vnexpress" in _host: _referer = "https://vnexpress.net/"
|
| 145 |
-
r = requests.get(url, headers={**HEADERS, "Referer": _referer}, timeout=10)
|
| 146 |
-
if r.status_code != 200: return Response(status_code=502)
|
| 147 |
-
return Response(content=r.content, media_type=r.headers.get("Content-Type", "image/jpeg"), headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
|
| 148 |
-
except: return Response(status_code=502)
|
| 149 |
-
|
| 150 |
-
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
| 151 |
-
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 152 |
-
try:
|
| 153 |
-
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 154 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 155 |
-
if r.status_code != 200: return []
|
| 156 |
-
r.encoding = "utf-8"
|
| 157 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 158 |
-
videos = []; seen = set()
|
| 159 |
-
for a in soup.find_all("a", href=True):
|
| 160 |
-
href = a.get("href", "")
|
| 161 |
-
if "/video/" not in href and "/xem-lai/" not in href: continue
|
| 162 |
-
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 163 |
-
clean_href = href.split("?")[0].split("#")[0]
|
| 164 |
-
if clean_href in seen: continue
|
| 165 |
-
seen.add(clean_href)
|
| 166 |
-
img_src = ""
|
| 167 |
-
img = a.find("img")
|
| 168 |
-
if not img and a.parent: img = a.parent.find("img")
|
| 169 |
-
if not img:
|
| 170 |
-
p = a.parent
|
| 171 |
-
for _ in range(4):
|
| 172 |
-
if p and p.find("img"): img = p.find("img"); break
|
| 173 |
-
p = p.parent if p else None
|
| 174 |
-
if img:
|
| 175 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "") or img.get("data-thumb", "") or img.get("data-image", ""))
|
| 176 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 177 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 178 |
-
if not img_src:
|
| 179 |
-
p = a.parent
|
| 180 |
-
for _ in range(5):
|
| 181 |
-
if p is None: break
|
| 182 |
-
style = p.get("style", "")
|
| 183 |
-
bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
|
| 184 |
-
if bg_match:
|
| 185 |
-
img_src = bg_match.group(1)
|
| 186 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 187 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 188 |
-
break
|
| 189 |
-
p = p.parent if p else None
|
| 190 |
-
title = ""
|
| 191 |
-
for attr in ["title", "aria-label"]:
|
| 192 |
-
val = a.get(attr, "")
|
| 193 |
-
if val and len(val) >= 5: title = val; break
|
| 194 |
-
if not title:
|
| 195 |
-
for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
|
| 196 |
-
try:
|
| 197 |
-
el = a.select_one(selector)
|
| 198 |
-
if el: t = el.get_text(strip=True)
|
| 199 |
-
if t and len(t) >= 5: title = t; break
|
| 200 |
-
except: pass
|
| 201 |
-
if not title:
|
| 202 |
-
text = a.get_text(strip=True)
|
| 203 |
-
if text and len(text) >= 5: title = text[:100]
|
| 204 |
-
if not title or len(title) < 3:
|
| 205 |
-
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 206 |
-
title = slug.replace("-", " ").replace("_", " ").title()
|
| 207 |
-
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 208 |
-
if not title or len(title) < 3: continue
|
| 209 |
-
if not img_src:
|
| 210 |
-
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 211 |
-
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 212 |
-
videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
|
| 213 |
-
if len(videos) >= limit: break
|
| 214 |
-
return videos
|
| 215 |
-
except Exception as e:
|
| 216 |
-
print(f"[xemlaibongda] Error: {e}"); return []
|
| 217 |
-
|
| 218 |
-
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 219 |
-
def scrape_highlights_by_league(league_key):
|
| 220 |
-
if league_key not in HL_LEAGUES: return []
|
| 221 |
-
return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
|
| 222 |
-
def scrape_all_league_highlights():
|
| 223 |
-
results = {}
|
| 224 |
-
def _fetch(key): return key, scrape_highlights_by_league(key)
|
| 225 |
-
with ThreadPoolExecutor(8) as ex:
|
| 226 |
-
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 227 |
-
for f in as_completed(futs, timeout=25):
|
| 228 |
-
try: key, vids = f.result()
|
| 229 |
-
except: continue
|
| 230 |
-
if vids: results[key] = vids
|
| 231 |
-
return results
|
| 232 |
-
|
| 233 |
-
def extract_xemlaibongda_video(url):
|
| 234 |
-
try:
|
| 235 |
-
r=requests.get(url, headers=HEADERS, timeout=15)
|
| 236 |
-
if r.status_code!=200: return None
|
| 237 |
-
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml")
|
| 238 |
-
og=soup.find("meta",property="og:image")
|
| 239 |
-
og_poster=og.get("content","") if og else ""
|
| 240 |
-
if og_poster.startswith("//"): og_poster="https:"+og_poster
|
| 241 |
-
video=soup.find("video")
|
| 242 |
-
if video:
|
| 243 |
-
src=video.get("src",""); poster=video.get("poster","")
|
| 244 |
-
if not src:
|
| 245 |
-
source=video.find("source")
|
| 246 |
-
if source: src=source.get("src","")
|
| 247 |
-
if not poster: poster=og_poster
|
| 248 |
-
if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
| 249 |
-
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 250 |
-
if m3u8s: return{"src":m3u8s[0],"poster":og_poster,"type":"hls"}
|
| 251 |
-
yt_iframe = soup.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed"))
|
| 252 |
-
if yt_iframe: return{"src":yt_iframe.get("src",""),"poster":og_poster,"type":"youtube"}
|
| 253 |
-
return None
|
| 254 |
-
except: return None
|
| 255 |
-
|
| 256 |
-
# ===== LIVESCORE =====
|
| 257 |
-
@app.get("/api/livescore/live")
|
| 258 |
-
def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
| 259 |
-
@app.get("/api/livescore/incoming")
|
| 260 |
-
def api_livescore_incoming(): return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)})
|
| 261 |
-
@app.get("/api/livescore/today")
|
| 262 |
-
def api_livescore_today():
|
| 263 |
-
today=datetime.now(VN_TZ).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)})
|
| 264 |
-
@app.get("/api/livescore/results")
|
| 265 |
-
def api_livescore_results():
|
| 266 |
-
today=datetime.now(VN_TZ).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)})
|
| 267 |
-
@app.get("/api/livescore/standings/{league}")
|
| 268 |
-
def api_livescore_standings(league:str):
|
| 269 |
-
tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
|
| 270 |
-
@app.get("/api/livescore/date/{date}")
|
| 271 |
-
def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
|
| 272 |
-
|
| 273 |
-
@app.get("/api/livescore/updates7d")
|
| 274 |
-
def api_livescore_updates7d():
|
| 275 |
-
"""Aggregate results + incoming matches from past 7 days and next 7 days."""
|
| 276 |
-
def _f():
|
| 277 |
-
from datetime import date as _date
|
| 278 |
-
today = _date.today()
|
| 279 |
-
all_html = []
|
| 280 |
-
# Past 7 days (results)
|
| 281 |
-
for i in range(7, 0, -1):
|
| 282 |
-
d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 283 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 284 |
-
if html and len(html) > 50:
|
| 285 |
-
soup = BeautifulSoup(html, "lxml")
|
| 286 |
-
day_label = (today - timedelta(days=i)).strftime("%d/%m")
|
| 287 |
-
for match in soup.select(".match-detail"):
|
| 288 |
-
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 289 |
-
dt.string = f"📅 {day_label}"
|
| 290 |
-
match.insert(0, dt)
|
| 291 |
-
all_html.append(str(soup))
|
| 292 |
-
# Next 7 days (upcoming)
|
| 293 |
-
for i in range(7):
|
| 294 |
-
d = (today + timedelta(days=i)).strftime("%Y-%m-%d")
|
| 295 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
|
| 296 |
-
if html and len(html) > 50:
|
| 297 |
-
soup = BeautifulSoup(html, "lxml")
|
| 298 |
-
day_label = (today + timedelta(days=i)).strftime("%d/%m")
|
| 299 |
-
for match in soup.select(".match-detail"):
|
| 300 |
-
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 301 |
-
dt.string = f"📅 {day_label}"
|
| 302 |
-
match.insert(0, dt)
|
| 303 |
-
all_html.append(str(soup))
|
| 304 |
-
combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
|
| 305 |
-
return combined if all_html else ""
|
| 306 |
-
return JSONResponse({"html": _cached("ls_updates7d", _f, ttl=_cache_ttl)})
|
| 307 |
-
|
| 308 |
-
@app.get("/api/match/{event_id}/commentaries")
|
| 309 |
-
def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
|
| 310 |
-
@app.get("/api/match/{event_id}/stats")
|
| 311 |
-
def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/event-standing/player-performance?event_id={event_id}")})
|
| 312 |
-
|
| 313 |
-
from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
|
| 314 |
-
|
| 315 |
-
@app.get("/api/match/{event_id}/detail")
|
| 316 |
-
def api_match_detail(event_id: int, url: str = Query(default="")):
|
| 317 |
-
try:
|
| 318 |
-
if url: data = fetch_match_detail_by_url(url)
|
| 319 |
-
else: data = fetch_match_detail(event_id)
|
| 320 |
-
return JSONResponse(data)
|
| 321 |
-
except Exception as e: return JSONResponse({"event_id": event_id, "found": False, "error": str(e)})
|
| 322 |
-
|
| 323 |
-
@app.get("/api/livescore/featured")
|
| 324 |
-
def api_livescore_featured():
|
| 325 |
-
def _f():
|
| 326 |
-
sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now(VN_TZ).strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")]
|
| 327 |
-
for endpoint, stype in sources:
|
| 328 |
-
html=fetch_bongda_api(endpoint)
|
| 329 |
-
if not html or len(html)<100:continue
|
| 330 |
-
soup=BeautifulSoup(html,"lxml");all_matches=[]
|
| 331 |
-
for li in soup.select("li.match-detail"):
|
| 332 |
-
match=_parse_match_from_li(li, stype)
|
| 333 |
-
if not match or not match["event_id"]:continue
|
| 334 |
-
if stype=="today" and "KT" in match.get("minute",""):continue
|
| 335 |
-
all_matches.append(match)
|
| 336 |
-
if not all_matches:continue
|
| 337 |
-
for pl in PRIORITY_LEAGUES:
|
| 338 |
-
for match in all_matches:
|
| 339 |
-
if pl in match["league"]:return match
|
| 340 |
-
return all_matches[0]
|
| 341 |
-
return None
|
| 342 |
-
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 343 |
-
|
| 344 |
-
@app.get("/api/highlights")
|
| 345 |
-
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 346 |
-
@app.get("/api/highlights/leagues")
|
| 347 |
-
def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
|
| 348 |
-
@app.get("/api/highlights/{league}")
|
| 349 |
-
def api_highlights_league(league:str):
|
| 350 |
-
if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
|
| 351 |
-
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 352 |
-
|
| 353 |
-
@app.get("/api/video_url")
|
| 354 |
-
def api_video_url(url:str=Query(...), img:str=Query(default="")):
|
| 355 |
-
if "youtube.com" in url or "youtu.be" in url:
|
| 356 |
-
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 357 |
-
if m: 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"})
|
| 358 |
-
if "xemlaibongda.top" in url:
|
| 359 |
-
v=extract_xemlaibongda_video(url)
|
| 360 |
-
if v:
|
| 361 |
-
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 362 |
-
if not v.get("poster") and img: v["poster"] = img
|
| 363 |
-
return JSONResponse(v)
|
| 364 |
-
return JSONResponse({"error":"not found"})
|
| 365 |
-
|
| 366 |
-
# ===== WORLD CUP 2026 API =====
|
| 367 |
-
_wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
|
| 368 |
-
_WC_RATE_LIMIT = 10
|
| 369 |
-
def _wc_rate_limit():
|
| 370 |
-
global _wc_request_times
|
| 371 |
-
with _wc_rate_limit_lock:
|
| 372 |
-
now = time.time()
|
| 373 |
-
_wc_request_times = [t for t in _wc_request_times if now - t < 60]
|
| 374 |
-
if len(_wc_request_times) >= _WC_RATE_LIMIT: return False
|
| 375 |
-
_wc_request_times.append(now)
|
| 376 |
-
return True
|
| 377 |
-
|
| 378 |
-
@app.get("/api/wc2026")
|
| 379 |
-
def api_wc2026():
|
| 380 |
-
return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
|
| 381 |
-
|
| 382 |
-
@app.get("/api/wc2026/{tab}")
|
| 383 |
-
def api_wc2026_tab(tab: str):
|
| 384 |
-
valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
|
| 385 |
-
if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400)
|
| 386 |
-
def _fetch_tab():
|
| 387 |
-
if tab == "highlights": return scrape_highlights_by_league("world-cup")
|
| 388 |
-
elif tab == "news": return scrape_wc_news()
|
| 389 |
-
elif tab == "fixtures": return scrape_fixtures()
|
| 390 |
-
elif tab == "standings": return scrape_standings()
|
| 391 |
-
elif tab == "stats": return scrape_stats()
|
| 392 |
-
return []
|
| 393 |
-
return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
|
| 394 |
-
|
| 395 |
-
@app.get("/api/bdp_videos")
|
| 396 |
-
def api_bdp_videos():
|
| 397 |
-
def _f():
|
| 398 |
-
try:
|
| 399 |
-
soup=_get(f"{BASE_BDP}/video"); arts=[]; seen=set()
|
| 400 |
-
for a in soup.find_all("a",href=True):
|
| 401 |
-
href=a.get("href","")
|
| 402 |
-
if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
|
| 403 |
-
if not href.startswith("http"): href=BASE_BDP+href
|
| 404 |
-
if href in seen: continue
|
| 405 |
-
title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
|
| 406 |
-
if not title or len(title)<5: continue
|
| 407 |
-
img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
|
| 408 |
-
img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
|
| 409 |
-
seen.add(href); arts.append({"title":title,"link":href,"img":img,"source":"bdp"})
|
| 410 |
-
return arts[:20]
|
| 411 |
-
except: return []
|
| 412 |
-
return JSONResponse(_cached("bdp_videos",_f))
|
| 413 |
-
|
| 414 |
-
# ===== NEWS =====
|
| 415 |
-
VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
|
| 416 |
-
|
| 417 |
-
def scrape_vne(cat_url):
|
| 418 |
-
try:
|
| 419 |
-
soup=_get(cat_url); arts=[]
|
| 420 |
-
for it in soup.select("article.item-news")[:15]:
|
| 421 |
-
a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
|
| 422 |
-
if not a: continue
|
| 423 |
-
t=a.get("title","") or a.get_text(strip=True); lk=a.get("href","")
|
| 424 |
-
if not t or not lk: continue
|
| 425 |
-
im=it.find("img"); img=(im.get("data-src") or im.get("src","")) if im else ""
|
| 426 |
-
if img and 'blank' in img:
|
| 427 |
-
src=it.find("source")
|
| 428 |
-
if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
|
| 429 |
-
arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
|
| 430 |
-
return arts
|
| 431 |
-
except: return []
|
| 432 |
-
|
| 433 |
-
def scrape_genk_ai():
|
| 434 |
-
try:
|
| 435 |
-
r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
|
| 436 |
-
if r.status_code!=200: return []
|
| 437 |
-
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); articles=[]; seen=set()
|
| 438 |
-
for a in soup.find_all("a",href=True):
|
| 439 |
-
href=a.get("href","")
|
| 440 |
-
if not href.endswith(".chn") or href=="/ai.chn": continue
|
| 441 |
-
if href.startswith("/"): href="https://genk.vn"+href
|
| 442 |
-
if href in seen or "genk.vn" not in href: continue
|
| 443 |
-
title=a.get("title","") or a.get_text(strip=True)
|
| 444 |
-
if not title or len(title)<20: continue
|
| 445 |
-
container=a.parent; img_src=""
|
| 446 |
-
for _ in range(6):
|
| 447 |
-
if container is None: break
|
| 448 |
-
for img in container.find_all("img"):
|
| 449 |
-
s=img.get("data-src","") or img.get("src","")
|
| 450 |
-
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
|
| 451 |
-
if img_src: break; container=container.parent
|
| 452 |
-
seen.add(href)
|
| 453 |
-
if not img_src:
|
| 454 |
-
try:
|
| 455 |
-
og_r=requests.get(href,headers=HEADERS,timeout=8); og_r.encoding="utf-8"
|
| 456 |
-
og_soup=BeautifulSoup(og_r.text,"lxml"); og_tag=og_soup.find("meta",property="og:image")
|
| 457 |
-
if og_tag: img_src=og_tag.get("content","")
|
| 458 |
-
except: pass
|
| 459 |
-
articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
|
| 460 |
-
if len(articles)>=30: break
|
| 461 |
-
return articles
|
| 462 |
-
except: return []
|
| 463 |
-
|
| 464 |
-
@app.get("/api/homepage")
|
| 465 |
-
def api_homepage():
|
| 466 |
-
def _f():
|
| 467 |
-
articles=[]
|
| 468 |
-
with ThreadPoolExecutor(12) as ex:
|
| 469 |
-
futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]}
|
| 470 |
-
for f in as_completed(futs):
|
| 471 |
-
try:
|
| 472 |
-
for a in f.result(): a["group"]=futs[f]; articles.append(a)
|
| 473 |
-
except: pass
|
| 474 |
-
return articles
|
| 475 |
-
return JSONResponse(_cached("homepage",_f))
|
| 476 |
-
|
| 477 |
-
@app.get("/api/category/{cat_id}")
|
| 478 |
-
def api_category(cat_id:str):
|
| 479 |
-
def _f():
|
| 480 |
-
if cat_id=="cong-nghe": return scrape_genk_ai()
|
| 481 |
-
if cat_id in VNE_CATS:
|
| 482 |
-
arts=scrape_vne(VNE_CATS[cat_id][0])
|
| 483 |
-
[a.update({"group":VNE_CATS[cat_id][1]}) for a in arts]
|
| 484 |
-
return arts
|
| 485 |
-
return []
|
| 486 |
-
return JSONResponse(_cached(f"cat_{cat_id}",_f))
|
| 487 |
-
|
| 488 |
-
@app.get("/api/categories")
|
| 489 |
-
def api_categories():
|
| 490 |
-
cats=[{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
|
| 491 |
-
for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
|
| 492 |
-
return JSONResponse(cats)
|
| 493 |
-
|
| 494 |
-
@app.get("/api/proxy/xlb")
|
| 495 |
-
def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
| 496 |
-
try:
|
| 497 |
-
url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
|
| 498 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 499 |
-
if r.status_code != 200: return JSONResponse({"videos": []})
|
| 500 |
-
r.encoding = "utf-8"
|
| 501 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 502 |
-
videos, seen = [], set()
|
| 503 |
-
for a in soup.find_all("a", href=True):
|
| 504 |
-
href = a.get("href", "")
|
| 505 |
-
if "/video/" not in href and "/xem-lai/" not in href: continue
|
| 506 |
-
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 507 |
-
clean = href.split("?")[0].split("#")[0]
|
| 508 |
-
if clean in seen: continue
|
| 509 |
-
seen.add(clean)
|
| 510 |
-
img_src = ""
|
| 511 |
-
img = a.find("img") or (a.parent.find("img") if a.parent else None)
|
| 512 |
-
if not img:
|
| 513 |
-
p = a.parent
|
| 514 |
-
for _ in range(5):
|
| 515 |
-
if p and p.find("img"): img = p.find("img"); break
|
| 516 |
-
p = p.parent if p else None
|
| 517 |
-
if img:
|
| 518 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
|
| 519 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 520 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 521 |
-
title = a.find("h3")
|
| 522 |
-
if not title: title = a.find("h2")
|
| 523 |
-
if not title: title = a.find("strong")
|
| 524 |
-
t = title.get_text(strip=True) if title else ""
|
| 525 |
-
if not t:
|
| 526 |
-
slug = clean.split("/video/")[-1].rstrip("/")
|
| 527 |
-
t = slug.replace("-", " ").title()
|
| 528 |
-
videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
|
| 529 |
-
if len(videos) >= limit: break
|
| 530 |
-
return JSONResponse({"videos": videos})
|
| 531 |
-
except Exception as e:
|
| 532 |
-
return JSONResponse({"videos": [], "error": str(e)})
|
| 533 |
-
|
| 534 |
-
@app.get("/api/article")
|
| 535 |
-
def api_article(url:str=Query(...)):
|
| 536 |
-
try:
|
| 537 |
-
r2 = requests.get(url, headers=HEADERS, timeout=10)
|
| 538 |
-
if r2.status_code == 200:
|
| 539 |
-
r2.encoding = "utf-8"
|
| 540 |
-
soup = BeautifulSoup(r2.text, "lxml")
|
| 541 |
-
og = soup.find("meta", property="og:image")
|
| 542 |
-
return JSONResponse({"og_image": og.get("content", "") if og else ""})
|
| 543 |
-
except: pass
|
| 544 |
-
return JSONResponse({"og_image": ""})
|
| 545 |
-
|
| 546 |
-
@app.get("/api/storage_status")
|
| 547 |
-
def api_storage_status():
|
| 548 |
-
return JSONResponse({"persistent":os.path.isdir("/data")})
|
| 549 |
-
|
| 550 |
-
@app.get("/api/hot_topics")
|
| 551 |
-
def api_hot_topics():
|
| 552 |
-
return JSONResponse({"topics":[]})
|
| 553 |
-
|
| 554 |
-
@app.get("/", response_class=HTMLResponse)
|
| 555 |
-
async def root():
|
| 556 |
-
return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|