Spaces:
Running
Running
Restore full app_v2_entry.py with import rewrite_slide added
Browse files- app_v2_entry.py +1069 -1
app_v2_entry.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
try:
|
| 2 |
import ai_ext
|
| 3 |
except Exception as e:
|
|
@@ -11,4 +15,1068 @@ except Exception as e:
|
|
| 11 |
try:
|
| 12 |
import rewrite_slide
|
| 13 |
except Exception as e:
|
| 14 |
-
print(f"[WARN] rewrite_slide import failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 15 |
try:
|
| 16 |
import rewrite_slide
|
| 17 |
except Exception as e:
|
| 18 |
+
print(f"[WARN] rewrite_slide import failed: {e}")
|
| 19 |
+
|
| 20 |
+
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 21 |
+
from fastapi.staticfiles import StaticFiles
|
| 22 |
+
from starlette.routing import Mount
|
| 23 |
+
from fastapi import Query, Request, UploadFile, File, Form
|
| 24 |
+
import requests as req
|
| 25 |
+
from bs4 import BeautifulSoup
|
| 26 |
+
import re, html as html_lib, json, threading, time, uuid
|
| 27 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 28 |
+
from urllib.parse import quote
|
| 29 |
+
|
| 30 |
+
HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
|
| 31 |
+
|
| 32 |
+
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 33 |
+
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()))]
|
| 34 |
+
app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
|
| 35 |
+
app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
|
| 36 |
+
|
| 37 |
+
def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
|
| 38 |
+
|
| 39 |
+
# Cache for match details (5 min TTL)
|
| 40 |
+
_match_cache = {}
|
| 41 |
+
|
| 42 |
+
# === FAST BONGDA PROXY ENDPOINT ===
|
| 43 |
+
def _get_match_detail(event_id, slug=None):
|
| 44 |
+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
|
| 45 |
+
if slug:
|
| 46 |
+
url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
|
| 47 |
+
else:
|
| 48 |
+
url = f"https://bongda.com.vn/tran-dau/{event_id}"
|
| 49 |
+
resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 50 |
+
if resp.status_code != 200:
|
| 51 |
+
return None
|
| 52 |
+
soup = BeautifulSoup(resp.text, 'html.parser')
|
| 53 |
+
result = {"event_id": event_id, "found": False, "sections": []}
|
| 54 |
+
info = {}
|
| 55 |
+
tel = soup.select_one('.teams')
|
| 56 |
+
if tel:
|
| 57 |
+
he = tel.select_one('.team.home')
|
| 58 |
+
if he:
|
| 59 |
+
p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
|
| 60 |
+
if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
|
| 61 |
+
lo = he.select_one('img')
|
| 62 |
+
if lo: info['home_logo'] = lo.get('src', '')
|
| 63 |
+
ae = tel.select_one('.team.away')
|
| 64 |
+
if ae:
|
| 65 |
+
p_tags = ae.select('p')
|
| 66 |
+
team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
|
| 67 |
+
if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
|
| 68 |
+
lo = ae.select_one('img')
|
| 69 |
+
if lo: info['away_logo'] = lo.get('src', '')
|
| 70 |
+
sc = tel.select_one('.score')
|
| 71 |
+
if sc:
|
| 72 |
+
parts = [_clean(p.get_text()) for p in sc.select('p')]
|
| 73 |
+
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 74 |
+
lb = sc.select_one('.label')
|
| 75 |
+
if lb: info['status_label'] = _clean(lb.get_text())
|
| 76 |
+
if info.get('home_team') and info.get('away_team'):
|
| 77 |
+
result['info'] = info
|
| 78 |
+
result['found'] = True
|
| 79 |
+
result['sections'].append('info')
|
| 80 |
+
events = []
|
| 81 |
+
for ev in soup.select('.events .period .event'):
|
| 82 |
+
ev_cls = ' '.join(ev.get('class', []))
|
| 83 |
+
ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
|
| 84 |
+
parent = ev.parent
|
| 85 |
+
if parent:
|
| 86 |
+
h2 = parent.find('h2')
|
| 87 |
+
if h2: ev_data['period'] = _clean(h2.get_text())
|
| 88 |
+
if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
|
| 89 |
+
elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
|
| 90 |
+
elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
|
| 91 |
+
elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
|
| 92 |
+
players_el = ev.select_one('.players')
|
| 93 |
+
if players_el:
|
| 94 |
+
pl_text = _clean(players_el.get_text(' ', strip=True))
|
| 95 |
+
m = re.match(r"(\d+)'(.*)", pl_text)
|
| 96 |
+
if m:
|
| 97 |
+
ev_data['time'] = f"{m.group(1)}'"
|
| 98 |
+
ev_data['players'] = m.group(2)
|
| 99 |
+
else:
|
| 100 |
+
ev_data['players'] = pl_text
|
| 101 |
+
events.append(ev_data)
|
| 102 |
+
if events:
|
| 103 |
+
result['events'] = events
|
| 104 |
+
result['sections'].append('events')
|
| 105 |
+
pred = soup.select_one('.prediction-card')
|
| 106 |
+
if pred:
|
| 107 |
+
team_info = pred.select_one('.team-info')
|
| 108 |
+
if team_info:
|
| 109 |
+
teams = team_info.select('.team')
|
| 110 |
+
pred_data = {}
|
| 111 |
+
if len(teams) >= 2:
|
| 112 |
+
pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
|
| 113 |
+
pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
|
| 114 |
+
divider = team_info.select_one('.divider')
|
| 115 |
+
if divider: pred_data['result'] = _clean(divider.get_text())
|
| 116 |
+
vc = pred.select_one('.vote-count')
|
| 117 |
+
if vc: pred_data['vote_count'] = _clean(vc.get_text())
|
| 118 |
+
result['prediction'] = pred_data
|
| 119 |
+
recent = []
|
| 120 |
+
ml = soup.select_one('.matches-list')
|
| 121 |
+
if ml:
|
| 122 |
+
for item in ml.select('.match-detail, .match-item, li'):
|
| 123 |
+
de = item.select_one('.date, .time')
|
| 124 |
+
le = item.select_one('.league')
|
| 125 |
+
he_item = item.select_one('.home, .team-home')
|
| 126 |
+
ae_item = item.select_one('.away, .team-away')
|
| 127 |
+
se = item.select_one('.score, .result')
|
| 128 |
+
if he_item or ae_item:
|
| 129 |
+
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'})
|
| 130 |
+
if recent:
|
| 131 |
+
result['recent_matches'] = recent
|
| 132 |
+
result['sections'].append('recent')
|
| 133 |
+
try:
|
| 134 |
+
api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
|
| 135 |
+
ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
|
| 136 |
+
if ar.status_code == 200:
|
| 137 |
+
ad = ar.json()
|
| 138 |
+
if ad.get('status') == 'success' and ad.get('html'):
|
| 139 |
+
asp = BeautifulSoup(ad['html'], 'html.parser')
|
| 140 |
+
ast = {}
|
| 141 |
+
for row in asp.select('li, tr'):
|
| 142 |
+
cells = row.select('td, span, p')
|
| 143 |
+
if len(cells) >= 3:
|
| 144 |
+
lb = _clean(cells[0].get_text())
|
| 145 |
+
if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
|
| 146 |
+
if ast:
|
| 147 |
+
result['h2h_stats_parsed'] = ast
|
| 148 |
+
result['sections'].append('h2h_stats')
|
| 149 |
+
except: pass
|
| 150 |
+
return result
|
| 151 |
+
|
| 152 |
+
@app.get('/api/proxy/bongda')
|
| 153 |
+
def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
|
| 154 |
+
if event_id is None:
|
| 155 |
+
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 156 |
+
cache_key = f"{event_id}_{slug}"
|
| 157 |
+
now = time.time()
|
| 158 |
+
cached = _match_cache.get(cache_key)
|
| 159 |
+
if cached and now - cached.get('_ts', 0) < 300:
|
| 160 |
+
return JSONResponse(cached)
|
| 161 |
+
try:
|
| 162 |
+
result = _get_match_detail(event_id, slug)
|
| 163 |
+
if result:
|
| 164 |
+
result['_ts'] = now
|
| 165 |
+
_match_cache[cache_key] = result
|
| 166 |
+
return JSONResponse(result)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
|
| 169 |
+
_match_cache[cache_key] = err
|
| 170 |
+
return JSONResponse(err)
|
| 171 |
+
return JSONResponse({"event_id": event_id, "found": False})
|
| 172 |
+
|
| 173 |
+
@app.get('/api/match/{event_id}/detail')
|
| 174 |
+
def api_match_detail(event_id: int, url: str = Query(default=None)):
|
| 175 |
+
slug = None
|
| 176 |
+
if url:
|
| 177 |
+
m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
|
| 178 |
+
if m:
|
| 179 |
+
slug = m.group(1)
|
| 180 |
+
cache_key = f"{event_id}_{slug or ''}"
|
| 181 |
+
now = time.time()
|
| 182 |
+
cached = _match_cache.get(cache_key)
|
| 183 |
+
if cached and now - cached.get('_ts', 0) < 300:
|
| 184 |
+
return JSONResponse(cached)
|
| 185 |
+
try:
|
| 186 |
+
if not slug:
|
| 187 |
+
try:
|
| 188 |
+
home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
| 189 |
+
if home_r.status_code == 200:
|
| 190 |
+
home_soup = BeautifulSoup(home_r.text, 'html.parser')
|
| 191 |
+
for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
|
| 192 |
+
href = a.get('href', '')
|
| 193 |
+
m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
|
| 194 |
+
if m:
|
| 195 |
+
slug = m.group(1)
|
| 196 |
+
cache_key = f"{event_id}_{slug}"
|
| 197 |
+
break
|
| 198 |
+
except: pass
|
| 199 |
+
result = _get_match_detail(event_id, slug)
|
| 200 |
+
if result:
|
| 201 |
+
result['_ts'] = now
|
| 202 |
+
_match_cache[cache_key] = result
|
| 203 |
+
return JSONResponse(result)
|
| 204 |
+
except Exception as e:
|
| 205 |
+
err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
|
| 206 |
+
_match_cache[cache_key] = err
|
| 207 |
+
return JSONResponse(err)
|
| 208 |
+
return JSONResponse({"event_id": event_id, "found": False})
|
| 209 |
+
|
| 210 |
+
_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())
|
| 211 |
+
|
| 212 |
+
def _has_kw(topic,title):
|
| 213 |
+
tl=topic.lower();tt=(title or'').lower()
|
| 214 |
+
if tl in tt:return True
|
| 215 |
+
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
|
| 216 |
+
if not words:return True
|
| 217 |
+
return any(w in tt for w in words)
|
| 218 |
+
|
| 219 |
+
def _s_vnexpress(topic,limit=8):
|
| 220 |
+
items=[]
|
| 221 |
+
try:
|
| 222 |
+
r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
|
| 223 |
+
for art in soup.select('article.item-news')[:limit]:
|
| 224 |
+
a=art.select_one('h2 a, h3 a')
|
| 225 |
+
if a and a.get('href'):
|
| 226 |
+
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 227 |
+
if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
|
| 228 |
+
except:pass
|
| 229 |
+
return items
|
| 230 |
+
|
| 231 |
+
def _s_dantri(topic,limit=8):
|
| 232 |
+
items=[]
|
| 233 |
+
try:
|
| 234 |
+
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')
|
| 235 |
+
for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
|
| 236 |
+
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 237 |
+
if t and len(t)>15 and _has_kw(topic,t):
|
| 238 |
+
if not href.startswith('http'):href='https://dantri.com.vn'+href
|
| 239 |
+
items.append({'title':t,'url':href,'via':'Dân Trí'})
|
| 240 |
+
if len(items)>=limit:break
|
| 241 |
+
except:pass
|
| 242 |
+
return items
|
| 243 |
+
|
| 244 |
+
def _s_vietnamnet(topic,limit=6):
|
| 245 |
+
items=[]
|
| 246 |
+
try:
|
| 247 |
+
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')
|
| 248 |
+
for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
|
| 249 |
+
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 250 |
+
if t and len(t)>15 and _has_kw(topic,t):
|
| 251 |
+
if not href.startswith('http'):href='https://vietnamnet.vn'+href
|
| 252 |
+
items.append({'title':t,'url':href,'via':'VietNamNet'})
|
| 253 |
+
if len(items)>=limit:break
|
| 254 |
+
except:pass
|
| 255 |
+
return items
|
| 256 |
+
|
| 257 |
+
def _s_bongda(topic,limit=5):
|
| 258 |
+
items=[]
|
| 259 |
+
try:
|
| 260 |
+
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')
|
| 261 |
+
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 262 |
+
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 263 |
+
if t and len(t)>15 and _has_kw(topic,t):
|
| 264 |
+
if not href.startswith('http'):href='https://bongda.com.vn'+href
|
| 265 |
+
items.append({'title':t,'url':href,'via':'Bóng Đá'})
|
| 266 |
+
if len(items)>=limit:break
|
| 267 |
+
except:pass
|
| 268 |
+
return items
|
| 269 |
+
|
| 270 |
+
def _s_genk(topic,limit=5):
|
| 271 |
+
items=[]
|
| 272 |
+
try:
|
| 273 |
+
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')
|
| 274 |
+
for a in soup.select('a[href$=".chn"]')[:limit*3]:
|
| 275 |
+
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 276 |
+
if t and len(t)>15 and _has_kw(topic,t):
|
| 277 |
+
if href.startswith('/'):href='https://genk.vn'+href
|
| 278 |
+
items.append({'title':t,'url':href,'via':'GenK'})
|
| 279 |
+
if len(items)>=limit:break
|
| 280 |
+
except:pass
|
| 281 |
+
return items
|
| 282 |
+
|
| 283 |
+
def _s_thanhnien(topic,limit=6):
|
| 284 |
+
items=[]
|
| 285 |
+
try:
|
| 286 |
+
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')
|
| 287 |
+
for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
|
| 288 |
+
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 289 |
+
if t and len(t)>15 and _has_kw(topic,t):
|
| 290 |
+
if not href.startswith('http'):href='https://thanhnien.vn'+href
|
| 291 |
+
items.append({'title':t,'url':href,'via':'Thanh Niên'})
|
| 292 |
+
if len(items)>=limit:break
|
| 293 |
+
except:pass
|
| 294 |
+
return items
|
| 295 |
+
|
| 296 |
+
def _s_tuoitre(topic,limit=6):
|
| 297 |
+
items=[]
|
| 298 |
+
try:
|
| 299 |
+
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')
|
| 300 |
+
for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
|
| 301 |
+
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 302 |
+
if t and len(t)>15 and _has_kw(topic,t):
|
| 303 |
+
if not href.startswith('http'):href='https://tuoitre.vn'+href
|
| 304 |
+
items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
|
| 305 |
+
if len(items)>=limit:break
|
| 306 |
+
except:pass
|
| 307 |
+
return items
|
| 308 |
+
|
| 309 |
+
def _s_thethaovanhoa(topic,limit=5):
|
| 310 |
+
items=[]
|
| 311 |
+
try:
|
| 312 |
+
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')
|
| 313 |
+
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 314 |
+
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 315 |
+
if t and len(t)>15 and _has_kw(topic,t):
|
| 316 |
+
if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
|
| 317 |
+
items.append({'title':t,'url':href,'via':'TT&VH'})
|
| 318 |
+
if len(items)>=limit:break
|
| 319 |
+
except:pass
|
| 320 |
+
return items
|
| 321 |
+
|
| 322 |
+
def _search_all(topic,limit=36):
|
| 323 |
+
results={}
|
| 324 |
+
with ThreadPoolExecutor(8) as ex:
|
| 325 |
+
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'}
|
| 326 |
+
for f in as_completed(futs,timeout=14):
|
| 327 |
+
try:results[futs[f]]=f.result()
|
| 328 |
+
except:results[futs[f]]=[]
|
| 329 |
+
srcs=list(results.values());out=[];seen=set()
|
| 330 |
+
for i in range(max((len(s) for s in srcs),default=0)):
|
| 331 |
+
for s in srcs:
|
| 332 |
+
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])
|
| 333 |
+
return out[:limit]
|
| 334 |
+
|
| 335 |
+
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
|
| 336 |
+
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 337 |
+
|
| 338 |
+
_article_cache = {}
|
| 339 |
+
_article_cache_ttl = 1800
|
| 340 |
+
|
| 341 |
+
_art_session = None
|
| 342 |
+
_art_lock = threading.Lock()
|
| 343 |
+
def _get_art_session():
|
| 344 |
+
global _art_session
|
| 345 |
+
if _art_session is None:
|
| 346 |
+
with _art_lock:
|
| 347 |
+
if _art_session is None:
|
| 348 |
+
_art_session = req.Session()
|
| 349 |
+
_art_session.headers.update({
|
| 350 |
+
"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",
|
| 351 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 352 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 353 |
+
})
|
| 354 |
+
return _art_session
|
| 355 |
+
|
| 356 |
+
def _scrape_article_fast(url):
|
| 357 |
+
from urllib.parse import urlparse
|
| 358 |
+
domain = urlparse(url).netloc
|
| 359 |
+
sess = _get_art_session()
|
| 360 |
+
uas = [
|
| 361 |
+
{"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"},
|
| 362 |
+
{"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"},
|
| 363 |
+
]
|
| 364 |
+
for ua in uas:
|
| 365 |
+
try:
|
| 366 |
+
r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
|
| 367 |
+
if not r or r.status_code != 200:
|
| 368 |
+
continue
|
| 369 |
+
r.encoding = 'utf-8'
|
| 370 |
+
soup = BeautifulSoup(r.text, 'lxml')
|
| 371 |
+
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']):
|
| 372 |
+
tag.decompose()
|
| 373 |
+
title = summary = og_img = ""
|
| 374 |
+
ogt = soup.find('meta', property='og:title')
|
| 375 |
+
if ogt: title = ogt.get('content', '')
|
| 376 |
+
ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
|
| 377 |
+
if ogd: summary = ogd.get('content', '')[:500]
|
| 378 |
+
ogi = soup.find('meta', property='og:image')
|
| 379 |
+
if ogi:
|
| 380 |
+
og_img = ogi.get('content', '')
|
| 381 |
+
if og_img.startswith('//'): og_img = 'https:' + og_img
|
| 382 |
+
h1 = soup.find('h1')
|
| 383 |
+
if not title and h1: title = h1.get_text(strip=True)[:200]
|
| 384 |
+
body = []
|
| 385 |
+
selectors = [
|
| 386 |
+
'.fck_detail', '.sidebar-1',
|
| 387 |
+
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
|
| 388 |
+
'.content-detail', '.main-content-detail', '.box-content',
|
| 389 |
+
'.knc-content', '.article-body', '.detail-body',
|
| 390 |
+
'.article-detail', '.detail-content',
|
| 391 |
+
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 392 |
+
'.entry-content', '#content', '.article-text', '.story-body',
|
| 393 |
+
]
|
| 394 |
+
for sel in selectors:
|
| 395 |
+
el = soup.select_one(sel)
|
| 396 |
+
if el and len(el.find_all('p')) >= 2:
|
| 397 |
+
seen_imgs = set()
|
| 398 |
+
for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
|
| 399 |
+
if child.name == 'p':
|
| 400 |
+
t = child.get_text(strip=True)
|
| 401 |
+
if t and len(t) > 15:
|
| 402 |
+
body.append({'type': 'p', 'text': t})
|
| 403 |
+
elif child.name in ('h2','h3'):
|
| 404 |
+
t = child.get_text(strip=True)
|
| 405 |
+
if t:
|
| 406 |
+
body.append({'type': 'heading', 'text': t})
|
| 407 |
+
elif child.name in ('figure','img'):
|
| 408 |
+
im = child if child.name == 'img' else child.find('img')
|
| 409 |
+
if im:
|
| 410 |
+
src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
|
| 411 |
+
if src and 'base64' not in src and src not in seen_imgs:
|
| 412 |
+
seen_imgs.add(src)
|
| 413 |
+
if src.startswith('//'): src = 'https:' + src
|
| 414 |
+
body.append({'type': 'img', 'src': src})
|
| 415 |
+
if child.name == 'figure':
|
| 416 |
+
cap = child.find('figcaption')
|
| 417 |
+
if cap:
|
| 418 |
+
ct = cap.get_text(strip=True)
|
| 419 |
+
if ct: body.append({'type': 'p', 'text': ct})
|
| 420 |
+
if len(body) >= 2:
|
| 421 |
+
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 422 |
+
'body': body[:50], 'source': domain, 'url': url}
|
| 423 |
+
if title and (summary or og_img):
|
| 424 |
+
fallback = []
|
| 425 |
+
if og_img: fallback.append({'type': 'img', 'src': og_img})
|
| 426 |
+
if summary: fallback.append({'type': 'p', 'text': summary})
|
| 427 |
+
if fallback:
|
| 428 |
+
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 429 |
+
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
| 430 |
+
if title:
|
| 431 |
+
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 432 |
+
'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
|
| 433 |
+
'source': domain, 'url': url, 'fallback': True}
|
| 434 |
+
break
|
| 435 |
+
except Exception:
|
| 436 |
+
continue
|
| 437 |
+
return None
|
| 438 |
+
|
| 439 |
+
@app.get('/api/article')
|
| 440 |
+
def api_article_v2(url: str = Query(...)):
|
| 441 |
+
from urllib.parse import unquote
|
| 442 |
+
safe_url = unquote(url)
|
| 443 |
+
try:
|
| 444 |
+
now = time.time()
|
| 445 |
+
cached = _article_cache.get(safe_url)
|
| 446 |
+
if cached and now - cached['t'] < _article_cache_ttl:
|
| 447 |
+
resp = JSONResponse(cached['d'])
|
| 448 |
+
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 449 |
+
return resp
|
| 450 |
+
data = _scrape_article_fast(safe_url)
|
| 451 |
+
if data and data.get('body'):
|
| 452 |
+
_article_cache[safe_url] = {'d': data, 't': now}
|
| 453 |
+
resp = JSONResponse(data)
|
| 454 |
+
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 455 |
+
return resp
|
| 456 |
+
result = {'error': 'Không đọc được', 'url': safe_url}
|
| 457 |
+
resp = JSONResponse(result)
|
| 458 |
+
resp.headers["Cache-Control"] = "public, max-age=60"
|
| 459 |
+
return resp
|
| 460 |
+
except Exception as e:
|
| 461 |
+
return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
|
| 462 |
+
|
| 463 |
+
_hot_cache={'t':0,'d':[]}
|
| 464 |
+
def _get_hot_topics():
|
| 465 |
+
now=time.time()
|
| 466 |
+
if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
|
| 467 |
+
freq={};display={}
|
| 468 |
+
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']
|
| 469 |
+
for feed_url in feeds:
|
| 470 |
+
try:
|
| 471 |
+
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
|
| 472 |
+
for item in soup.find_all('item')[:12]:
|
| 473 |
+
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 474 |
+
if not title:continue
|
| 475 |
+
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]
|
| 476 |
+
if len(words)<2:continue
|
| 477 |
+
for n in(3,4,2):
|
| 478 |
+
for i in range(max(0,len(words)-n+1)):
|
| 479 |
+
phrase=' '.join(words[i:i+n])
|
| 480 |
+
if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
|
| 481 |
+
except:continue
|
| 482 |
+
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
|
| 483 |
+
for key,count in ranked:
|
| 484 |
+
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)
|
| 485 |
+
if is_dup:continue
|
| 486 |
+
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
|
| 487 |
+
if len(topics)>=20:break
|
| 488 |
+
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']:
|
| 489 |
+
if len(topics)>=24:break
|
| 490 |
+
if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
|
| 491 |
+
_hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
|
| 492 |
+
|
| 493 |
+
@app.get('/api/hot_topics')
|
| 494 |
+
def api_hot_topics():
|
| 495 |
+
resp = JSONResponse({'topics':_get_hot_topics()})
|
| 496 |
+
resp.headers["Cache-Control"] = "public, max-age=120"
|
| 497 |
+
return resp
|
| 498 |
+
@app.get('/')
|
| 499 |
+
async def serve_index():
|
| 500 |
+
p=os.path.join(STATIC_DIR,'index_v2.html')
|
| 501 |
+
if os.path.exists(p):return FileResponse(p,media_type='text/html')
|
| 502 |
+
return HTMLResponse('<h1>VNEWS</h1>')
|
| 503 |
+
@app.get('/api/hashtag/sources')
|
| 504 |
+
def _ht(topic:str=Query(...),page:int=Query(default=0)):
|
| 505 |
+
items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
|
| 506 |
+
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
| 507 |
+
@app.get('/api/categories')
|
| 508 |
+
def _cat():return JSONResponse([])
|
| 509 |
+
@app.get('/api/storage_status')
|
| 510 |
+
def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
| 511 |
+
@app.get('/s')
|
| 512 |
+
async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
|
| 513 |
+
|
| 514 |
+
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
|
| 515 |
+
|
| 516 |
+
_xlb_cache = {}
|
| 517 |
+
_xlb_lock = threading.Lock()
|
| 518 |
+
|
| 519 |
+
def _xlb_scrape(path):
|
| 520 |
+
url = f"https://xemlaibongda.top/{path}"
|
| 521 |
+
r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
|
| 522 |
+
if r.status_code != 200:
|
| 523 |
+
return []
|
| 524 |
+
soup = BeautifulSoup(r.text, 'lxml')
|
| 525 |
+
vids = []
|
| 526 |
+
seen = set()
|
| 527 |
+
for a in soup.select('a[href*="/video/"]'):
|
| 528 |
+
href = a.get('href', '')
|
| 529 |
+
if not href or href in seen:
|
| 530 |
+
continue
|
| 531 |
+
seen.add(href)
|
| 532 |
+
if not href.startswith('http'):
|
| 533 |
+
href = 'https://xemlaibongda.top' + href
|
| 534 |
+
img = a.select_one('img')
|
| 535 |
+
p = a.parent
|
| 536 |
+
for _ in range(4):
|
| 537 |
+
if img:
|
| 538 |
+
break
|
| 539 |
+
if p:
|
| 540 |
+
img = p.select_one('img')
|
| 541 |
+
p = p.parent
|
| 542 |
+
img_src = ''
|
| 543 |
+
if img:
|
| 544 |
+
img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
|
| 545 |
+
if img_src.startswith('//'):
|
| 546 |
+
img_src = 'https:' + img_src
|
| 547 |
+
elif img_src.startswith('/'):
|
| 548 |
+
img_src = 'https://xemlaibongda.top' + img_src
|
| 549 |
+
title = ''
|
| 550 |
+
for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
|
| 551 |
+
t = a.select_one(sel)
|
| 552 |
+
if t:
|
| 553 |
+
title = _clean(t.get_text())
|
| 554 |
+
break
|
| 555 |
+
if not title:
|
| 556 |
+
title = _clean(a.get('title',''))
|
| 557 |
+
if not title:
|
| 558 |
+
img_alt = a.select_one('img')
|
| 559 |
+
if img_alt:
|
| 560 |
+
title = _clean(img_alt.get('alt',''))
|
| 561 |
+
if not title:
|
| 562 |
+
parent = a.parent
|
| 563 |
+
if parent:
|
| 564 |
+
pt = _clean(parent.get_text(' ',strip=True))
|
| 565 |
+
if 5 < len(pt) < 120:
|
| 566 |
+
title = pt
|
| 567 |
+
if not title or len(title) < 3:
|
| 568 |
+
continue
|
| 569 |
+
vids.append({"link": href, "img": img_src, "title": title})
|
| 570 |
+
if len(vids) >= 30:
|
| 571 |
+
break
|
| 572 |
+
return vids
|
| 573 |
+
|
| 574 |
+
@app.get('/api/proxy/xlb')
|
| 575 |
+
def proxy_xlb(path: str = Query(default="")):
|
| 576 |
+
now = time.time()
|
| 577 |
+
cache_key = f"xlb:{path}"
|
| 578 |
+
with _xlb_lock:
|
| 579 |
+
cached = _xlb_cache.get(cache_key)
|
| 580 |
+
if cached and now - cached['t'] < 120:
|
| 581 |
+
return JSONResponse(cached['d'])
|
| 582 |
+
try:
|
| 583 |
+
vids = _xlb_scrape(path)
|
| 584 |
+
result = {"videos": vids, "count": len(vids)}
|
| 585 |
+
with _xlb_lock:
|
| 586 |
+
_xlb_cache[cache_key] = {'t': now, 'd': result}
|
| 587 |
+
return JSONResponse(result)
|
| 588 |
+
except Exception as e:
|
| 589 |
+
return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
|
| 590 |
+
|
| 591 |
+
@app.get('/api/wc2026')
|
| 592 |
+
def _w():return JSONResponse(get_wc2026_all())
|
| 593 |
+
@app.get('/api/wc2026/fixtures')
|
| 594 |
+
def _wf():return JSONResponse(scrape_fixtures())
|
| 595 |
+
@app.get('/api/wc2026/standings')
|
| 596 |
+
def _ws():return JSONResponse(scrape_standings())
|
| 597 |
+
@app.get('/api/wc2026/stats')
|
| 598 |
+
def _wst():return JSONResponse(scrape_stats())
|
| 599 |
+
@app.get('/api/wc2026/history')
|
| 600 |
+
def _whi():return JSONResponse(scrape_history())
|
| 601 |
+
@app.get('/api/wc2026/news')
|
| 602 |
+
def _wn():return JSONResponse(scrape_wc_news())
|
| 603 |
+
@app.get('/api/wc2026/road')
|
| 604 |
+
def _wr():return JSONResponse(scrape_road_to_wc())
|
| 605 |
+
@app.get('/api/wc2026/h2h/{eid}')
|
| 606 |
+
def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
|
| 607 |
+
@app.get('/api/wc2026/lineups/{eid}')
|
| 608 |
+
def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
|
| 609 |
+
@app.get('/api/wc2026/match/{eid}')
|
| 610 |
+
def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
|
| 611 |
+
|
| 612 |
+
DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
|
| 613 |
+
os.makedirs(DATA_DIR,exist_ok=True)
|
| 614 |
+
IF=os.path.join(DATA_DIR,'interactions_v2.json')
|
| 615 |
+
CF=os.path.join(DATA_DIR,'comments_v2.json')
|
| 616 |
+
WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
|
| 617 |
+
WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
|
| 618 |
+
os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
|
| 619 |
+
|
| 620 |
+
_il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
|
| 621 |
+
def _lj(p):
|
| 622 |
+
try:
|
| 623 |
+
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
| 624 |
+
except:pass
|
| 625 |
+
return{}
|
| 626 |
+
def _sj(p,d):
|
| 627 |
+
try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
|
| 628 |
+
except:pass
|
| 629 |
+
|
| 630 |
+
@app.post('/api/v2/interact')
|
| 631 |
+
async def _int(request:Request):
|
| 632 |
+
b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
|
| 633 |
+
if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
|
| 634 |
+
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])
|
| 635 |
+
|
| 636 |
+
@app.get('/api/v2/interactions')
|
| 637 |
+
def _gi(id:str=Query(...)):
|
| 638 |
+
with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
|
| 639 |
+
|
| 640 |
+
@app.get('/api/v2/comments')
|
| 641 |
+
def _gc(id:str=Query(...)):
|
| 642 |
+
with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
|
| 643 |
+
|
| 644 |
+
@app.post('/api/v2/comment')
|
| 645 |
+
async def _pc(request:Request):
|
| 646 |
+
b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
|
| 647 |
+
if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
|
| 648 |
+
c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 649 |
+
with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
|
| 650 |
+
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 651 |
+
return JSONResponse({'comments':cms})
|
| 652 |
+
|
| 653 |
+
def _load_wall_posts():
|
| 654 |
+
with _wl_lock:
|
| 655 |
+
return _lj(WALL_FILE)
|
| 656 |
+
|
| 657 |
+
def _save_wall_posts(posts):
|
| 658 |
+
with _wl_lock:
|
| 659 |
+
_sj(WALL_FILE, posts)
|
| 660 |
+
|
| 661 |
+
@app.get('/api/wall')
|
| 662 |
+
def api_wall():
|
| 663 |
+
posts = _load_wall_posts()
|
| 664 |
+
if not posts:
|
| 665 |
+
return JSONResponse({"posts": []})
|
| 666 |
+
return JSONResponse({"posts": posts})
|
| 667 |
+
|
| 668 |
+
@app.post('/api/wall')
|
| 669 |
+
async def api_wall_post(request: Request):
|
| 670 |
+
content_type = request.headers.get('content-type', '')
|
| 671 |
+
if 'multipart/form-data' in content_type:
|
| 672 |
+
try:
|
| 673 |
+
form = await request.form()
|
| 674 |
+
except Exception as e:
|
| 675 |
+
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 676 |
+
title = form.get('title', 'Video mới') or 'Video mới'
|
| 677 |
+
text = form.get('text', '') or ''
|
| 678 |
+
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 679 |
+
video_file = form.get('video')
|
| 680 |
+
post_id = str(uuid.uuid4())[:12]
|
| 681 |
+
video_url = None
|
| 682 |
+
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 683 |
+
fname = video_file.filename.lower()
|
| 684 |
+
if fname.endswith('.mp4'):
|
| 685 |
+
ext = '.mp4'
|
| 686 |
+
elif fname.endswith('.webm'):
|
| 687 |
+
ext = '.webm'
|
| 688 |
+
else:
|
| 689 |
+
ext = '.webm'
|
| 690 |
+
video_filename = f"wall_{post_id}{ext}"
|
| 691 |
+
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 692 |
+
try:
|
| 693 |
+
content = await video_file.read()
|
| 694 |
+
if not content:
|
| 695 |
+
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 696 |
+
with open(video_path, 'wb') as f:
|
| 697 |
+
f.write(content)
|
| 698 |
+
file_size_mb = len(content) / 1024 / 1024
|
| 699 |
+
if file_size_mb > 50:
|
| 700 |
+
os.remove(video_path)
|
| 701 |
+
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 702 |
+
video_url = f"/api/wall/video/{video_filename}"
|
| 703 |
+
except Exception as e:
|
| 704 |
+
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 705 |
+
post = {
|
| 706 |
+
"id": post_id,
|
| 707 |
+
"title": title[:200],
|
| 708 |
+
"text": text[:2000],
|
| 709 |
+
"source": source,
|
| 710 |
+
"video": video_url,
|
| 711 |
+
"img": None,
|
| 712 |
+
"images": [],
|
| 713 |
+
"created": int(time.time()),
|
| 714 |
+
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 715 |
+
}
|
| 716 |
+
posts = _load_wall_posts()
|
| 717 |
+
if not isinstance(posts, list):
|
| 718 |
+
posts = []
|
| 719 |
+
posts.insert(0, post)
|
| 720 |
+
posts = posts[:200]
|
| 721 |
+
_save_wall_posts(posts)
|
| 722 |
+
return JSONResponse({"post": post, "ok": True})
|
| 723 |
+
try:
|
| 724 |
+
body = await request.json()
|
| 725 |
+
except:
|
| 726 |
+
body = {}
|
| 727 |
+
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 728 |
+
text = body.get('text', '') or ''
|
| 729 |
+
img = body.get('img', None)
|
| 730 |
+
source = body.get('source', 'user') or 'user'
|
| 731 |
+
post_id = str(uuid.uuid4())[:12]
|
| 732 |
+
post = {
|
| 733 |
+
"id": post_id,
|
| 734 |
+
"title": title[:200],
|
| 735 |
+
"text": text[:2000],
|
| 736 |
+
"source": source,
|
| 737 |
+
"video": None,
|
| 738 |
+
"img": img,
|
| 739 |
+
"images": [],
|
| 740 |
+
"created": int(time.time()),
|
| 741 |
+
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 742 |
+
}
|
| 743 |
+
posts = _load_wall_posts()
|
| 744 |
+
if not isinstance(posts, list):
|
| 745 |
+
posts = []
|
| 746 |
+
posts.insert(0, post)
|
| 747 |
+
posts = posts[:200]
|
| 748 |
+
_save_wall_posts(posts)
|
| 749 |
+
return JSONResponse({"post": post, "ok": True})
|
| 750 |
+
|
| 751 |
+
@app.get('/api/wall/video/{filename}')
|
| 752 |
+
def api_wall_video(filename: str):
|
| 753 |
+
if '..' in filename or '/' in filename:
|
| 754 |
+
return Response(status_code=403)
|
| 755 |
+
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
| 756 |
+
if not os.path.exists(video_path):
|
| 757 |
+
return Response(status_code=404)
|
| 758 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 759 |
+
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 760 |
+
return FileResponse(video_path, media_type=media_type)
|
| 761 |
+
|
| 762 |
+
@app.delete('/api/wall/{post_id}')
|
| 763 |
+
def api_wall_delete(post_id: str):
|
| 764 |
+
posts = _load_wall_posts()
|
| 765 |
+
if not isinstance(posts, list):
|
| 766 |
+
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 767 |
+
for i, p in enumerate(posts):
|
| 768 |
+
if p.get('id') == post_id:
|
| 769 |
+
if p.get('video'):
|
| 770 |
+
video_name = p['video'].split('/')[-1]
|
| 771 |
+
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
| 772 |
+
if os.path.exists(video_path):
|
| 773 |
+
os.remove(video_path)
|
| 774 |
+
posts.pop(i)
|
| 775 |
+
_save_wall_posts(posts)
|
| 776 |
+
return JSONResponse({"ok": True})
|
| 777 |
+
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 778 |
+
|
| 779 |
+
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 780 |
+
import random as _random2
|
| 781 |
+
from urllib.parse import quote as _quote2
|
| 782 |
+
|
| 783 |
+
_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 784 |
+
|
| 785 |
+
# Unique character markers for language detection
|
| 786 |
+
_UNIQUE_CHARS = {
|
| 787 |
+
'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
|
| 788 |
+
'spanish': set('ñáéíóúü¿¡'),
|
| 789 |
+
'portuguese': set('ãõçáéíóúâêôà'),
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
_STOPWORDS = {
|
| 793 |
+
'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'},
|
| 794 |
+
'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'},
|
| 795 |
+
'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'},
|
| 796 |
+
'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'},
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
def detect_language(text):
|
| 800 |
+
"""Detect language from text content using stopword + character analysis."""
|
| 801 |
+
if not text:
|
| 802 |
+
return 'vietnamese'
|
| 803 |
+
text_lower = text.lower()
|
| 804 |
+
text_chars = set(text_lower)
|
| 805 |
+
|
| 806 |
+
# Strong signal: Vietnamese unique characters
|
| 807 |
+
vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
|
| 808 |
+
if vn_chars >= 2:
|
| 809 |
+
return 'vietnamese'
|
| 810 |
+
|
| 811 |
+
# Spanish unique chars (ñ, ¿, ¡)
|
| 812 |
+
es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
|
| 813 |
+
pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
|
| 814 |
+
|
| 815 |
+
# Stopword scoring
|
| 816 |
+
words = set(re.findall(r'\b\w+\b', text_lower))
|
| 817 |
+
scores = {}
|
| 818 |
+
for lang, stops in _STOPWORDS.items():
|
| 819 |
+
scores[lang] = len(words & stops) / max(len(stops), 1)
|
| 820 |
+
|
| 821 |
+
# Disambiguate Portuguese vs Spanish
|
| 822 |
+
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'}
|
| 823 |
+
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'}
|
| 824 |
+
|
| 825 |
+
pt_overlap = len(words & pt_markers)
|
| 826 |
+
es_overlap = len(words & es_markers)
|
| 827 |
+
|
| 828 |
+
if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
|
| 829 |
+
return 'portuguese'
|
| 830 |
+
if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
|
| 831 |
+
return 'spanish'
|
| 832 |
+
if scores.get('english', 0) > 0.15:
|
| 833 |
+
return 'english'
|
| 834 |
+
|
| 835 |
+
best = max(scores, key=scores.get)
|
| 836 |
+
return best if scores[best] > 0.05 else 'vietnamese'
|
| 837 |
+
|
| 838 |
+
# Emotion keyword-based detection
|
| 839 |
+
_EMOTION_KEYWORDS = {
|
| 840 |
+
'happy': {
|
| 841 |
+
'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
|
| 842 |
+
'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
|
| 843 |
+
'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
|
| 844 |
+
'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'],
|
| 845 |
+
},
|
| 846 |
+
'sad': {
|
| 847 |
+
'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
|
| 848 |
+
'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
|
| 849 |
+
'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
|
| 850 |
+
'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'],
|
| 851 |
+
},
|
| 852 |
+
'excited': {
|
| 853 |
+
'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
|
| 854 |
+
'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
|
| 855 |
+
'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
|
| 856 |
+
'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á'],
|
| 857 |
+
},
|
| 858 |
+
'humorous': {
|
| 859 |
+
'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
|
| 860 |
+
'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
|
| 861 |
+
'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
|
| 862 |
+
'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'],
|
| 863 |
+
},
|
| 864 |
+
'serious': {
|
| 865 |
+
'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
|
| 866 |
+
'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
|
| 867 |
+
'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
|
| 868 |
+
'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'],
|
| 869 |
+
},
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
def detect_emotion(text, language='vietnamese'):
|
| 873 |
+
"""Detect emotion from text using keyword matching."""
|
| 874 |
+
if not text:
|
| 875 |
+
return 'neutral'
|
| 876 |
+
text_lower = text.lower()
|
| 877 |
+
|
| 878 |
+
scores = {}
|
| 879 |
+
for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
|
| 880 |
+
keywords = lang_keywords.get(language, lang_keywords.get('en', []))
|
| 881 |
+
score = sum(1 for kw in keywords if kw in text_lower)
|
| 882 |
+
scores[emotion] = score
|
| 883 |
+
|
| 884 |
+
if max(scores.values()) == 0:
|
| 885 |
+
return 'neutral'
|
| 886 |
+
|
| 887 |
+
return max(scores, key=scores.get)
|
| 888 |
+
|
| 889 |
+
def detect_language_and_emotion(title, text):
|
| 890 |
+
"""Detect both language and emotion from article content."""
|
| 891 |
+
combined = f"{title} {text}"
|
| 892 |
+
lang = detect_language(combined)
|
| 893 |
+
emotion = detect_emotion(combined, lang)
|
| 894 |
+
return lang, emotion
|
| 895 |
+
|
| 896 |
+
# Voice selection based on language and emotion (using MultilingualNeural voices)
|
| 897 |
+
VOICE_BY_LANG_EMOTION = {
|
| 898 |
+
'vietnamese': {
|
| 899 |
+
'happy': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 900 |
+
'sad': ('vi-VN-NamMinhNeural', 'buồn'),
|
| 901 |
+
'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
|
| 902 |
+
'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 903 |
+
'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
|
| 904 |
+
'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
|
| 905 |
+
},
|
| 906 |
+
'portuguese': {
|
| 907 |
+
'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
|
| 908 |
+
'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
|
| 909 |
+
'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
|
| 910 |
+
'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
|
| 911 |
+
'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
|
| 912 |
+
'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
|
| 913 |
+
},
|
| 914 |
+
'english': {
|
| 915 |
+
'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
|
| 916 |
+
'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
|
| 917 |
+
'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
|
| 918 |
+
'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
|
| 919 |
+
'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
|
| 920 |
+
'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
|
| 921 |
+
},
|
| 922 |
+
'french': {
|
| 923 |
+
'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
|
| 924 |
+
'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
|
| 925 |
+
'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
|
| 926 |
+
'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
|
| 927 |
+
'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
|
| 928 |
+
'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
|
| 929 |
+
},
|
| 930 |
+
'german': {
|
| 931 |
+
'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
|
| 932 |
+
'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
|
| 933 |
+
'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
|
| 934 |
+
'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
|
| 935 |
+
'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
|
| 936 |
+
'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
|
| 937 |
+
},
|
| 938 |
+
'korean': {
|
| 939 |
+
'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
|
| 940 |
+
'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
|
| 941 |
+
'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
|
| 942 |
+
'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
|
| 943 |
+
'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
|
| 944 |
+
'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
|
| 945 |
+
},
|
| 946 |
+
'italian': {
|
| 947 |
+
'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
|
| 948 |
+
'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
|
| 949 |
+
'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
|
| 950 |
+
'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
|
| 951 |
+
'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
|
| 952 |
+
'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
|
| 953 |
+
},
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
# All valid voice IDs (new MultilingualNeural format)
|
| 957 |
+
VALID_VOICES = {
|
| 958 |
+
'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
|
| 959 |
+
'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
|
| 960 |
+
'pt-BR-ThalitaMultilingualNeural',
|
| 961 |
+
'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
|
| 962 |
+
'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
|
| 963 |
+
'ko-KR-HyunsuMultilingualNeural',
|
| 964 |
+
'it-IT-GiuseppeMultilingualNeural',
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
def get_voice_for_content(title, text, preferred_voice=None):
|
| 968 |
+
"""Get appropriate voice based on content language and emotion."""
|
| 969 |
+
# Accept the new MultilingualNeural voices directly
|
| 970 |
+
if preferred_voice and preferred_voice in VALID_VOICES:
|
| 971 |
+
return preferred_voice
|
| 972 |
+
|
| 973 |
+
# Also accept old shorthand voice IDs and map them to new format
|
| 974 |
+
old_voice_map = {
|
| 975 |
+
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 976 |
+
'namminh': 'vi-VN-NamMinhNeural',
|
| 977 |
+
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 978 |
+
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 979 |
+
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 980 |
+
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 981 |
+
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 982 |
+
'ela': 'en-US-AndrewMultilingualNeural',
|
| 983 |
+
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 984 |
+
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 985 |
+
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 986 |
+
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 987 |
+
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 988 |
+
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 989 |
+
}
|
| 990 |
+
if preferred_voice and preferred_voice in old_voice_map:
|
| 991 |
+
return old_voice_map[preferred_voice]
|
| 992 |
+
|
| 993 |
+
lang, emotion = detect_language_and_emotion(title, text)
|
| 994 |
+
lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
|
| 995 |
+
voice, _ = lang_map.get(emotion, lang_map['neutral'])
|
| 996 |
+
return voice
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
def _is_relevant_image(img_url, title, text):
|
| 1000 |
+
"""Check if an image is relevant to the article content."""
|
| 1001 |
+
if not img_url:
|
| 1002 |
+
return False
|
| 1003 |
+
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 1004 |
+
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 1005 |
+
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 1006 |
+
img_lower = img_url.lower()
|
| 1007 |
+
for p in skip_patterns:
|
| 1008 |
+
if p in img_lower:
|
| 1009 |
+
return False
|
| 1010 |
+
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 1011 |
+
return False
|
| 1012 |
+
return True
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
def _filter_relevant_images(images, title, text, max_images=8):
|
| 1016 |
+
"""Filter and rank images by relevance to article content."""
|
| 1017 |
+
if not images:
|
| 1018 |
+
return []
|
| 1019 |
+
seen = set()
|
| 1020 |
+
relevant = []
|
| 1021 |
+
for img in images:
|
| 1022 |
+
if img in seen:
|
| 1023 |
+
continue
|
| 1024 |
+
seen.add(img)
|
| 1025 |
+
if _is_relevant_image(img, title, text):
|
| 1026 |
+
relevant.append(img)
|
| 1027 |
+
return relevant[:max_images]
|
| 1028 |
+
|
| 1029 |
+
|
| 1030 |
+
def _scrape_article_for_rewrite(url):
|
| 1031 |
+
"""Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
|
| 1032 |
+
try:
|
| 1033 |
+
r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
|
| 1034 |
+
r.encoding = 'utf-8'
|
| 1035 |
+
soup = BeautifulSoup(r.text, 'lxml')
|
| 1036 |
+
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 1037 |
+
tag.decompose()
|
| 1038 |
+
h1 = soup.find('h1')
|
| 1039 |
+
ogt = soup.find('meta', property='og:title')
|
| 1040 |
+
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 1041 |
+
ogi = soup.find('meta', property='og:image')
|
| 1042 |
+
og_img = ogi.get('content', '') if ogi else ''
|
| 1043 |
+
if og_img and og_img.startswith('//'):
|
| 1044 |
+
og_img = 'https:' + og_img
|
| 1045 |
+
block = None
|
| 1046 |
+
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 1047 |
+
el = soup.select_one(sel)
|
| 1048 |
+
if el and len(el.find_all('p')) >= 2:
|
| 1049 |
+
block = el
|
| 1050 |
+
break
|
| 1051 |
+
if not block:
|
| 1052 |
+
block = soup.body or soup
|
| 1053 |
+
paragraphs = []
|
| 1054 |
+
all_images = []
|
| 1055 |
+
seen_imgs = set()
|
| 1056 |
+
if og_img and og_img not in seen_imgs:
|
| 1057 |
+
all_images.append(og_img)
|
| 1058 |
+
seen_imgs.add(og_img)
|
| 1059 |
+
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 1060 |
+
if el.name == 'p':
|
| 1061 |
+
t = _clean(el.get_text(strip=True))
|
| 1062 |
+
if t and len(t) > 40:
|
| 1063 |
+
paragraphs.append(t)
|
| 1064 |
+
elif el.name in ('figure', 'img'):
|
| 1065 |
+
im = el if el.name == 'img' else el.find('img')
|
| 1066 |
+
if im:
|
| 1067 |
+
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 1068 |
+
if src and 'base64' not in src:
|
| 1069 |
+
if src.startswith('//'):
|
| 1070 |
+
src = 'https:' + src
|
| 1071 |
+
if src not in seen_imgs:
|
| 1072 |
+
all_images.append(src)
|
| 1073 |
+
seen_imgs.add(src)
|
| 1074 |
+
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 1075 |
+
paragraphs_text = '\n\n'.join(paragraphs)
|
| 1076 |
+
lang, emotion = detect_language_and_emotion(title, paragraphs_text)
|
| 1077 |
+
lang_name_map = {'vietnamese': 'vi', 'english': 'en', 'portuguese': 'pt', 'spanish': 'es',
|
| 1078 |
+
'french': 'fr', 'german': 'de', 'korean': 'ko', 'italian': 'it'}
|
| 1079 |
+
return {'title': _clean(title), 'text': paragraphs_text, 'images': relevant_images,
|
| 1080 |
+
'og_img': og_img, 'language': lang_name_map.get(lang, 'vi'), 'emotion': emotion}
|
| 1081 |
+
except Exception as e:
|
| 1082 |
+
return None
|