Spaces:
Running
Running
Fix syntax errors in f-strings
Browse files- app_v2_entry.py +1 -1332
app_v2_entry.py
CHANGED
|
@@ -1,1332 +1 @@
|
|
| 1 |
-
|
| 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 |
-
# Rotating User-Agents for search scrapers - tránh bị chặn
|
| 210 |
-
_SEARCH_UAS = [
|
| 211 |
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
| 212 |
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
| 213 |
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
| 214 |
-
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
|
| 215 |
-
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
| 216 |
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
| 217 |
-
]
|
| 218 |
-
|
| 219 |
-
import random as _search_rand
|
| 220 |
-
|
| 221 |
-
def _search_headers():
|
| 222 |
-
return {'User-Agent': _search_rand.choice(_SEARCH_UAS), 'Accept-Language': 'vi-VN,vi;q=0.9,en;q=0.8', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'}
|
| 223 |
-
|
| 224 |
-
def _has_kw(topic,title):
|
| 225 |
-
tl=topic.lower();tt=(title or'').lower()
|
| 226 |
-
if tl in tt:return True
|
| 227 |
-
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
|
| 228 |
-
if not words:return True
|
| 229 |
-
return any(w in tt for w in words)
|
| 230 |
-
|
| 231 |
-
def _s_vnexpress(topic,limit=8):
|
| 232 |
-
items=[]
|
| 233 |
-
try:
|
| 234 |
-
r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 235 |
-
for art in soup.select('article.item-news')[:limit]:
|
| 236 |
-
a=art.select_one('h2 a, h3 a')
|
| 237 |
-
if a and a.get('href'):
|
| 238 |
-
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 239 |
-
if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
|
| 240 |
-
except:pass
|
| 241 |
-
return items
|
| 242 |
-
|
| 243 |
-
def _s_dantri(topic,limit=8):
|
| 244 |
-
items=[]
|
| 245 |
-
try:
|
| 246 |
-
r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 247 |
-
for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
|
| 248 |
-
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 249 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 250 |
-
if not href.startswith('http'):href='https://dantri.com.vn'+href
|
| 251 |
-
items.append({'title':t,'url':href,'via':'Dân Trí'})
|
| 252 |
-
if len(items)>=limit:break
|
| 253 |
-
except:pass
|
| 254 |
-
return items
|
| 255 |
-
|
| 256 |
-
def _s_vietnamnet(topic,limit=6):
|
| 257 |
-
items=[]
|
| 258 |
-
try:
|
| 259 |
-
r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 260 |
-
for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
|
| 261 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 262 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 263 |
-
if not href.startswith('http'):href='https://vietnamnet.vn'+href
|
| 264 |
-
items.append({'title':t,'url':href,'via':'VietNamNet'})
|
| 265 |
-
if len(items)>=limit:break
|
| 266 |
-
except:pass
|
| 267 |
-
return items
|
| 268 |
-
|
| 269 |
-
def _s_bongda(topic,limit=5):
|
| 270 |
-
items=[]
|
| 271 |
-
try:
|
| 272 |
-
r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 273 |
-
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 274 |
-
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 275 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 276 |
-
if not href.startswith('http'):href='https://bongda.com.vn'+href
|
| 277 |
-
items.append({'title':t,'url':href,'via':'Bóng Đá'})
|
| 278 |
-
if len(items)>=limit:break
|
| 279 |
-
except:pass
|
| 280 |
-
return items
|
| 281 |
-
|
| 282 |
-
def _s_genk(topic,limit=5):
|
| 283 |
-
items=[]
|
| 284 |
-
try:
|
| 285 |
-
r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 286 |
-
for a in soup.select('a[href$=".chn"]')[:limit*3]:
|
| 287 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 288 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 289 |
-
if href.startswith('/'):href='https://genk.vn'+href
|
| 290 |
-
items.append({'title':t,'url':href,'via':'GenK'})
|
| 291 |
-
if len(items)>=limit:break
|
| 292 |
-
except:pass
|
| 293 |
-
return items
|
| 294 |
-
|
| 295 |
-
def _s_thanhnien(topic,limit=6):
|
| 296 |
-
items=[]
|
| 297 |
-
try:
|
| 298 |
-
r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 299 |
-
for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
|
| 300 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 301 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 302 |
-
if not href.startswith('http'):href='https://thanhnien.vn'+href
|
| 303 |
-
items.append({'title':t,'url':href,'via':'Thanh Niên'})
|
| 304 |
-
if len(items)>=limit:break
|
| 305 |
-
except:pass
|
| 306 |
-
return items
|
| 307 |
-
|
| 308 |
-
def _s_tuoitre(topic,limit=6):
|
| 309 |
-
items=[]
|
| 310 |
-
try:
|
| 311 |
-
r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 312 |
-
for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
|
| 313 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 314 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 315 |
-
if not href.startswith('http'):href='https://tuoitre.vn'+href
|
| 316 |
-
items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
|
| 317 |
-
if len(items)>=limit:break
|
| 318 |
-
except:pass
|
| 319 |
-
return items
|
| 320 |
-
|
| 321 |
-
def _s_thethaovanhoa(topic,limit=5):
|
| 322 |
-
items=[]
|
| 323 |
-
try:
|
| 324 |
-
r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
|
| 325 |
-
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 326 |
-
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 327 |
-
if t and len(t)>15 and _has_kw(topic,t):
|
| 328 |
-
if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
|
| 329 |
-
items.append({'title':t,'url':href,'via':'TT&VH'})
|
| 330 |
-
if len(items)>=limit:break
|
| 331 |
-
except:pass
|
| 332 |
-
return items
|
| 333 |
-
|
| 334 |
-
def _s_google_news(topic,limit=8):
|
| 335 |
-
"""Google News RSS fallback - hoạt động khi các site VN chặn."""
|
| 336 |
-
items=[]
|
| 337 |
-
try:
|
| 338 |
-
r=req.get(f"https://news.google.com/rss/search?q={quote(topic)}+Việt+Nam&hl=vi&gl=VN&ceid=VN:vi",headers=_search_headers(),timeout=10)
|
| 339 |
-
r.encoding='utf-8'
|
| 340 |
-
soup=BeautifulSoup(r.text,'xml')
|
| 341 |
-
for item in soup.find_all('item')[:limit]:
|
| 342 |
-
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 343 |
-
link=item.find('link').get_text() if item.find('link') else ''
|
| 344 |
-
source=item.find('source').get_text() if item.find('source') else 'Google News'
|
| 345 |
-
if title and link and len(title)>15:
|
| 346 |
-
items.append({'title':title,'url':link,'via':f'Google {source}'})
|
| 347 |
-
except:pass
|
| 348 |
-
return items
|
| 349 |
-
|
| 350 |
-
def _search_all(topic,limit=36):
|
| 351 |
-
results={}
|
| 352 |
-
with ThreadPoolExecutor(8) as ex:
|
| 353 |
-
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'}
|
| 354 |
-
for f in as_completed(futs,timeout=20):
|
| 355 |
-
try:results[futs[f]]=f.result()
|
| 356 |
-
except:results[futs[f]]=[]
|
| 357 |
-
srcs=list(results.values());out=[];seen=set()
|
| 358 |
-
for i in range(max((len(s) for s in srcs),default=0)):
|
| 359 |
-
for s in srcs:
|
| 360 |
-
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])
|
| 361 |
-
# Fallback: nếu tất cả 8 site VN đều trả về rỗng, thử Google News RSS
|
| 362 |
-
if not out:
|
| 363 |
-
try:
|
| 364 |
-
google_items=_s_google_news(topic,limit)
|
| 365 |
-
out.extend(google_items)
|
| 366 |
-
except:pass
|
| 367 |
-
return out[:limit]
|
| 368 |
-
|
| 369 |
-
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
| 370 |
-
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 371 |
-
|
| 372 |
-
_article_cache = {}
|
| 373 |
-
_article_cache_ttl = 1800
|
| 374 |
-
|
| 375 |
-
_art_session = None
|
| 376 |
-
_art_lock = threading.Lock()
|
| 377 |
-
def _get_art_session():
|
| 378 |
-
global _art_session
|
| 379 |
-
if _art_session is None:
|
| 380 |
-
with _art_lock:
|
| 381 |
-
if _art_session is None:
|
| 382 |
-
_art_session = req.Session()
|
| 383 |
-
_art_session.headers.update({
|
| 384 |
-
"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",
|
| 385 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 386 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 387 |
-
})
|
| 388 |
-
return _art_session
|
| 389 |
-
|
| 390 |
-
def _scrape_article_fast(url):
|
| 391 |
-
from urllib.parse import urlparse
|
| 392 |
-
domain = urlparse(url).netloc
|
| 393 |
-
sess = _get_art_session()
|
| 394 |
-
uas = [
|
| 395 |
-
{"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"},
|
| 396 |
-
{"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"},
|
| 397 |
-
]
|
| 398 |
-
for ua in uas:
|
| 399 |
-
try:
|
| 400 |
-
r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
|
| 401 |
-
if not r or r.status_code != 200:
|
| 402 |
-
continue
|
| 403 |
-
r.encoding = 'utf-8'
|
| 404 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 405 |
-
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']):
|
| 406 |
-
tag.decompose()
|
| 407 |
-
title = summary = og_img = ""
|
| 408 |
-
ogt = soup.find('meta', property='og:title')
|
| 409 |
-
if ogt: title = ogt.get('content', '')
|
| 410 |
-
ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
|
| 411 |
-
if ogd: summary = ogd.get('content', '')[:500]
|
| 412 |
-
ogi = soup.find('meta', property='og:image')
|
| 413 |
-
if ogi:
|
| 414 |
-
og_img = ogi.get('content', '')
|
| 415 |
-
if og_img.startswith('//'): og_img = 'https:' + og_img
|
| 416 |
-
h1 = soup.find('h1')
|
| 417 |
-
if not title and h1: title = h1.get_text(strip=True)[:200]
|
| 418 |
-
body = []
|
| 419 |
-
selectors = [
|
| 420 |
-
'.fck_detail', '.sidebar-1',
|
| 421 |
-
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
|
| 422 |
-
'.content-detail', '.main-content-detail', '.box-content',
|
| 423 |
-
'.knc-content', '.article-body', '.detail-body',
|
| 424 |
-
'.article-detail', '.detail-content',
|
| 425 |
-
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 426 |
-
'.entry-content', '#content', '.article-text', '.story-body',
|
| 427 |
-
]
|
| 428 |
-
for sel in selectors:
|
| 429 |
-
el = soup.select_one(sel)
|
| 430 |
-
if el and len(el.find_all('p')) >= 2:
|
| 431 |
-
seen_imgs = set()
|
| 432 |
-
for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
|
| 433 |
-
if child.name == 'p':
|
| 434 |
-
t = child.get_text(strip=True)
|
| 435 |
-
if t and len(t) > 15:
|
| 436 |
-
body.append({'type': 'p', 'text': t})
|
| 437 |
-
elif child.name in ('h2','h3'):
|
| 438 |
-
t = child.get_text(strip=True)
|
| 439 |
-
if t:
|
| 440 |
-
body.append({'type': 'heading', 'text': t})
|
| 441 |
-
elif child.name in ('figure','img'):
|
| 442 |
-
im = child if child.name == 'img' else child.find('img')
|
| 443 |
-
if im:
|
| 444 |
-
src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
|
| 445 |
-
if src and 'base64' not in src and src not in seen_imgs:
|
| 446 |
-
seen_imgs.add(src)
|
| 447 |
-
if src.startswith('//'): src = 'https:' + src
|
| 448 |
-
body.append({'type': 'img', 'src': src})
|
| 449 |
-
if child.name == 'figure':
|
| 450 |
-
cap = child.find('figcaption')
|
| 451 |
-
if cap:
|
| 452 |
-
ct = cap.get_text(strip=True)
|
| 453 |
-
if ct: body.append({'type': 'p', 'text': ct})
|
| 454 |
-
if len(body) >= 2:
|
| 455 |
-
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 456 |
-
'body': body[:50], 'source': domain, 'url': url}
|
| 457 |
-
if title and (summary or og_img):
|
| 458 |
-
fallback = []
|
| 459 |
-
if og_img: fallback.append({'type': 'img', 'src': og_img})
|
| 460 |
-
if summary: fallback.append({'type': 'p', 'text': summary})
|
| 461 |
-
if fallback:
|
| 462 |
-
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 463 |
-
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
| 464 |
-
if title:
|
| 465 |
-
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 466 |
-
'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
|
| 467 |
-
'source': domain, 'url': url, 'fallback': True}
|
| 468 |
-
break
|
| 469 |
-
except Exception:
|
| 470 |
-
continue
|
| 471 |
-
return None
|
| 472 |
-
|
| 473 |
-
@app.get('/api/article')
|
| 474 |
-
def api_article_v2(url: str = Query(...)):
|
| 475 |
-
from urllib.parse import unquote
|
| 476 |
-
safe_url = unquote(url)
|
| 477 |
-
try:
|
| 478 |
-
now = time.time()
|
| 479 |
-
cached = _article_cache.get(safe_url)
|
| 480 |
-
if cached and now - cached['t'] < _article_cache_ttl:
|
| 481 |
-
resp = JSONResponse(cached['d'])
|
| 482 |
-
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 483 |
-
return resp
|
| 484 |
-
data = _scrape_article_fast(safe_url)
|
| 485 |
-
if data and data.get('body'):
|
| 486 |
-
_article_cache[safe_url] = {'d': data, 't': now}
|
| 487 |
-
resp = JSONResponse(data)
|
| 488 |
-
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 489 |
-
return resp
|
| 490 |
-
result = {'error': 'Không đọc được', 'url': safe_url}
|
| 491 |
-
resp = JSONResponse(result)
|
| 492 |
-
resp.headers["Cache-Control"] = "public, max-age=60"
|
| 493 |
-
return resp
|
| 494 |
-
except Exception as e:
|
| 495 |
-
return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
|
| 496 |
-
|
| 497 |
-
_hot_cache={'t':0,'d':[]}
|
| 498 |
-
def _get_hot_topics():
|
| 499 |
-
now=time.time()
|
| 500 |
-
if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
|
| 501 |
-
freq={};display={}
|
| 502 |
-
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']
|
| 503 |
-
for feed_url in feeds:
|
| 504 |
-
try:
|
| 505 |
-
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
|
| 506 |
-
for item in soup.find_all('item')[:12]:
|
| 507 |
-
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 508 |
-
if not title:continue
|
| 509 |
-
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]
|
| 510 |
-
if len(words)<2:continue
|
| 511 |
-
for n in(3,4,2):
|
| 512 |
-
for i in range(max(0,len(words)-n+1)):
|
| 513 |
-
phrase=' '.join(words[i:i+n])
|
| 514 |
-
if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
|
| 515 |
-
except:continue
|
| 516 |
-
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
|
| 517 |
-
for key,count in ranked:
|
| 518 |
-
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)
|
| 519 |
-
if is_dup:continue
|
| 520 |
-
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
|
| 521 |
-
if len(topics)>=20:break
|
| 522 |
-
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']:
|
| 523 |
-
if len(topics)>=24:break
|
| 524 |
-
if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
|
| 525 |
-
_hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
|
| 526 |
-
|
| 527 |
-
@app.get('/api/hot_topics')
|
| 528 |
-
def api_hot_topics():
|
| 529 |
-
resp = JSONResponse({'topics':_get_hot_topics()})
|
| 530 |
-
resp.headers["Cache-Control"] = "public, max-age=120"
|
| 531 |
-
return resp
|
| 532 |
-
@app.get('/')
|
| 533 |
-
async def serve_index():
|
| 534 |
-
p=os.path.join(STATIC_DIR,'index_v2.html')
|
| 535 |
-
if os.path.exists(p):return FileResponse(p,media_type='text/html')
|
| 536 |
-
return HTMLResponse('<h1>VNEWS</h1>')
|
| 537 |
-
@app.get('/api/hashtag/sources')
|
| 538 |
-
def _ht(topic:str=Query(...),page:int=Query(default=0),source:str=Query(default=None)):
|
| 539 |
-
if source == 'google':
|
| 540 |
-
# Direct Google News RSS request
|
| 541 |
-
items=_s_google_news(topic,36)
|
| 542 |
-
else:
|
| 543 |
-
items=_search_all(topic,36)
|
| 544 |
-
per_page=8;start=page*per_page;end=start+per_page
|
| 545 |
-
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
| 546 |
-
@app.get('/api/categories')
|
| 547 |
-
def _cat():return JSONResponse([])
|
| 548 |
-
@app.get('/api/storage_status')
|
| 549 |
-
def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
| 550 |
-
# ===== SHARE HELPERS: render content pages for shared links =====
|
| 551 |
-
def _render_slides_page(post, safe_title, safe_img, safe_url):
|
| 552 |
-
slides = post.get('slides', [])
|
| 553 |
-
# Get image from post.img or first slide's image
|
| 554 |
-
if not safe_img and slides and slides[0].get('image'):
|
| 555 |
-
safe_img = slides[0].get('image', '')
|
| 556 |
-
# Use text for description if available
|
| 557 |
-
description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
|
| 558 |
-
|
| 559 |
-
# Build canonical URL preserving original query format if url was provided
|
| 560 |
-
if safe_url and safe_url != '/':
|
| 561 |
-
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 562 |
-
else:
|
| 563 |
-
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 564 |
-
|
| 565 |
-
h = f'''<!DOCTYPE html>
|
| 566 |
-
<html lang="vi">
|
| 567 |
-
<head>
|
| 568 |
-
<meta charset="utf-8">
|
| 569 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 570 |
-
<title>{_clean(safe_title)}</title>
|
| 571 |
-
<meta property="og:title" content="{_clean(safe_title)}">
|
| 572 |
-
<meta property="og:image" content="{_clean(safe_img)}">
|
| 573 |
-
<meta property="og:description" content="{description}">
|
| 574 |
-
<meta property="og:url" content="{canonical_url}">
|
| 575 |
-
<link rel="canonical" href="{canonical_url}">
|
| 576 |
-
<style>
|
| 577 |
-
*{{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}}
|
| 578 |
-
.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}}
|
| 579 |
-
.slide-num{{color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px}}
|
| 580 |
-
.slide-img{{width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px}}
|
| 581 |
-
.slide-text{{color:#ddd;font-size:14px;line-height:1.6;margin:0}}
|
| 582 |
-
</style>
|
| 583 |
-
</head>
|
| 584 |
-
<body>'''
|
| 585 |
-
for s in slides:
|
| 586 |
-
img_src = s.get('image', '')
|
| 587 |
-
if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src):
|
| 588 |
-
img_tag = f'<img src="/api/proxy/img?url={quote(img_src, safe="")}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
|
| 589 |
-
else:
|
| 590 |
-
img_tag = f'<img src="{_clean(img_src)}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">' if img_src else ''
|
| 591 |
-
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>'
|
| 592 |
-
h += '</body></html>'
|
| 593 |
-
return HTMLResponse(h)
|
| 594 |
-
|
| 595 |
-
def _render_video_page(post, safe_title, safe_img, safe_url):
|
| 596 |
-
video_url = post.get('video', '')
|
| 597 |
-
# Use text for description if available
|
| 598 |
-
description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
|
| 599 |
-
|
| 600 |
-
# Build canonical URL preserving original query format if url was provided
|
| 601 |
-
if safe_url and safe_url != '/':
|
| 602 |
-
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 603 |
-
else:
|
| 604 |
-
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 605 |
-
|
| 606 |
-
h = f'''<!DOCTYPE html>
|
| 607 |
-
<html lang="vi">
|
| 608 |
-
<head>
|
| 609 |
-
<meta charset="utf-8">
|
| 610 |
-
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
| 611 |
-
<title>{_clean(safe_title)}</title>
|
| 612 |
-
<meta property="og:title" content="{_clean(safe_title)}">
|
| 613 |
-
<meta property="og:image" content="{_clean(safe_img)}">
|
| 614 |
-
<meta property="og:description" content="{description}">
|
| 615 |
-
<meta property="og:url" content="{canonical_url}">
|
| 616 |
-
<link rel="canonical" href="{canonical_url}">
|
| 617 |
-
<meta name="twitter:card" content="player">
|
| 618 |
-
<meta name="twitter:player" content="{video_url}">
|
| 619 |
-
<style>
|
| 620 |
-
*{{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}}
|
| 621 |
-
.video-container{{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000}}
|
| 622 |
-
video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#000}}
|
| 623 |
-
.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}}
|
| 624 |
-
.title-text{{color:#fff;font-size:13px;line-height:1.4;max-width:600px;margin:0 auto}}
|
| 625 |
-
</style>
|
| 626 |
-
</head>
|
| 627 |
-
<body>
|
| 628 |
-
<div class="video-container">
|
| 629 |
-
<video src="{_clean(video_url)}" controls autoplay playsinline loop></video>
|
| 630 |
-
<div class="title-bar"><div class="title-text">{_clean(safe_title)}</div></div>
|
| 631 |
-
</div>
|
| 632 |
-
</body></html>'''
|
| 633 |
-
return HTMLResponse(h)
|
| 634 |
-
|
| 635 |
-
@app.get('/s/{slug}')
|
| 636 |
-
async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
|
| 637 |
-
"""SEO-friendly share endpoint with slug in URL path.
|
| 638 |
-
Shows slide content when slug matches a wall post ID, otherwise redirects.
|
| 639 |
-
"""
|
| 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 post by slug (post ID)
|
| 645 |
-
post = None
|
| 646 |
-
try:
|
| 647 |
-
if slug and len(slug) > 5: # Likely a post ID
|
| 648 |
-
posts = _load_wall_posts()
|
| 649 |
-
for p in posts:
|
| 650 |
-
if p.get('id') == slug:
|
| 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 |
-
except:
|
| 657 |
-
pass
|
| 658 |
-
|
| 659 |
-
if post and post.get('slides'):
|
| 660 |
-
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 661 |
-
|
| 662 |
-
if post and post.get('video'):
|
| 663 |
-
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 664 |
-
|
| 665 |
-
# Otherwise redirect
|
| 666 |
-
return HTMLResponse(f'''<!DOCTYPE html>
|
| 667 |
-
<html lang="vi">
|
| 668 |
-
<head>
|
| 669 |
-
<meta charset="utf-8">
|
| 670 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 671 |
-
<title>{_clean(safe_title)}</title>
|
| 672 |
-
<meta property="og:title" content="{_clean(safe_title)}">
|
| 673 |
-
<meta property="og:image" content="{_clean(safe_img)}">
|
| 674 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 675 |
-
<meta property="og:url" content="{SPACE}/s/{slug}">
|
| 676 |
-
<link rel="canonical" href="{SPACE}/s/{slug}">
|
| 677 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 678 |
-
</head><body></body></html>''')
|
| 679 |
-
|
| 680 |
-
@app.get('/s')
|
| 681 |
-
async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
| 682 |
-
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
| 683 |
-
safe_img = _clean(img) if img else ''
|
| 684 |
-
safe_url = _clean(url) if url else '/'
|
| 685 |
-
|
| 686 |
-
# Try to find wall post by post_id or URL (prioritize posts with slides/video)
|
| 687 |
-
post = None
|
| 688 |
-
try:
|
| 689 |
-
posts = _load_wall_posts()
|
| 690 |
-
if post_id:
|
| 691 |
-
for p in posts:
|
| 692 |
-
if p.get('id') == post_id:
|
| 693 |
-
post = p
|
| 694 |
-
safe_title = p.get('title', safe_title) or safe_title
|
| 695 |
-
safe_img = p.get('img', safe_img) or safe_img
|
| 696 |
-
safe_url = p.get('url', safe_url) or safe_url
|
| 697 |
-
break
|
| 698 |
-
elif url:
|
| 699 |
-
# Find matching URL - prioritize posts with slides or video
|
| 700 |
-
for p in posts:
|
| 701 |
-
if p.get('url') == url and p.get('slides'):
|
| 702 |
-
post = p
|
| 703 |
-
safe_title = p.get('title', safe_title) or safe_title
|
| 704 |
-
safe_img = p.get('img', safe_img) or safe_img
|
| 705 |
-
safe_url = p.get('url', safe_url) or safe_url
|
| 706 |
-
break
|
| 707 |
-
if not post:
|
| 708 |
-
# Fallback: find any matching URL
|
| 709 |
-
for p in posts:
|
| 710 |
-
if p.get('url') == url:
|
| 711 |
-
post = p
|
| 712 |
-
safe_title = p.get('title', safe_title) or safe_title
|
| 713 |
-
safe_img = p.get('img', safe_img) or safe_img
|
| 714 |
-
safe_url = p.get('url', safe_url) or safe_url
|
| 715 |
-
break
|
| 716 |
-
except:
|
| 717 |
-
pass
|
| 718 |
-
|
| 719 |
-
if post and post.get('slides'):
|
| 720 |
-
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 721 |
-
|
| 722 |
-
if post and post.get('video'):
|
| 723 |
-
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 724 |
-
|
| 725 |
-
# Fallback: redirect to original URL
|
| 726 |
-
return HTMLResponse(f'''<!DOCTYPE html>
|
| 727 |
-
<html lang="vi">
|
| 728 |
-
<head>
|
| 729 |
-
<meta charset="utf-8">
|
| 730 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 731 |
-
<title>{safe_title}</title>
|
| 732 |
-
<meta property="og:title" content="{safe_title}">
|
| 733 |
-
<meta property="og:image" content="{safe_img}">
|
| 734 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 735 |
-
<meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
|
| 736 |
-
<link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
|
| 737 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 738 |
-
</head><body></body></html>''')
|
| 739 |
-
|
| 740 |
-
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
|
| 741 |
-
|
| 742 |
-
_xlb_cache = {}
|
| 743 |
-
_xlb_lock = threading.Lock()
|
| 744 |
-
|
| 745 |
-
def _xlb_scrape(path):
|
| 746 |
-
url = f"https://xemlaibongda.top/{path}"
|
| 747 |
-
r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
|
| 748 |
-
if r.status_code != 200:
|
| 749 |
-
return []
|
| 750 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 751 |
-
vids = []
|
| 752 |
-
seen = set()
|
| 753 |
-
for a in soup.select('a[href*="/video/"]'):
|
| 754 |
-
href = a.get('href', '')
|
| 755 |
-
if not href or href in seen:
|
| 756 |
-
continue
|
| 757 |
-
seen.add(href)
|
| 758 |
-
if not href.startswith('http'):
|
| 759 |
-
href = 'https://xemlaibongda.top' + href
|
| 760 |
-
img = a.select_one('img')
|
| 761 |
-
p = a.parent
|
| 762 |
-
for _ in range(4):
|
| 763 |
-
if img:
|
| 764 |
-
break
|
| 765 |
-
if p:
|
| 766 |
-
img = p.select_one('img')
|
| 767 |
-
p = p.parent
|
| 768 |
-
img_src = ''
|
| 769 |
-
if img:
|
| 770 |
-
img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
|
| 771 |
-
if img_src.startswith('//'):
|
| 772 |
-
img_src = 'https:' + img_src
|
| 773 |
-
elif img_src.startswith('/'):
|
| 774 |
-
img_src = 'https://xemlaibongda.top' + img_src
|
| 775 |
-
title = ''
|
| 776 |
-
for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
|
| 777 |
-
t = a.select_one(sel)
|
| 778 |
-
if t:
|
| 779 |
-
title = _clean(t.get_text())
|
| 780 |
-
break
|
| 781 |
-
if not title:
|
| 782 |
-
title = _clean(a.get('title',''))
|
| 783 |
-
if not title:
|
| 784 |
-
img_alt = a.select_one('img')
|
| 785 |
-
if img_alt:
|
| 786 |
-
title = _clean(img_alt.get('alt',''))
|
| 787 |
-
if not title:
|
| 788 |
-
parent = a.parent
|
| 789 |
-
if parent:
|
| 790 |
-
pt = _clean(parent.get_text(' ',strip=True))
|
| 791 |
-
if 5 < len(pt) < 120:
|
| 792 |
-
title = pt
|
| 793 |
-
if not title or len(title) < 3:
|
| 794 |
-
continue
|
| 795 |
-
vids.append({"link": href, "img": img_src, "title": title})
|
| 796 |
-
if len(vids) >= 30:
|
| 797 |
-
break
|
| 798 |
-
return vids
|
| 799 |
-
|
| 800 |
-
@app.get('/api/proxy/xlb')
|
| 801 |
-
def proxy_xlb(path: str = Query(default="")):
|
| 802 |
-
now = time.time()
|
| 803 |
-
cache_key = f"xlb:{path}"
|
| 804 |
-
with _xlb_lock:
|
| 805 |
-
cached = _xlb_cache.get(cache_key)
|
| 806 |
-
if cached and now - cached['t'] < 120:
|
| 807 |
-
return JSONResponse(cached['d'])
|
| 808 |
-
try:
|
| 809 |
-
vids = _xlb_scrape(path)
|
| 810 |
-
result = {"videos": vids, "count": len(vids)}
|
| 811 |
-
with _xlb_lock:
|
| 812 |
-
_xlb_cache[cache_key] = {'t': now, 'd': result}
|
| 813 |
-
return JSONResponse(result)
|
| 814 |
-
except Exception as e:
|
| 815 |
-
return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
|
| 816 |
-
|
| 817 |
-
@app.get('/api/wc2026')
|
| 818 |
-
def _w():return JSONResponse(get_wc2026_all())
|
| 819 |
-
@app.get('/api/wc2026/fixtures')
|
| 820 |
-
def _wf():return JSONResponse(scrape_fixtures())
|
| 821 |
-
@app.get('/api/wc2026/standings')
|
| 822 |
-
def _ws():return JSONResponse(scrape_standings())
|
| 823 |
-
@app.get('/api/wc2026/stats')
|
| 824 |
-
def _wst():return JSONResponse(scrape_stats())
|
| 825 |
-
@app.get('/api/wc2026/history')
|
| 826 |
-
def _whi():return JSONResponse(scrape_history())
|
| 827 |
-
@app.get('/api/wc2026/news')
|
| 828 |
-
def _wn():return JSONResponse(scrape_wc_news())
|
| 829 |
-
@app.get('/api/wc2026/road')
|
| 830 |
-
def _wr():return JSONResponse(scrape_road_to_wc())
|
| 831 |
-
@app.get('/api/wc2026/h2h/{eid}')
|
| 832 |
-
def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
|
| 833 |
-
@app.get('/api/wc2026/lineups/{eid}')
|
| 834 |
-
def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
|
| 835 |
-
@app.get('/api/wc2026/match/{eid}')
|
| 836 |
-
def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
|
| 837 |
-
|
| 838 |
-
DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
|
| 839 |
-
os.makedirs(DATA_DIR,exist_ok=True)
|
| 840 |
-
IF=os.path.join(DATA_DIR,'interactions_v2.json')
|
| 841 |
-
CF=os.path.join(DATA_DIR,'comments_v2.json')
|
| 842 |
-
WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
|
| 843 |
-
WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
|
| 844 |
-
os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
|
| 845 |
-
|
| 846 |
-
_il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
|
| 847 |
-
def _lj(p):
|
| 848 |
-
try:
|
| 849 |
-
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
| 850 |
-
except:pass
|
| 851 |
-
return{}
|
| 852 |
-
def _sj(p,d):
|
| 853 |
-
try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
|
| 854 |
-
except:pass
|
| 855 |
-
|
| 856 |
-
@app.post('/api/v2/interact')
|
| 857 |
-
async def _int(request:Request):
|
| 858 |
-
b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
|
| 859 |
-
if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
|
| 860 |
-
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])
|
| 861 |
-
|
| 862 |
-
@app.get('/api/v2/interactions')
|
| 863 |
-
def _gi(id:str=Query(...)):
|
| 864 |
-
with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
|
| 865 |
-
|
| 866 |
-
@app.get('/api/v2/comments')
|
| 867 |
-
def _gc(id:str=Query(...)):
|
| 868 |
-
with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
|
| 869 |
-
|
| 870 |
-
@app.post('/api/v2/comment')
|
| 871 |
-
async def _pc(request:Request):
|
| 872 |
-
b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
|
| 873 |
-
if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
|
| 874 |
-
c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 875 |
-
with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
|
| 876 |
-
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 877 |
-
return JSONResponse({'comments':cms})
|
| 878 |
-
|
| 879 |
-
def _load_wall_posts():
|
| 880 |
-
with _wl_lock:
|
| 881 |
-
return _lj(WALL_FILE)
|
| 882 |
-
|
| 883 |
-
def _save_wall_posts(posts):
|
| 884 |
-
with _wl_lock:
|
| 885 |
-
_sj(WALL_FILE, posts)
|
| 886 |
-
|
| 887 |
-
@app.get('/api/wall')
|
| 888 |
-
def api_wall():
|
| 889 |
-
posts = _load_wall_posts()
|
| 890 |
-
if not posts:
|
| 891 |
-
return JSONResponse({"posts": []})
|
| 892 |
-
return JSONResponse({"posts": posts})
|
| 893 |
-
|
| 894 |
-
@app.post('/api/wall')
|
| 895 |
-
async def api_wall_post(request: Request):
|
| 896 |
-
content_type = request.headers.get('content-type', '')
|
| 897 |
-
if 'multipart/form-data' in content_type:
|
| 898 |
-
try:
|
| 899 |
-
form = await request.form()
|
| 900 |
-
except Exception as e:
|
| 901 |
-
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 902 |
-
title = form.get('title', 'Video mới') or 'Video mới'
|
| 903 |
-
text = form.get('text', '') or ''
|
| 904 |
-
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 905 |
-
video_file = form.get('video')
|
| 906 |
-
post_id = str(uuid.uuid4())[:12]
|
| 907 |
-
video_url = None
|
| 908 |
-
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 909 |
-
fname = video_file.filename.lower()
|
| 910 |
-
if fname.endswith('.mp4'):
|
| 911 |
-
ext = '.mp4'
|
| 912 |
-
elif fname.endswith('.webm'):
|
| 913 |
-
ext = '.webm'
|
| 914 |
-
else:
|
| 915 |
-
ext = '.webm'
|
| 916 |
-
video_filename = f"wall_{post_id}{ext}"
|
| 917 |
-
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 918 |
-
try:
|
| 919 |
-
content = await video_file.read()
|
| 920 |
-
if not content:
|
| 921 |
-
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 922 |
-
with open(video_path, 'wb') as f:
|
| 923 |
-
f.write(content)
|
| 924 |
-
file_size_mb = len(content) / 1024 / 1024
|
| 925 |
-
if file_size_mb > 50:
|
| 926 |
-
os.remove(video_path)
|
| 927 |
-
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 928 |
-
video_url = f"/api/wall/video/{video_filename}"
|
| 929 |
-
except Exception as e:
|
| 930 |
-
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 931 |
-
post = {
|
| 932 |
-
"id": post_id,
|
| 933 |
-
"title": title[:200],
|
| 934 |
-
"text": text[:2000],
|
| 935 |
-
"source": source,
|
| 936 |
-
"video": video_url,
|
| 937 |
-
"img": None,
|
| 938 |
-
"images": [],
|
| 939 |
-
"created": int(time.time()),
|
| 940 |
-
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 941 |
-
}
|
| 942 |
-
posts = _load_wall_posts()
|
| 943 |
-
if not isinstance(posts, list):
|
| 944 |
-
posts = []
|
| 945 |
-
posts.insert(0, post)
|
| 946 |
-
posts = posts[:200]
|
| 947 |
-
_save_wall_posts(posts)
|
| 948 |
-
return JSONResponse({"post": post, "ok": True})
|
| 949 |
-
try:
|
| 950 |
-
body = await request.json()
|
| 951 |
-
except:
|
| 952 |
-
body = {}
|
| 953 |
-
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 954 |
-
text = body.get('text', '') or ''
|
| 955 |
-
img = body.get('img', None)
|
| 956 |
-
source = body.get('source', 'user') or 'user'
|
| 957 |
-
post_id = str(uuid.uuid4())[:12]
|
| 958 |
-
post = {
|
| 959 |
-
"id": post_id,
|
| 960 |
-
"title": title[:200],
|
| 961 |
-
"text": text[:2000],
|
| 962 |
-
"source": source,
|
| 963 |
-
"video": None,
|
| 964 |
-
"img": img,
|
| 965 |
-
"images": [],
|
| 966 |
-
"created": int(time.time()),
|
| 967 |
-
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 968 |
-
}
|
| 969 |
-
posts = _load_wall_posts()
|
| 970 |
-
if not isinstance(posts, list):
|
| 971 |
-
posts = []
|
| 972 |
-
posts.insert(0, post)
|
| 973 |
-
posts = posts[:200]
|
| 974 |
-
_save_wall_posts(posts)
|
| 975 |
-
return JSONResponse({"post": post, "ok": True})
|
| 976 |
-
|
| 977 |
-
@app.get('/api/wall/video/{filename}')
|
| 978 |
-
def api_wall_video(filename: str):
|
| 979 |
-
if '..' in filename or '/' in filename:
|
| 980 |
-
return Response(status_code=403)
|
| 981 |
-
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
| 982 |
-
if not os.path.exists(video_path):
|
| 983 |
-
return Response(status_code=404)
|
| 984 |
-
ext = os.path.splitext(filename)[1].lower()
|
| 985 |
-
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 986 |
-
return FileResponse(video_path, media_type=media_type)
|
| 987 |
-
|
| 988 |
-
@app.delete('/api/wall/{post_id}')
|
| 989 |
-
def api_wall_delete(post_id: str):
|
| 990 |
-
posts = _load_wall_posts()
|
| 991 |
-
if not isinstance(posts, list):
|
| 992 |
-
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 993 |
-
for i, p in enumerate(posts):
|
| 994 |
-
if p.get('id') == post_id:
|
| 995 |
-
if p.get('video'):
|
| 996 |
-
video_name = p['video'].split('/')[-1]
|
| 997 |
-
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
| 998 |
-
if os.path.exists(video_path):
|
| 999 |
-
os.remove(video_path)
|
| 1000 |
-
posts.pop(i)
|
| 1001 |
-
_save_wall_posts(posts)
|
| 1002 |
-
return JSONResponse({"ok": True})
|
| 1003 |
-
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 1004 |
-
|
| 1005 |
-
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 1006 |
-
import random as _random2
|
| 1007 |
-
from urllib.parse import quote as _quote2
|
| 1008 |
-
|
| 1009 |
-
_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 1010 |
-
|
| 1011 |
-
# Unique character markers for language detection
|
| 1012 |
-
_UNIQUE_CHARS = {
|
| 1013 |
-
'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
|
| 1014 |
-
'spanish': set('ñáéíóúü¿¡'),
|
| 1015 |
-
'portuguese': set('ãõçáéíóúâêôà'),
|
| 1016 |
-
}
|
| 1017 |
-
|
| 1018 |
-
_STOPWORDS = {
|
| 1019 |
-
'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'},
|
| 1020 |
-
'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'},
|
| 1021 |
-
'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'},
|
| 1022 |
-
'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'},
|
| 1023 |
-
}
|
| 1024 |
-
|
| 1025 |
-
def detect_language(text):
|
| 1026 |
-
"""Detect language from text content using stopword + character analysis."""
|
| 1027 |
-
if not text:
|
| 1028 |
-
return 'vietnamese'
|
| 1029 |
-
text_lower = text.lower()
|
| 1030 |
-
text_chars = set(text_lower)
|
| 1031 |
-
|
| 1032 |
-
# Strong signal: Vietnamese unique characters
|
| 1033 |
-
vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
|
| 1034 |
-
if vn_chars >= 2:
|
| 1035 |
-
return 'vietnamese'
|
| 1036 |
-
|
| 1037 |
-
# Spanish unique chars (ñ, ¿, ¡)
|
| 1038 |
-
es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
|
| 1039 |
-
pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
|
| 1040 |
-
|
| 1041 |
-
# Stopword scoring
|
| 1042 |
-
words = set(re.findall(r'\b\w+\b', text_lower))
|
| 1043 |
-
scores = {}
|
| 1044 |
-
for lang, stops in _STOPWORDS.items():
|
| 1045 |
-
scores[lang] = len(words & stops) / max(len(stops), 1)
|
| 1046 |
-
|
| 1047 |
-
# Disambiguate Portuguese vs Spanish
|
| 1048 |
-
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'}
|
| 1049 |
-
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'}
|
| 1050 |
-
|
| 1051 |
-
pt_overlap = len(words & pt_markers)
|
| 1052 |
-
es_overlap = len(words & es_markers)
|
| 1053 |
-
|
| 1054 |
-
if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
|
| 1055 |
-
return 'portuguese'
|
| 1056 |
-
if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
|
| 1057 |
-
return 'spanish'
|
| 1058 |
-
if scores.get('english', 0) > 0.15:
|
| 1059 |
-
return 'english'
|
| 1060 |
-
|
| 1061 |
-
best = max(scores, key=scores.get)
|
| 1062 |
-
return best if scores[best] > 0.05 else 'vietnamese'
|
| 1063 |
-
|
| 1064 |
-
# Emotion keyword-based detection
|
| 1065 |
-
_EMOTION_KEYWORDS = {
|
| 1066 |
-
'happy': {
|
| 1067 |
-
'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
|
| 1068 |
-
'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
|
| 1069 |
-
'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
|
| 1070 |
-
'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'],
|
| 1071 |
-
},
|
| 1072 |
-
'sad': {
|
| 1073 |
-
'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
|
| 1074 |
-
'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
|
| 1075 |
-
'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
|
| 1076 |
-
'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'],
|
| 1077 |
-
},
|
| 1078 |
-
'excited': {
|
| 1079 |
-
'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
|
| 1080 |
-
'pt': ['animado', 'emocionante', 'incrível', 'uau', 'inacreditável', 'empolgante', 'elétrico', 'recorde'],
|
| 1081 |
-
'es': ['emocionado', 'emocionante', 'increíble', 'guau', 'increíble', 'impresionante', 'electrizante', 'récord'],
|
| 1082 |
-
'vi': ['phấn khích', 'hồi hộp', 'không thể tin', 'tuyệt vời', 'ấn tượng', 'kỷ lục', 'đột phá', 'sốc'],
|
| 1083 |
-
},
|
| 1084 |
-
'angry': {
|
| 1085 |
-
'en': ['angry', 'furious', 'outraged', 'frustrated', 'annoyed', 'irritated', 'mad', 'rage', 'fury', 'upset', 'hostile', 'aggressive'],
|
| 1086 |
-
'pt': ['bravo', 'furioso', 'indignado', 'frustrado', 'irritado', 'raiva', 'fúria', 'chateado', 'hostil', 'agressivo'],
|
| 1087 |
-
'es': ['enojado', 'furioso', 'indignado', 'frustrado', 'molesto', 'irritado', 'rabia', 'furia', 'enojado', 'hostil', 'agresivo'],
|
| 1088 |
-
'vi': ['tức giận', 'phẫn nộ', 'bức xúc', 'bực mình', 'khó chịu', 'cáu', 'giận dữ', 'thịnh nộ', 'thù địch'],
|
| 1089 |
-
},
|
| 1090 |
-
'neutral': {
|
| 1091 |
-
'en': ['say', 'said', 'according', 'report', 'announce', 'statement', 'information', 'update', 'news', 'today', 'government', 'official'],
|
| 1092 |
-
'pt': ['dizer', 'disse', 'segundo', 'relatório', 'anunciar', 'declaração', 'informação', 'notícia', 'hoje', 'governo', 'oficial'],
|
| 1093 |
-
'es': ['decir', 'dijo', 'según', 'informe', 'anunciar', 'declaración', 'información', 'noticia', 'hoy', 'gobierno', 'oficial'],
|
| 1094 |
-
'vi': ['nói', 'cho biết', 'theo', 'báo cáo', 'thông báo', 'tuyên bố', 'thông tin', 'cập nhật', 'tin tức', 'hôm nay', 'chính phủ', 'quan chức'],
|
| 1095 |
-
},
|
| 1096 |
-
}
|
| 1097 |
-
|
| 1098 |
-
def detect_emotion(text, language='vietnamese'):
|
| 1099 |
-
"""Detect emotion from text using keyword matching."""
|
| 1100 |
-
if not text:
|
| 1101 |
-
return 'neutral'
|
| 1102 |
-
text_lower = text.lower()
|
| 1103 |
-
words = set(re.findall(r'\b\w+\b', text_lower))
|
| 1104 |
-
lang = language if language in ['en', 'pt', 'es', 'vi'] else 'vi'
|
| 1105 |
-
lang_map = {'en': 'english', 'pt': 'portuguese', 'es': 'spanish', 'vi': 'vietnamese'}
|
| 1106 |
-
lang_key = lang_map.get(lang, 'vietnamese')
|
| 1107 |
-
|
| 1108 |
-
scores = {}
|
| 1109 |
-
for emotion, lang_dict in _EMOTION_KEYWORDS.items():
|
| 1110 |
-
keywords = lang_dict.get(lang_key, lang_dict.get('en', []))
|
| 1111 |
-
scores[emotion] = len(words & set(kw.lower() for kw in keywords))
|
| 1112 |
-
|
| 1113 |
-
# Also check all languages for overlap
|
| 1114 |
-
for emotion, lang_dict in _EMOTION_KEYWORDS.items():
|
| 1115 |
-
for lang, keywords in lang_dict.items():
|
| 1116 |
-
if lang != lang_key:
|
| 1117 |
-
scores[emotion] = scores.get(emotion, 0) + len(words & set(kw.lower() for kw in keywords)) * 0.5
|
| 1118 |
-
|
| 1119 |
-
best = max(scores, key=scores.get)
|
| 1120 |
-
return best if scores[best] > 0 else 'neutral'
|
| 1121 |
-
|
| 1122 |
-
# ===== REWRITE ENDPOINT =====
|
| 1123 |
-
_rewrite_cache = {}
|
| 1124 |
-
_rewrite_cache_lock = threading.Lock()
|
| 1125 |
-
|
| 1126 |
-
def _rewrite_article(url, lang='vi', emotion='neutral', style='standard'):
|
| 1127 |
-
"""Rewrite article using HF Inference API."""
|
| 1128 |
-
from urllib.parse import unquote
|
| 1129 |
-
cache_key = f"{url}_{lang}_{emotion}_{style}"
|
| 1130 |
-
with _rewrite_cache_lock:
|
| 1131 |
-
cached = _rewrite_cache.get(cache_key)
|
| 1132 |
-
if cached and time.time() - cached['t'] < 3600:
|
| 1133 |
-
return cached['d']
|
| 1134 |
-
|
| 1135 |
-
article = _scrape_article_fast(unquote(url))
|
| 1136 |
-
if not article or not article.get('body'):
|
| 1137 |
-
return {'error': 'Không đọc được bài viết'}
|
| 1138 |
-
|
| 1139 |
-
title = article.get('title', '')
|
| 1140 |
-
summary = article.get('summary', '')
|
| 1141 |
-
text_parts = [p.get('text', '') for p in article.get('body', []) if p.get('type') == 'p']
|
| 1142 |
-
full_text = ' '.join(text_parts[:15])
|
| 1143 |
-
|
| 1144 |
-
if not full_text:
|
| 1145 |
-
return {'error': 'Không có nội dung để rewrite'}
|
| 1146 |
-
|
| 1147 |
-
# Build prompt
|
| 1148 |
-
style_guides = {
|
| 1149 |
-
'standard': 'Viết lại bằng tiếng Việt, giữ nguyên thông tin chính, dễ đọc.',
|
| 1150 |
-
'simple': 'Viết lại bằng tiếng Việt đơn giản, dễ hiểu, phù hợp với mọi lứa tuổi.',
|
| 1151 |
-
'detailed': 'Viết lại bằng tiếng Việt chi tiết, phân tích sâu, có dẫn chứng.',
|
| 1152 |
-
'short': 'Viết lại bằng tiếng Việt ngắn gọn, chỉ giữ ý chính, tối đa 3 câu.',
|
| 1153 |
-
}
|
| 1154 |
-
style_guide = style_guides.get(style, style_guides['standard'])
|
| 1155 |
-
|
| 1156 |
-
emotion_guide = {
|
| 1157 |
-
'neutral': '',
|
| 1158 |
-
'happy': 'Thể hiện giọng điệu tích cực, lạc quan.',
|
| 1159 |
-
'sad': 'Thể hiện giọng điệu đồng cảm, nhẹ nhàng.',
|
| 1160 |
-
'excited': 'Thể hiện giọng điệu hào hứng, sôi nổi.',
|
| 1161 |
-
'angry': 'Thể hiện giọng điệu mạnh mẽ, phản biện.',
|
| 1162 |
-
}.get(emotion, '')
|
| 1163 |
-
|
| 1164 |
-
prompt = f"""Viết lại bài báo sau bằng tiếng Việt.
|
| 1165 |
-
|
| 1166 |
-
Yêu cầu:
|
| 1167 |
-
- {style_guide}
|
| 1168 |
-
- {emotion_guide}
|
| 1169 |
-
- Giữ lại các sự kiện, số liệu, tên riêng quan trọng
|
| 1170 |
-
- Tối đa 500 từ
|
| 1171 |
-
- Chia thành các đoạn ngắn, dễ đọc
|
| 1172 |
-
|
| 1173 |
-
Tiêu đề: {title}
|
| 1174 |
-
|
| 1175 |
-
Tóm tắt: {summary}
|
| 1176 |
-
|
| 1177 |
-
Nội dung: {full_text[:3000]}
|
| 1178 |
-
|
| 1179 |
-
Bài viết sau khi rewrite:"""
|
| 1180 |
-
|
| 1181 |
-
try:
|
| 1182 |
-
from huggingface_hub import InferenceClient
|
| 1183 |
-
client = InferenceClient(model="Qwen/Qwen2.5-72B-Instruct")
|
| 1184 |
-
response = client.text_generation(prompt, max_new_tokens=800, temperature=0.7, do_sample=True)
|
| 1185 |
-
result = response.strip()
|
| 1186 |
-
with _rewrite_cache_lock:
|
| 1187 |
-
_rewrite_cache[cache_key] = {'d': result, 't': time.time()}
|
| 1188 |
-
return {'rewritten': result, 'title': title, 'url': url}
|
| 1189 |
-
except Exception as e:
|
| 1190 |
-
# Fallback: try smaller model
|
| 1191 |
-
try:
|
| 1192 |
-
from huggingface_hub import InferenceClient
|
| 1193 |
-
client = InferenceClient(model="Qwen/Qwen2.5-32B-Instruct")
|
| 1194 |
-
response = client.text_generation(prompt, max_new_tokens=600, temperature=0.7, do_sample=True)
|
| 1195 |
-
result = response.strip()
|
| 1196 |
-
with _rewrite_cache_lock:
|
| 1197 |
-
_rewrite_cache[cache_key] = {'d': result, 't': time.time()}
|
| 1198 |
-
return {'rewritten': result, 'title': title, 'url': url}
|
| 1199 |
-
except Exception as e2:
|
| 1200 |
-
return {'error': f'Lỗi AI: {str(e2)[:100]}'}
|
| 1201 |
-
|
| 1202 |
-
@app.get('/api/rewrite')
|
| 1203 |
-
def api_rewrite(url: str = Query(...), lang: str = Query(default='vi'), emotion: str = Query(default='neutral'), style: str = Query(default='standard')):
|
| 1204 |
-
try:
|
| 1205 |
-
result = _rewrite_article(url, lang, emotion, style)
|
| 1206 |
-
return JSONResponse(result)
|
| 1207 |
-
except Exception as e:
|
| 1208 |
-
return JSONResponse({'error': f'Lỗi rewrite: {str(e)[:100]}'})
|
| 1209 |
-
|
| 1210 |
-
# === TTS Multi-language endpoint ===
|
| 1211 |
-
_tts_cache = {}
|
| 1212 |
-
_tts_cache_lock = threading.Lock()
|
| 1213 |
-
|
| 1214 |
-
@app.get('/api/tts')
|
| 1215 |
-
def api_tts(text: str = Query(...), lang: str = Query(default='vi')):
|
| 1216 |
-
"""Generate TTS audio using edge-tts."""
|
| 1217 |
-
import subprocess, tempfile
|
| 1218 |
-
cache_key = f"{text[:100]}_{lang}"
|
| 1219 |
-
with _tts_cache_lock:
|
| 1220 |
-
cached = _tts_cache.get(cache_key)
|
| 1221 |
-
if cached and time.time() - cached['t'] < 3600:
|
| 1222 |
-
return FileResponse(cached['p'], media_type='audio/mpeg')
|
| 1223 |
-
|
| 1224 |
-
voice_map = {
|
| 1225 |
-
'vi': 'vi-VN-HoaiMyNeural',
|
| 1226 |
-
'en': 'en-US-JennyNeural',
|
| 1227 |
-
'pt': 'pt-BR-FranciscaNeural',
|
| 1228 |
-
'es': 'es-ES-ElviraNeural',
|
| 1229 |
-
}
|
| 1230 |
-
voice = voice_map.get(lang, 'vi-VN-HoaiMyNeural')
|
| 1231 |
-
|
| 1232 |
-
try:
|
| 1233 |
-
with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
|
| 1234 |
-
output_path = f.name
|
| 1235 |
-
cmd = ['edge-tts', '--voice', voice, '--text', text[:500], '--write-media', output_path]
|
| 1236 |
-
subprocess.run(cmd, timeout=30, check=True)
|
| 1237 |
-
with _tts_cache_lock:
|
| 1238 |
-
_tts_cache[cache_key] = {'p': output_path, 't': time.time()}
|
| 1239 |
-
return FileResponse(output_path, media_type='audio/mpeg')
|
| 1240 |
-
except Exception as e:
|
| 1241 |
-
return JSONResponse({'error': f'TTS error: {str(e)[:100]'}, status_code=500)
|
| 1242 |
-
|
| 1243 |
-
# === SHORT VIDEO GENERATION ===
|
| 1244 |
-
@app.get('/api/short-video')
|
| 1245 |
-
def api_short_video(url: str = Query(...), lang: str = Query(default='vi')):
|
| 1246 |
-
"""Generate short video from article using TTS + images."""
|
| 1247 |
-
from urllib.parse import unquote
|
| 1248 |
-
import subprocess, tempfile, os
|
| 1249 |
-
|
| 1250 |
-
article = _scrape_article_fast(unquote(url))
|
| 1251 |
-
if not article or not article.get('body'):
|
| 1252 |
-
return JSONResponse({'error': 'Không đọc được bài viết'})
|
| 1253 |
-
|
| 1254 |
-
title = article.get('title', '')
|
| 1255 |
-
text_parts = [p.get('text', '') for p in article.get('body', []) if p.get('type') == 'p']
|
| 1256 |
-
images = [p.get('src', '') for p in article.get('body', []) if p.get('type') == 'img']
|
| 1257 |
-
|
| 1258 |
-
if not text_parts:
|
| 1259 |
-
return JSONResponse({'error': 'Không có nội dung'})
|
| 1260 |
-
|
| 1261 |
-
# Generate TTS
|
| 1262 |
-
tts_text = f"{title}. {' '.join(text_parts[:5])}"
|
| 1263 |
-
voice_map = {'vi': 'vi-VN-HoaiMyNeural', 'en': 'en-US-JennyNeural'}
|
| 1264 |
-
voice = voice_map.get(lang, 'vi-VN-HoaiMyNeural')
|
| 1265 |
-
|
| 1266 |
-
try:
|
| 1267 |
-
with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
|
| 1268 |
-
audio_path = f.name
|
| 1269 |
-
subprocess.run(['edge-tts', '--voice', voice, '--text', tts_text[:500], '--write-media', audio_path], timeout=30, check=True)
|
| 1270 |
-
|
| 1271 |
-
# Generate video with images
|
| 1272 |
-
import glob
|
| 1273 |
-
video_id = str(uuid.uuid4())[:8]
|
| 1274 |
-
output_path = os.path.join(WALL_VIDEO_DIR, f"short_{video_id}.mp4")
|
| 1275 |
-
|
| 1276 |
-
# Use ffmpeg to create video from audio + image
|
| 1277 |
-
if images:
|
| 1278 |
-
img_path = images[0]
|
| 1279 |
-
# Download image
|
| 1280 |
-
try:
|
| 1281 |
-
img_resp = req.get(img_path, timeout=10)
|
| 1282 |
-
if img_resp.status_code == 200:
|
| 1283 |
-
img_local = os.path.join(WALL_VIDEO_DIR, f"img_{video_id}.jpg")
|
| 1284 |
-
with open(img_local, 'wb') as f:
|
| 1285 |
-
f.write(img_resp.content)
|
| 1286 |
-
cmd = ['ffmpeg', '-y', '-loop', '1', '-i', img_local, '-i', audio_path, '-c:v', 'libx264', '-tune', 'stillimage', '-c:a', 'aac', '-b:a', '192k', '-pix_fmt', 'yuv420p', '-shortest', '-vf', 'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1', output_path]
|
| 1287 |
-
subprocess.run(cmd, timeout=30, check=True)
|
| 1288 |
-
os.unlink(img_local)
|
| 1289 |
-
os.unlink(audio_path)
|
| 1290 |
-
return JSONResponse({'video': f'/api/wall/video/short_{video_id}.mp4', 'title': title})
|
| 1291 |
-
except:
|
| 1292 |
-
pass
|
| 1293 |
-
|
| 1294 |
-
# Fallback: audio-only video with black background
|
| 1295 |
-
cmd = ['ffmpeg', '-y', '-f', 'lavfi', '-i', 'color=c=black:s=1080x1920:d=10', '-i', audio_path, '-c:v', 'libx264', '-tune', 'stillimage', '-c:a', 'aac', '-b:a', '192k', '-pix_fmt', 'yuv420p', '-shortest', output_path]
|
| 1296 |
-
subprocess.run(cmd, timeout=30, check=True)
|
| 1297 |
-
os.unlink(audio_path)
|
| 1298 |
-
return JSONResponse({'video': f'/api/wall/video/short_{video_id}.mp4', 'title': title})
|
| 1299 |
-
except Exception as e:
|
| 1300 |
-
return JSONResponse({'error': f'Video error: {str(e)[:100]'}, status_code=500)
|
| 1301 |
-
|
| 1302 |
-
# ===== IMAGE PROXY =====
|
| 1303 |
-
_img_cache = {}
|
| 1304 |
-
_img_cache_lock = threading.Lock()
|
| 1305 |
-
|
| 1306 |
-
@app.get('/api/proxy/img')
|
| 1307 |
-
def proxy_img(url: str = Query(...)):
|
| 1308 |
-
from urllib.parse import unquote
|
| 1309 |
-
img_url = unquote(url)
|
| 1310 |
-
with _img_cache_lock:
|
| 1311 |
-
cached = _img_cache.get(img_url)
|
| 1312 |
-
if cached and time.time() - cached['t'] < 3600:
|
| 1313 |
-
return Response(content=cached['d'], media_type=cached.get('m', 'image/jpeg'))
|
| 1314 |
-
|
| 1315 |
-
try:
|
| 1316 |
-
r = req.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
|
| 1317 |
-
if r.status_code == 200:
|
| 1318 |
-
content = r.content
|
| 1319 |
-
content_type = r.headers.get('content-type', 'image/jpeg')
|
| 1320 |
-
with _img_cache_lock:
|
| 1321 |
-
_img_cache[img_url] = {'d': content, 'm': content_type, 't': time.time()}
|
| 1322 |
-
return Response(content=content, media_type=content_type)
|
| 1323 |
-
except:
|
| 1324 |
-
pass
|
| 1325 |
-
return Response(status_code=404)
|
| 1326 |
-
|
| 1327 |
-
# === STATIC FILES ===
|
| 1328 |
-
if os.path.isdir(STATIC_DIR):
|
| 1329 |
-
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 1330 |
-
|
| 1331 |
-
print(f"[VNEWS] app_v2_entry loaded. DATA_DIR={DATA_DIR}, STATIC_DIR={STATIC_DIR}")
|
| 1332 |
-
print(f"[VNEWS] Persistent storage: {os.path.isdir('/data') and os.access('/data', os.W_OK)}")
|
|
|
|
| 1 |
+
return JSONResponse({'error': f'TTS error: {str(e)[:100]}'}, status_code=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|