Spaces:
Running
Running
Fix: rotating User-Agents, timeout 20s, Google News RSS fallback, source=google param, fix _UNIQUE_CHARS corruption, fix f-string syntax
Browse files- app_v2_entry.py +251 -210
app_v2_entry.py
CHANGED
|
@@ -23,7 +23,7 @@ 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
|
| 27 |
|
| 28 |
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 29 |
SPACE = "https://bep40-vnews.hf.space"
|
|
@@ -33,7 +33,6 @@ 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 |
def _get_match_detail(event_id, slug=None):
|
|
@@ -203,9 +202,9 @@ def api_match_detail(event_id: int, url: str = Query(default=None)):
|
|
| 203 |
return JSONResponse(err)
|
| 204 |
return JSONResponse({"event_id": event_id, "found": False})
|
| 205 |
|
| 206 |
-
_STOP=set('
|
| 207 |
|
| 208 |
-
# Rotating User-Agents
|
| 209 |
_SEARCH_UAS = [
|
| 210 |
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
| 211 |
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
|
@@ -221,117 +220,157 @@ def _search_headers():
|
|
| 221 |
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'}
|
| 222 |
|
| 223 |
def _has_kw(topic,title):
|
| 224 |
-
tl=topic.lower()
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
| 228 |
return any(w in tt for w in words)
|
| 229 |
|
| 230 |
def _s_vnexpress(topic,limit=8):
|
| 231 |
items=[]
|
| 232 |
try:
|
| 233 |
-
r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
|
|
|
| 234 |
for art in soup.select('article.item-news')[:limit]:
|
| 235 |
a=art.select_one('h2 a, h3 a')
|
| 236 |
if a and a.get('href'):
|
| 237 |
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 238 |
-
if _has_kw(topic,t):
|
| 239 |
-
|
|
|
|
|
|
|
| 240 |
return items
|
| 241 |
|
| 242 |
def _s_dantri(topic,limit=8):
|
| 243 |
items=[]
|
| 244 |
try:
|
| 245 |
-
r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers=_search_headers(),timeout=12)
|
|
|
|
| 246 |
for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
|
| 247 |
-
t=_clean(a.get_text(strip=True))
|
|
|
|
| 248 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 249 |
-
if not href.startswith('http'):
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
| 253 |
return items
|
| 254 |
|
| 255 |
def _s_vietnamnet(topic,limit=6):
|
| 256 |
items=[]
|
| 257 |
try:
|
| 258 |
-
r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
|
|
|
| 259 |
for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
|
| 260 |
-
t=_clean(a.get('title','') or a.get_text(strip=True))
|
|
|
|
| 261 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 262 |
-
if not href.startswith('http'):
|
|
|
|
| 263 |
items.append({'title':t,'url':href,'via':'VietNamNet'})
|
| 264 |
-
if len(items)>=limit:
|
| 265 |
-
|
|
|
|
|
|
|
| 266 |
return items
|
| 267 |
|
| 268 |
def _s_bongda(topic,limit=5):
|
| 269 |
items=[]
|
| 270 |
try:
|
| 271 |
-
r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
|
|
|
| 272 |
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 273 |
-
t=_clean(a.get_text(strip=True))
|
|
|
|
| 274 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 275 |
-
if not href.startswith('http'):
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
| 279 |
return items
|
| 280 |
|
| 281 |
def _s_genk(topic,limit=5):
|
| 282 |
items=[]
|
| 283 |
try:
|
| 284 |
-
r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
|
|
|
| 285 |
for a in soup.select('a[href$=".chn"]')[:limit*3]:
|
| 286 |
-
t=_clean(a.get('title','') or a.get_text(strip=True))
|
|
|
|
| 287 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 288 |
-
if href.startswith('/'):
|
|
|
|
| 289 |
items.append({'title':t,'url':href,'via':'GenK'})
|
| 290 |
-
if len(items)>=limit:
|
| 291 |
-
|
|
|
|
|
|
|
| 292 |
return items
|
| 293 |
|
| 294 |
def _s_thanhnien(topic,limit=6):
|
| 295 |
items=[]
|
| 296 |
try:
|
| 297 |
-
r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
|
|
|
| 298 |
for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
|
| 299 |
-
t=_clean(a.get('title','') or a.get_text(strip=True))
|
|
|
|
| 300 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 301 |
-
if not href.startswith('http'):
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
| 305 |
return items
|
| 306 |
|
| 307 |
def _s_tuoitre(topic,limit=6):
|
| 308 |
items=[]
|
| 309 |
try:
|
| 310 |
-
r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers=_search_headers(),timeout=12)
|
|
|
|
| 311 |
for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
|
| 312 |
-
t=_clean(a.get('title','') or a.get_text(strip=True))
|
|
|
|
| 313 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 314 |
-
if not href.startswith('http'):
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
| 318 |
return items
|
| 319 |
|
| 320 |
def _s_thethaovanhoa(topic,limit=5):
|
| 321 |
items=[]
|
| 322 |
try:
|
| 323 |
-
r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers=_search_headers(),timeout=12)
|
|
|
|
| 324 |
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 325 |
-
t=_clean(a.get('title','') or a.get_text(strip=True))
|
|
|
|
| 326 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 327 |
-
if not href.startswith('http'):
|
|
|
|
| 328 |
items.append({'title':t,'url':href,'via':'TT&VH'})
|
| 329 |
-
if len(items)>=limit:
|
| 330 |
-
|
|
|
|
|
|
|
| 331 |
return items
|
| 332 |
|
| 333 |
def _s_google_news(topic,limit=8):
|
| 334 |
-
"""Google News RSS fallback"""
|
| 335 |
items=[]
|
| 336 |
try:
|
| 337 |
r=req.get(f"https://news.google.com/rss/search?q={quote(topic)}+Viet+Nam&hl=vi&gl=VN&ceid=VN:vi",headers=_search_headers(),timeout=10)
|
|
@@ -342,8 +381,9 @@ def _s_google_news(topic,limit=8):
|
|
| 342 |
link=item.find('link').get_text() if item.find('link') else ''
|
| 343 |
source=item.find('source').get_text() if item.find('source') else 'Google News'
|
| 344 |
if title and link and len(title)>15:
|
| 345 |
-
items.append({'title':title,'url':link,'via':
|
| 346 |
-
except:
|
|
|
|
| 347 |
return items
|
| 348 |
|
| 349 |
def _search_all(topic,limit=36):
|
|
@@ -351,17 +391,24 @@ def _search_all(topic,limit=36):
|
|
| 351 |
with ThreadPoolExecutor(8) as ex:
|
| 352 |
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'}
|
| 353 |
for f in as_completed(futs,timeout=20):
|
| 354 |
-
try:
|
| 355 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
for i in range(max((len(s) for s in srcs),default=0)):
|
| 358 |
for s in srcs:
|
| 359 |
-
if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:
|
|
|
|
|
|
|
| 360 |
if not out:
|
| 361 |
try:
|
| 362 |
google_items=_s_google_news(topic,limit)
|
| 363 |
out.extend(google_items)
|
| 364 |
-
except:
|
|
|
|
| 365 |
return out[:limit]
|
| 366 |
|
| 367 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
|
@@ -461,7 +508,7 @@ def _scrape_article_fast(url):
|
|
| 461 |
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
| 462 |
if title:
|
| 463 |
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 464 |
-
'body': [{'type': 'p', 'text': '
|
| 465 |
'source': domain, 'url': url, 'fallback': True}
|
| 466 |
break
|
| 467 |
except Exception:
|
|
@@ -485,7 +532,7 @@ def api_article_v2(url: str = Query(...)):
|
|
| 485 |
resp = JSONResponse(data)
|
| 486 |
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 487 |
return resp
|
| 488 |
-
result = {'error': '
|
| 489 |
resp = JSONResponse(result)
|
| 490 |
resp.headers["Cache-Control"] = "public, max-age=60"
|
| 491 |
return resp
|
|
@@ -495,134 +542,133 @@ def api_article_v2(url: str = Query(...)):
|
|
| 495 |
_hot_cache={'t':0,'d':[]}
|
| 496 |
def _get_hot_topics():
|
| 497 |
now=time.time()
|
| 498 |
-
if _hot_cache['d'] and now-_hot_cache['t']<600:
|
| 499 |
-
|
|
|
|
|
|
|
| 500 |
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']
|
| 501 |
for feed_url in feeds:
|
| 502 |
try:
|
| 503 |
-
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6)
|
|
|
|
|
|
|
| 504 |
for item in soup.find_all('item')[:12]:
|
| 505 |
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 506 |
-
if not title:
|
| 507 |
-
|
| 508 |
-
|
|
|
|
|
|
|
|
|
|
| 509 |
for n in(3,4,2):
|
| 510 |
for i in range(max(0,len(words)-n+1)):
|
| 511 |
phrase=' '.join(words[i:i+n])
|
| 512 |
-
if 8<=len(phrase)<=45:
|
| 513 |
-
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
for key,count in ranked:
|
| 516 |
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)
|
| 517 |
-
if is_dup:
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
if len(topics)>=
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
@app.get('/api/hot_topics')
|
| 526 |
def api_hot_topics():
|
| 527 |
resp = JSONResponse({'topics':_get_hot_topics()})
|
| 528 |
resp.headers["Cache-Control"] = "public, max-age=120"
|
| 529 |
return resp
|
|
|
|
| 530 |
@app.get('/')
|
| 531 |
async def serve_index():
|
| 532 |
p=os.path.join(STATIC_DIR,'index_v2.html')
|
| 533 |
-
if os.path.exists(p):
|
|
|
|
| 534 |
return HTMLResponse('<h1>VNEWS</h1>')
|
|
|
|
| 535 |
@app.get('/api/hashtag/sources')
|
| 536 |
def _ht(topic:str=Query(...),page:int=Query(default=0),source:str=Query(default=None)):
|
| 537 |
if source == 'google':
|
| 538 |
items=_s_google_news(topic,36)
|
| 539 |
else:
|
| 540 |
items=_search_all(topic,36)
|
| 541 |
-
per_page=8
|
|
|
|
|
|
|
| 542 |
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
|
|
|
| 543 |
@app.get('/api/categories')
|
| 544 |
-
def _cat():
|
|
|
|
|
|
|
| 545 |
@app.get('/api/storage_status')
|
| 546 |
-
def _st():
|
|
|
|
| 547 |
|
| 548 |
def _render_slides_page(post, safe_title, safe_img, safe_url):
|
| 549 |
slides = post.get('slides', [])
|
| 550 |
if not safe_img and slides and slides[0].get('image'):
|
| 551 |
safe_img = slides[0].get('image', '')
|
| 552 |
-
description = _clean((post.get('text') or '')[:200]) or "Tin
|
| 553 |
if safe_url and safe_url != '/':
|
| 554 |
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 555 |
else:
|
| 556 |
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 557 |
-
h =
|
| 558 |
-
|
| 559 |
-
<
|
| 560 |
-
<meta
|
| 561 |
-
<meta
|
| 562 |
-
<
|
| 563 |
-
<
|
| 564 |
-
|
| 565 |
-
<meta property="og:description" content="{description}">
|
| 566 |
-
<meta property="og:url" content="{canonical_url}">
|
| 567 |
-
<link rel="canonical" href="{canonical_url}">
|
| 568 |
-
<style>
|
| 569 |
-
*{{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}}
|
| 570 |
-
.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}}
|
| 571 |
-
.slide-num{{color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px}}
|
| 572 |
-
.slide-img{{width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px}}
|
| 573 |
-
.slide-text{{color:#ddd;font-size:14px;line-height:1.6;margin:0}}
|
| 574 |
-
</style>
|
| 575 |
-
</head>
|
| 576 |
-
<body>'''
|
| 577 |
for s in slides:
|
| 578 |
img_src = s.get('image', '')
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
|
|
|
|
|
|
| 584 |
h += '</body></html>'
|
| 585 |
return HTMLResponse(h)
|
| 586 |
|
| 587 |
def _render_video_page(post, safe_title, safe_img, safe_url):
|
| 588 |
video_url = post.get('video', '')
|
| 589 |
-
description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
|
| 590 |
if safe_url and safe_url != '/':
|
| 591 |
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 592 |
else:
|
| 593 |
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 594 |
-
h =
|
| 595 |
-
|
| 596 |
-
<
|
| 597 |
-
<meta
|
| 598 |
-
<meta
|
| 599 |
-
<
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
<meta property="og:url" content="{canonical_url}">
|
| 604 |
-
<link rel="canonical" href="{canonical_url}">
|
| 605 |
-
<meta name="twitter:card" content="player">
|
| 606 |
-
<meta name="twitter:player" content="{video_url}">
|
| 607 |
-
<style>
|
| 608 |
-
*{{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}}
|
| 609 |
-
.video-container{{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000}}
|
| 610 |
-
video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#000}}
|
| 611 |
-
.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}}
|
| 612 |
-
.title-text{{color:#fff;font-size:13px;line-height:1.4;max-width:600px;margin:0 auto}}
|
| 613 |
-
</style>
|
| 614 |
-
</head>
|
| 615 |
-
<body>
|
| 616 |
-
<div class="video-container">
|
| 617 |
-
<video src="{_clean(video_url)}" controls autoplay playsinline loop></video>
|
| 618 |
-
<div class="title-bar"><div class="title-text">{_clean(safe_title)}</div></div>
|
| 619 |
-
</div>
|
| 620 |
-
</body></html>'''
|
| 621 |
return HTMLResponse(h)
|
| 622 |
|
| 623 |
@app.get('/s/{slug}')
|
| 624 |
async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
|
| 625 |
-
safe_title = _clean(title) if title else 'VNEWS
|
| 626 |
safe_img = _clean(img) if img else ''
|
| 627 |
safe_url = _clean(url) if url else '/'
|
| 628 |
post = None
|
|
@@ -642,23 +688,11 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
|
|
| 642 |
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 643 |
if post and post.get('video'):
|
| 644 |
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 645 |
-
return HTMLResponse(f'
|
| 646 |
-
<html lang="vi">
|
| 647 |
-
<head>
|
| 648 |
-
<meta charset="utf-8">
|
| 649 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 650 |
-
<title>{_clean(safe_title)}</title>
|
| 651 |
-
<meta property="og:title" content="{_clean(safe_title)}">
|
| 652 |
-
<meta property="og:image" content="{_clean(safe_img)}">
|
| 653 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 654 |
-
<meta property="og:url" content="{SPACE}/s/{slug}">
|
| 655 |
-
<link rel="canonical" href="{SPACE}/s/{slug}">
|
| 656 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 657 |
-
</head><body></body></html>''')
|
| 658 |
|
| 659 |
@app.get('/s')
|
| 660 |
async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
| 661 |
-
safe_title = _clean(title) if title else 'VNEWS
|
| 662 |
safe_img = _clean(img) if img else ''
|
| 663 |
safe_url = _clean(url) if url else '/'
|
| 664 |
post = None
|
|
@@ -694,19 +728,7 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
|
| 694 |
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 695 |
if post and post.get('video'):
|
| 696 |
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 697 |
-
return HTMLResponse(f'
|
| 698 |
-
<html lang="vi">
|
| 699 |
-
<head>
|
| 700 |
-
<meta charset="utf-8">
|
| 701 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 702 |
-
<title>{safe_title}</title>
|
| 703 |
-
<meta property="og:title" content="{safe_title}">
|
| 704 |
-
<meta property="og:image" content="{safe_img}">
|
| 705 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 706 |
-
<meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
|
| 707 |
-
<link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
|
| 708 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 709 |
-
</head><body></body></html>''')
|
| 710 |
|
| 711 |
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
|
| 712 |
|
|
@@ -786,25 +808,35 @@ def proxy_xlb(path: str = Query(default="")):
|
|
| 786 |
return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
|
| 787 |
|
| 788 |
@app.get('/api/wc2026')
|
| 789 |
-
def _w():
|
|
|
|
| 790 |
@app.get('/api/wc2026/fixtures')
|
| 791 |
-
def _wf():
|
|
|
|
| 792 |
@app.get('/api/wc2026/standings')
|
| 793 |
-
def _ws():
|
|
|
|
| 794 |
@app.get('/api/wc2026/stats')
|
| 795 |
-
def _wst():
|
|
|
|
| 796 |
@app.get('/api/wc2026/history')
|
| 797 |
-
def _whi():
|
|
|
|
| 798 |
@app.get('/api/wc2026/news')
|
| 799 |
-
def _wn():
|
|
|
|
| 800 |
@app.get('/api/wc2026/road')
|
| 801 |
-
def _wr():
|
|
|
|
| 802 |
@app.get('/api/wc2026/h2h/{eid}')
|
| 803 |
-
def _wh2(eid:int):
|
|
|
|
| 804 |
@app.get('/api/wc2026/lineups/{eid}')
|
| 805 |
-
def _wl(eid:int):
|
|
|
|
| 806 |
@app.get('/api/wc2026/match/{eid}')
|
| 807 |
-
def _wm(eid:int):
|
|
|
|
| 808 |
|
| 809 |
DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
|
| 810 |
os.makedirs(DATA_DIR,exist_ok=True)
|
|
@@ -817,34 +849,62 @@ os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
|
|
| 817 |
_il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
|
| 818 |
def _lj(p):
|
| 819 |
try:
|
| 820 |
-
if os.path.exists(p):
|
| 821 |
-
|
| 822 |
-
|
|
|
|
|
|
|
| 823 |
def _sj(p,d):
|
| 824 |
-
try:
|
| 825 |
-
|
|
|
|
|
|
|
|
|
|
| 826 |
|
| 827 |
@app.post('/api/v2/interact')
|
| 828 |
async def _int(request:Request):
|
| 829 |
-
b=await request.json()
|
| 830 |
-
|
| 831 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
|
| 833 |
@app.get('/api/v2/interactions')
|
| 834 |
def _gi(id:str=Query(...)):
|
| 835 |
-
with _il:
|
|
|
|
| 836 |
|
| 837 |
@app.get('/api/v2/comments')
|
| 838 |
def _gc(id:str=Query(...)):
|
| 839 |
-
with _cl:
|
|
|
|
| 840 |
|
| 841 |
@app.post('/api/v2/comment')
|
| 842 |
async def _pc(request:Request):
|
| 843 |
-
b=await request.json()
|
| 844 |
-
|
|
|
|
|
|
|
|
|
|
| 845 |
c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 846 |
-
with _cl:
|
| 847 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 848 |
return JSONResponse({'comments':cms})
|
| 849 |
|
| 850 |
def _load_wall_posts():
|
|
@@ -870,7 +930,7 @@ async def api_wall_post(request: Request):
|
|
| 870 |
form = await request.form()
|
| 871 |
except Exception as e:
|
| 872 |
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 873 |
-
title = form.get('title', 'Video
|
| 874 |
text = form.get('text', '') or ''
|
| 875 |
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 876 |
video_file = form.get('video')
|
|
@@ -884,7 +944,7 @@ async def api_wall_post(request: Request):
|
|
| 884 |
ext = '.webm'
|
| 885 |
else:
|
| 886 |
ext = '.webm'
|
| 887 |
-
video_filename =
|
| 888 |
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 889 |
try:
|
| 890 |
content = await video_file.read()
|
|
@@ -895,10 +955,10 @@ async def api_wall_post(request: Request):
|
|
| 895 |
file_size_mb = len(content) / 1024 / 1024
|
| 896 |
if file_size_mb > 50:
|
| 897 |
os.remove(video_path)
|
| 898 |
-
return JSONResponse({"error": f"Video
|
| 899 |
-
video_url =
|
| 900 |
except Exception as e:
|
| 901 |
-
return JSONResponse({"error":
|
| 902 |
post = {
|
| 903 |
"id": post_id,
|
| 904 |
"title": title[:200],
|
|
@@ -973,24 +1033,5 @@ def api_wall_delete(post_id: str):
|
|
| 973 |
return JSONResponse({"ok": True})
|
| 974 |
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 975 |
|
| 976 |
-
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 977 |
-
import random as _random2
|
| 978 |
-
from urllib.parse import quote as _quote2
|
| 979 |
-
|
| 980 |
-
_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 981 |
-
|
| 982 |
-
_UNIQUE_CHARS = {
|
| 983 |
-
'vietnamese': set('daeooua a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a'),
|
| 984 |
-
'spanish': set('n ye'),
|
| 985 |
-
'portuguese': set('aocaeiua'),
|
| 986 |
-
}
|
| 987 |
-
|
| 988 |
-
_STOPWORDS = {
|
| 989 |
-
'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'},
|
| 990 |
-
'vietnamese': {'la', 'cua', 'va', 'co', 'duoc', 'cho', 'khong', 'voi', 'nay', 'do', 'tu', 'trong', 'da', 'se', 'mot', 'cac', 'nhung', 've', 'tai', 'nguoi', 'nam', 'den', 'ra', 'lai', 'nhu', 'khi', 'de', 'rat', 'cung', 'ma', 'neu', 'sau', 'tren', 'theo', 'vi', 'do', 'nen', 'thi', 'minh', 'toi', 'ban', 'anh', 'chi', 'em'},
|
| 991 |
-
'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'nao', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'ja', 'eu', 'tambem', 'so', 'pelo', 'pela', 'ate', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'sao', 'esta', 'ter', 'ser', 'foi', 'era', 'ha', 'estao', 'voce', 'nos', 'eles', 'elas'},
|
| 992 |
-
'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'mas', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'esta', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'tambien', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'estan', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tu', 'el', 'ella', 'nosotros', 'usted', 'ustedes'},
|
| 993 |
-
}
|
| 994 |
-
|
| 995 |
print(f"[VNEWS] app_v2_entry loaded. DATA_DIR={DATA_DIR}, STATIC_DIR={STATIC_DIR}")
|
| 996 |
print(f"[VNEWS] Persistent storage: {os.path.isdir('/data') and os.access('/data', os.W_OK)}")
|
|
|
|
| 23 |
from urllib.parse import quote
|
| 24 |
import asyncio
|
| 25 |
|
| 26 |
+
HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao huu", "emoji": "🤝"}
|
| 27 |
|
| 28 |
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 29 |
SPACE = "https://bep40-vnews.hf.space"
|
|
|
|
| 33 |
|
| 34 |
def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
|
| 35 |
|
|
|
|
| 36 |
_match_cache = {}
|
| 37 |
|
| 38 |
def _get_match_detail(event_id, slug=None):
|
|
|
|
| 202 |
return JSONResponse(err)
|
| 203 |
return JSONResponse({"event_id": event_id, "found": False})
|
| 204 |
|
| 205 |
+
_STOP=set('va cua cac nhung mot duoc trong voi cho tai sau truoc khi khong nguoi viet nam hom nay moi nhat nong tin tuc cap nhat theo tu den la co thi nay da de'.split())
|
| 206 |
|
| 207 |
+
# Rotating User-Agents
|
| 208 |
_SEARCH_UAS = [
|
| 209 |
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
| 210 |
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
|
|
|
| 220 |
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'}
|
| 221 |
|
| 222 |
def _has_kw(topic,title):
|
| 223 |
+
tl=topic.lower()
|
| 224 |
+
tt=(title or'').lower()
|
| 225 |
+
if tl in tt:
|
| 226 |
+
return True
|
| 227 |
+
words=[w for w in re.findall(r'[A-Za-z0-9]+',tl) if len(w)>2 and w not in _STOP]
|
| 228 |
+
if not words:
|
| 229 |
+
return True
|
| 230 |
return any(w in tt for w in words)
|
| 231 |
|
| 232 |
def _s_vnexpress(topic,limit=8):
|
| 233 |
items=[]
|
| 234 |
try:
|
| 235 |
+
r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
| 236 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 237 |
for art in soup.select('article.item-news')[:limit]:
|
| 238 |
a=art.select_one('h2 a, h3 a')
|
| 239 |
if a and a.get('href'):
|
| 240 |
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 241 |
+
if _has_kw(topic,t):
|
| 242 |
+
items.append({'title':t,'url':a['href'],'via':'VnExpress'})
|
| 243 |
+
except:
|
| 244 |
+
pass
|
| 245 |
return items
|
| 246 |
|
| 247 |
def _s_dantri(topic,limit=8):
|
| 248 |
items=[]
|
| 249 |
try:
|
| 250 |
+
r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers=_search_headers(),timeout=12)
|
| 251 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 252 |
for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
|
| 253 |
+
t=_clean(a.get_text(strip=True))
|
| 254 |
+
href=a.get('href','')
|
| 255 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 256 |
+
if not href.startswith('http'):
|
| 257 |
+
href='https://dantri.com.vn'+href
|
| 258 |
+
items.append({'title':t,'url':href,'via':'Dan Tri'})
|
| 259 |
+
if len(items)>=limit:
|
| 260 |
+
break
|
| 261 |
+
except:
|
| 262 |
+
pass
|
| 263 |
return items
|
| 264 |
|
| 265 |
def _s_vietnamnet(topic,limit=6):
|
| 266 |
items=[]
|
| 267 |
try:
|
| 268 |
+
r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
| 269 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 270 |
for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
|
| 271 |
+
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 272 |
+
href=a.get('href','')
|
| 273 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 274 |
+
if not href.startswith('http'):
|
| 275 |
+
href='https://vietnamnet.vn'+href
|
| 276 |
items.append({'title':t,'url':href,'via':'VietNamNet'})
|
| 277 |
+
if len(items)>=limit:
|
| 278 |
+
break
|
| 279 |
+
except:
|
| 280 |
+
pass
|
| 281 |
return items
|
| 282 |
|
| 283 |
def _s_bongda(topic,limit=5):
|
| 284 |
items=[]
|
| 285 |
try:
|
| 286 |
+
r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
| 287 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 288 |
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 289 |
+
t=_clean(a.get_text(strip=True))
|
| 290 |
+
href=a.get('href','')
|
| 291 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 292 |
+
if not href.startswith('http'):
|
| 293 |
+
href='https://bongda.com.vn'+href
|
| 294 |
+
items.append({'title':t,'url':href,'via':'Bong Da'})
|
| 295 |
+
if len(items)>=limit:
|
| 296 |
+
break
|
| 297 |
+
except:
|
| 298 |
+
pass
|
| 299 |
return items
|
| 300 |
|
| 301 |
def _s_genk(topic,limit=5):
|
| 302 |
items=[]
|
| 303 |
try:
|
| 304 |
+
r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
| 305 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 306 |
for a in soup.select('a[href$=".chn"]')[:limit*3]:
|
| 307 |
+
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 308 |
+
href=a.get('href','')
|
| 309 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 310 |
+
if href.startswith('/'):
|
| 311 |
+
href='https://genk.vn'+href
|
| 312 |
items.append({'title':t,'url':href,'via':'GenK'})
|
| 313 |
+
if len(items)>=limit:
|
| 314 |
+
break
|
| 315 |
+
except:
|
| 316 |
+
pass
|
| 317 |
return items
|
| 318 |
|
| 319 |
def _s_thanhnien(topic,limit=6):
|
| 320 |
items=[]
|
| 321 |
try:
|
| 322 |
+
r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12)
|
| 323 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 324 |
for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
|
| 325 |
+
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 326 |
+
href=a.get('href','')
|
| 327 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 328 |
+
if not href.startswith('http'):
|
| 329 |
+
href='https://thanhnien.vn'+href
|
| 330 |
+
items.append({'title':t,'url':href,'via':'Thanh Nien'})
|
| 331 |
+
if len(items)>=limit:
|
| 332 |
+
break
|
| 333 |
+
except:
|
| 334 |
+
pass
|
| 335 |
return items
|
| 336 |
|
| 337 |
def _s_tuoitre(topic,limit=6):
|
| 338 |
items=[]
|
| 339 |
try:
|
| 340 |
+
r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers=_search_headers(),timeout=12)
|
| 341 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 342 |
for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
|
| 343 |
+
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 344 |
+
href=a.get('href','')
|
| 345 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 346 |
+
if not href.startswith('http'):
|
| 347 |
+
href='https://tuoitre.vn'+href
|
| 348 |
+
items.append({'title':t,'url':href,'via':'Tuoi Tre'})
|
| 349 |
+
if len(items)>=limit:
|
| 350 |
+
break
|
| 351 |
+
except:
|
| 352 |
+
pass
|
| 353 |
return items
|
| 354 |
|
| 355 |
def _s_thethaovanhoa(topic,limit=5):
|
| 356 |
items=[]
|
| 357 |
try:
|
| 358 |
+
r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers=_search_headers(),timeout=12)
|
| 359 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 360 |
for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
|
| 361 |
+
t=_clean(a.get('title','') or a.get_text(strip=True))
|
| 362 |
+
href=a.get('href','')
|
| 363 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 364 |
+
if not href.startswith('http'):
|
| 365 |
+
href='https://thethaovanhoa.vn'+href
|
| 366 |
items.append({'title':t,'url':href,'via':'TT&VH'})
|
| 367 |
+
if len(items)>=limit:
|
| 368 |
+
break
|
| 369 |
+
except:
|
| 370 |
+
pass
|
| 371 |
return items
|
| 372 |
|
| 373 |
def _s_google_news(topic,limit=8):
|
|
|
|
| 374 |
items=[]
|
| 375 |
try:
|
| 376 |
r=req.get(f"https://news.google.com/rss/search?q={quote(topic)}+Viet+Nam&hl=vi&gl=VN&ceid=VN:vi",headers=_search_headers(),timeout=10)
|
|
|
|
| 381 |
link=item.find('link').get_text() if item.find('link') else ''
|
| 382 |
source=item.find('source').get_text() if item.find('source') else 'Google News'
|
| 383 |
if title and link and len(title)>15:
|
| 384 |
+
items.append({'title':title,'url':link,'via':'Google '+source})
|
| 385 |
+
except:
|
| 386 |
+
pass
|
| 387 |
return items
|
| 388 |
|
| 389 |
def _search_all(topic,limit=36):
|
|
|
|
| 391 |
with ThreadPoolExecutor(8) as ex:
|
| 392 |
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'}
|
| 393 |
for f in as_completed(futs,timeout=20):
|
| 394 |
+
try:
|
| 395 |
+
results[futs[f]]=f.result()
|
| 396 |
+
except:
|
| 397 |
+
results[futs[f]]=[]
|
| 398 |
+
srcs=list(results.values())
|
| 399 |
+
out=[]
|
| 400 |
+
seen=set()
|
| 401 |
for i in range(max((len(s) for s in srcs),default=0)):
|
| 402 |
for s in srcs:
|
| 403 |
+
if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:
|
| 404 |
+
seen.add(s[i]['url'])
|
| 405 |
+
out.append(s[i])
|
| 406 |
if not out:
|
| 407 |
try:
|
| 408 |
google_items=_s_google_news(topic,limit)
|
| 409 |
out.extend(google_items)
|
| 410 |
+
except:
|
| 411 |
+
pass
|
| 412 |
return out[:limit]
|
| 413 |
|
| 414 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
|
|
|
| 508 |
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
| 509 |
if title:
|
| 510 |
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 511 |
+
'body': [{'type': 'p', 'text': 'Noi dung dang duoc tai...'}],
|
| 512 |
'source': domain, 'url': url, 'fallback': True}
|
| 513 |
break
|
| 514 |
except Exception:
|
|
|
|
| 532 |
resp = JSONResponse(data)
|
| 533 |
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 534 |
return resp
|
| 535 |
+
result = {'error': 'Khong doc duoc', 'url': safe_url}
|
| 536 |
resp = JSONResponse(result)
|
| 537 |
resp.headers["Cache-Control"] = "public, max-age=60"
|
| 538 |
return resp
|
|
|
|
| 542 |
_hot_cache={'t':0,'d':[]}
|
| 543 |
def _get_hot_topics():
|
| 544 |
now=time.time()
|
| 545 |
+
if _hot_cache['d'] and now-_hot_cache['t']<600:
|
| 546 |
+
return _hot_cache['d']
|
| 547 |
+
freq={}
|
| 548 |
+
display={}
|
| 549 |
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']
|
| 550 |
for feed_url in feeds:
|
| 551 |
try:
|
| 552 |
+
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6)
|
| 553 |
+
r.encoding='utf-8'
|
| 554 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 555 |
for item in soup.find_all('item')[:12]:
|
| 556 |
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 557 |
+
if not title:
|
| 558 |
+
continue
|
| 559 |
+
title=re.sub(r'\s*[-|].*$','',title)
|
| 560 |
+
words=[w for w in re.findall(r'[A-Za-z0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
|
| 561 |
+
if len(words)<2:
|
| 562 |
+
continue
|
| 563 |
for n in(3,4,2):
|
| 564 |
for i in range(max(0,len(words)-n+1)):
|
| 565 |
phrase=' '.join(words[i:i+n])
|
| 566 |
+
if 8<=len(phrase)<=45:
|
| 567 |
+
key=phrase.lower()
|
| 568 |
+
freq[key]=freq.get(key,0)+1
|
| 569 |
+
display[key]=phrase
|
| 570 |
+
except:
|
| 571 |
+
continue
|
| 572 |
+
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True)
|
| 573 |
+
topics=[]
|
| 574 |
+
seen=set()
|
| 575 |
for key,count in ranked:
|
| 576 |
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)
|
| 577 |
+
if is_dup:
|
| 578 |
+
continue
|
| 579 |
+
seen.add(key)
|
| 580 |
+
topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
|
| 581 |
+
if len(topics)>=20:
|
| 582 |
+
break
|
| 583 |
+
for kw in['World Cup 2026','Kinh te Viet Nam','Bong da chau Au','Cong nghe AI','Gia vang','Thoi tiet']:
|
| 584 |
+
if len(topics)>=24:
|
| 585 |
+
break
|
| 586 |
+
if not any(kw.lower() in s for s in seen):
|
| 587 |
+
topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
|
| 588 |
+
_hot_cache.update({'t':now,'d':topics[:24]})
|
| 589 |
+
return topics[:24]
|
| 590 |
|
| 591 |
@app.get('/api/hot_topics')
|
| 592 |
def api_hot_topics():
|
| 593 |
resp = JSONResponse({'topics':_get_hot_topics()})
|
| 594 |
resp.headers["Cache-Control"] = "public, max-age=120"
|
| 595 |
return resp
|
| 596 |
+
|
| 597 |
@app.get('/')
|
| 598 |
async def serve_index():
|
| 599 |
p=os.path.join(STATIC_DIR,'index_v2.html')
|
| 600 |
+
if os.path.exists(p):
|
| 601 |
+
return FileResponse(p,media_type='text/html')
|
| 602 |
return HTMLResponse('<h1>VNEWS</h1>')
|
| 603 |
+
|
| 604 |
@app.get('/api/hashtag/sources')
|
| 605 |
def _ht(topic:str=Query(...),page:int=Query(default=0),source:str=Query(default=None)):
|
| 606 |
if source == 'google':
|
| 607 |
items=_s_google_news(topic,36)
|
| 608 |
else:
|
| 609 |
items=_search_all(topic,36)
|
| 610 |
+
per_page=8
|
| 611 |
+
start=page*per_page
|
| 612 |
+
end=start+per_page
|
| 613 |
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
| 614 |
+
|
| 615 |
@app.get('/api/categories')
|
| 616 |
+
def _cat():
|
| 617 |
+
return JSONResponse([])
|
| 618 |
+
|
| 619 |
@app.get('/api/storage_status')
|
| 620 |
+
def _st():
|
| 621 |
+
return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
| 622 |
|
| 623 |
def _render_slides_page(post, safe_title, safe_img, safe_url):
|
| 624 |
slides = post.get('slides', [])
|
| 625 |
if not safe_img and slides and slides[0].get('image'):
|
| 626 |
safe_img = slides[0].get('image', '')
|
| 627 |
+
description = _clean((post.get('text') or '')[:200]) or "Tin tuc tom tat, AI rewrite, World Cup 2026"
|
| 628 |
if safe_url and safe_url != '/':
|
| 629 |
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 630 |
else:
|
| 631 |
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 632 |
+
h = '<!DOCTYPE html>\n<html lang="vi">\n<head>\n<meta charset="utf-8">\n'
|
| 633 |
+
h += '<title>'+_clean(safe_title)+'</title>\n'
|
| 634 |
+
h += '<meta property="og:title" content="'+_clean(safe_title)+'">\n'
|
| 635 |
+
h += '<meta property="og:image" content="'+_clean(safe_img)+'">\n'
|
| 636 |
+
h += '<meta property="og:description" content="'+description+'">\n'
|
| 637 |
+
h += '<meta property="og:url" content="'+canonical_url+'">\n'
|
| 638 |
+
h += '<link rel="canonical" href="'+canonical_url+'">\n'
|
| 639 |
+
h += '<style>*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;padding:12px}.slide-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px}.slide-num{color:#5cb87a;font-size:12px;font-weight:700}.slide-img{width:100%;max-height:300px;object-fit:cover;border-radius:8px}.slide-text{color:#ddd;font-size:14px;line-height:1.6}</style>\n</head><body>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
for s in slides:
|
| 641 |
img_src = s.get('image', '')
|
| 642 |
+
img_tag = ''
|
| 643 |
+
if img_src:
|
| 644 |
+
if 'cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src:
|
| 645 |
+
img_tag = '<img src="/api/proxy/img?url='+quote(img_src, safe="")+'" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
|
| 646 |
+
else:
|
| 647 |
+
img_tag = '<img src="'+_clean(img_src)+'" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
|
| 648 |
+
h += '<div class="slide-card"><div class="slide-num">Slide '+str(s.get("index",1))+'/'+str(len(slides))+'</div>'+img_tag+'<p class="slide-text">'+_clean(s.get("text",""))+'</p></div>'
|
| 649 |
h += '</body></html>'
|
| 650 |
return HTMLResponse(h)
|
| 651 |
|
| 652 |
def _render_video_page(post, safe_title, safe_img, safe_url):
|
| 653 |
video_url = post.get('video', '')
|
|
|
|
| 654 |
if safe_url and safe_url != '/':
|
| 655 |
canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
|
| 656 |
else:
|
| 657 |
canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
|
| 658 |
+
h = '<!DOCTYPE html>\n<html lang="vi">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">\n'
|
| 659 |
+
h += '<title>'+_clean(safe_title)+'</title>\n'
|
| 660 |
+
h += '<meta property="og:title" content="'+_clean(safe_title)+'">\n'
|
| 661 |
+
h += '<meta property="og:image" content="'+_clean(safe_img)+'">\n'
|
| 662 |
+
h += '<meta property="og:url" content="'+canonical_url+'">\n'
|
| 663 |
+
h += '<link rel="canonical" href="'+canonical_url+'">\n'
|
| 664 |
+
h += '<style>*{margin:0;padding:0;box-sizing:border-box}body{background:#000;overflow:hidden}video{width:100vw;height:100vh;object-fit:contain}</style>\n</head><body>\n'
|
| 665 |
+
h += '<video src="'+_clean(video_url)+'" controls autoplay playsinline loop></video>\n'
|
| 666 |
+
h += '</body></html>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
return HTMLResponse(h)
|
| 668 |
|
| 669 |
@app.get('/s/{slug}')
|
| 670 |
async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
|
| 671 |
+
safe_title = _clean(title) if title else 'VNEWS'
|
| 672 |
safe_img = _clean(img) if img else ''
|
| 673 |
safe_url = _clean(url) if url else '/'
|
| 674 |
post = None
|
|
|
|
| 688 |
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 689 |
if post and post.get('video'):
|
| 690 |
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 691 |
+
return HTMLResponse(f'<!DOCTYPE html><html lang="vi"><head><meta charset="utf-8"><title>{_clean(safe_title)}</title><meta property="og:title" content="{_clean(safe_title)}"><meta property="og:image" content="{_clean(safe_img)}"><meta http-equiv="refresh" content="0;url={safe_url}"></head><body></body></html>')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
|
| 693 |
@app.get('/s')
|
| 694 |
async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
| 695 |
+
safe_title = _clean(title) if title else 'VNEWS'
|
| 696 |
safe_img = _clean(img) if img else ''
|
| 697 |
safe_url = _clean(url) if url else '/'
|
| 698 |
post = None
|
|
|
|
| 728 |
return _render_slides_page(post, safe_title, safe_img, safe_url)
|
| 729 |
if post and post.get('video'):
|
| 730 |
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 731 |
+
return HTMLResponse(f'<!DOCTYPE html><html lang="vi"><head><meta charset="utf-8"><title>{safe_title}</title><meta property="og:title" content="{safe_title}"><meta property="og:image" content="{safe_img}"><meta http-equiv="refresh" content="0;url={safe_url}"></head><body></body></html>')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
|
| 733 |
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
|
| 734 |
|
|
|
|
| 808 |
return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
|
| 809 |
|
| 810 |
@app.get('/api/wc2026')
|
| 811 |
+
def _w():
|
| 812 |
+
return JSONResponse(get_wc2026_all())
|
| 813 |
@app.get('/api/wc2026/fixtures')
|
| 814 |
+
def _wf():
|
| 815 |
+
return JSONResponse(scrape_fixtures())
|
| 816 |
@app.get('/api/wc2026/standings')
|
| 817 |
+
def _ws():
|
| 818 |
+
return JSONResponse(scrape_standings())
|
| 819 |
@app.get('/api/wc2026/stats')
|
| 820 |
+
def _wst():
|
| 821 |
+
return JSONResponse(scrape_stats())
|
| 822 |
@app.get('/api/wc2026/history')
|
| 823 |
+
def _whi():
|
| 824 |
+
return JSONResponse(scrape_history())
|
| 825 |
@app.get('/api/wc2026/news')
|
| 826 |
+
def _wn():
|
| 827 |
+
return JSONResponse(scrape_wc_news())
|
| 828 |
@app.get('/api/wc2026/road')
|
| 829 |
+
def _wr():
|
| 830 |
+
return JSONResponse(scrape_road_to_wc())
|
| 831 |
@app.get('/api/wc2026/h2h/{eid}')
|
| 832 |
+
def _wh2(eid:int):
|
| 833 |
+
return JSONResponse(scrape_h2h(eid))
|
| 834 |
@app.get('/api/wc2026/lineups/{eid}')
|
| 835 |
+
def _wl(eid:int):
|
| 836 |
+
return JSONResponse(scrape_lineups(eid))
|
| 837 |
@app.get('/api/wc2026/match/{eid}')
|
| 838 |
+
def _wm(eid:int):
|
| 839 |
+
return JSONResponse(scrape_match_detail(eid))
|
| 840 |
|
| 841 |
DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
|
| 842 |
os.makedirs(DATA_DIR,exist_ok=True)
|
|
|
|
| 849 |
_il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
|
| 850 |
def _lj(p):
|
| 851 |
try:
|
| 852 |
+
if os.path.exists(p):
|
| 853 |
+
return json.load(open(p,'r',encoding='utf-8'))
|
| 854 |
+
except:
|
| 855 |
+
pass
|
| 856 |
+
return {}
|
| 857 |
def _sj(p,d):
|
| 858 |
+
try:
|
| 859 |
+
open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False))
|
| 860 |
+
os.replace(p+'.tmp',p)
|
| 861 |
+
except:
|
| 862 |
+
pass
|
| 863 |
|
| 864 |
@app.post('/api/v2/interact')
|
| 865 |
async def _int(request:Request):
|
| 866 |
+
b=await request.json()
|
| 867 |
+
v=str(b.get('id','')).strip()
|
| 868 |
+
t=str(b.get('type','')).strip()
|
| 869 |
+
if not v or t not in('view','like'):
|
| 870 |
+
return JSONResponse({'error':'x'},status_code=400)
|
| 871 |
+
with _il:
|
| 872 |
+
db=_lj(IF)
|
| 873 |
+
db.setdefault(v,{'views':0,'likes':0,'comments':0})
|
| 874 |
+
db[v][t+'s']+=1
|
| 875 |
+
_sj(IF,db)
|
| 876 |
+
return JSONResponse(db[v])
|
| 877 |
|
| 878 |
@app.get('/api/v2/interactions')
|
| 879 |
def _gi(id:str=Query(...)):
|
| 880 |
+
with _il:
|
| 881 |
+
return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
|
| 882 |
|
| 883 |
@app.get('/api/v2/comments')
|
| 884 |
def _gc(id:str=Query(...)):
|
| 885 |
+
with _cl:
|
| 886 |
+
return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
|
| 887 |
|
| 888 |
@app.post('/api/v2/comment')
|
| 889 |
async def _pc(request:Request):
|
| 890 |
+
b=await request.json()
|
| 891 |
+
v=str(b.get('id','')).strip()
|
| 892 |
+
tx=str(b.get('text','')).strip()[:500]
|
| 893 |
+
if not v or not tx:
|
| 894 |
+
return JSONResponse({'error':'x'},status_code=400)
|
| 895 |
c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 896 |
+
with _cl:
|
| 897 |
+
db=_lj(CF)
|
| 898 |
+
db.setdefault(v,[])
|
| 899 |
+
db[v].append(c)
|
| 900 |
+
db[v]=db[v][-200:]
|
| 901 |
+
_sj(CF,db)
|
| 902 |
+
cms=db[v]
|
| 903 |
+
with _il:
|
| 904 |
+
idb=_lj(IF)
|
| 905 |
+
idb.setdefault(v,{'views':0,'likes':0,'comments':0})
|
| 906 |
+
idb[v]['comments']=len(cms)
|
| 907 |
+
_sj(IF,idb)
|
| 908 |
return JSONResponse({'comments':cms})
|
| 909 |
|
| 910 |
def _load_wall_posts():
|
|
|
|
| 930 |
form = await request.form()
|
| 931 |
except Exception as e:
|
| 932 |
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 933 |
+
title = form.get('title', 'Video moi') or 'Video moi'
|
| 934 |
text = form.get('text', '') or ''
|
| 935 |
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 936 |
video_file = form.get('video')
|
|
|
|
| 944 |
ext = '.webm'
|
| 945 |
else:
|
| 946 |
ext = '.webm'
|
| 947 |
+
video_filename = "wall_"+post_id+ext
|
| 948 |
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 949 |
try:
|
| 950 |
content = await video_file.read()
|
|
|
|
| 955 |
file_size_mb = len(content) / 1024 / 1024
|
| 956 |
if file_size_mb > 50:
|
| 957 |
os.remove(video_path)
|
| 958 |
+
return JSONResponse({"error": f"Video too large ({file_size_mb:.1f}MB). Max 50MB."}, status_code=400)
|
| 959 |
+
video_url = "/api/wall/video/"+video_filename
|
| 960 |
except Exception as e:
|
| 961 |
+
return JSONResponse({"error": "Video save error: "+str(e)}, status_code=500)
|
| 962 |
post = {
|
| 963 |
"id": post_id,
|
| 964 |
"title": title[:200],
|
|
|
|
| 1033 |
return JSONResponse({"ok": True})
|
| 1034 |
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 1035 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1036 |
print(f"[VNEWS] app_v2_entry loaded. DATA_DIR={DATA_DIR}, STATIC_DIR={STATIC_DIR}")
|
| 1037 |
print(f"[VNEWS] Persistent storage: {os.path.isdir('/data') and os.access('/data', os.W_OK)}")
|