bep40 commited on
Commit
013df6e
·
verified ·
1 Parent(s): d529d53

Fix: rotating User-Agents, timeout 20s, Google News RSS fallback, source=google param, fix _UNIQUE_CHARS corruption, fix f-string syntax

Browse files
Files changed (1) hide show
  1. 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 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"
@@ -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(' 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 thì này đã để'.split())
207
 
208
- # Rotating User-Agents for search scrapers
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();tt=(title or'').lower()
225
- if tl in tt:return True
226
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
227
- if not words:return True
 
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
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):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
239
- except:pass
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
246
  for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
247
- t=_clean(a.get_text(strip=True));href=a.get('href','')
 
248
  if t and len(t)>15 and _has_kw(topic,t):
249
- if not href.startswith('http'):href='https://dantri.com.vn'+href
250
- items.append({'title':t,'url':href,'via':'Dân Trí'})
251
- if len(items)>=limit:break
252
- except:pass
 
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
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));href=a.get('href','')
 
261
  if t and len(t)>15 and _has_kw(topic,t):
262
- if not href.startswith('http'):href='https://vietnamnet.vn'+href
 
263
  items.append({'title':t,'url':href,'via':'VietNamNet'})
264
- if len(items)>=limit:break
265
- except:pass
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
272
  for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
273
- t=_clean(a.get_text(strip=True));href=a.get('href','')
 
274
  if t and len(t)>15 and _has_kw(topic,t):
275
- if not href.startswith('http'):href='https://bongda.com.vn'+href
276
- items.append({'title':t,'url':href,'via':'Bóng Đá'})
277
- if len(items)>=limit:break
278
- except:pass
 
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
285
  for a in soup.select('a[href$=".chn"]')[:limit*3]:
286
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
 
287
  if t and len(t)>15 and _has_kw(topic,t):
288
- if href.startswith('/'):href='https://genk.vn'+href
 
289
  items.append({'title':t,'url':href,'via':'GenK'})
290
- if len(items)>=limit:break
291
- except:pass
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
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));href=a.get('href','')
 
300
  if t and len(t)>15 and _has_kw(topic,t):
301
- if not href.startswith('http'):href='https://thanhnien.vn'+href
302
- items.append({'title':t,'url':href,'via':'Thanh Niên'})
303
- if len(items)>=limit:break
304
- except:pass
 
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
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));href=a.get('href','')
 
313
  if t and len(t)>15 and _has_kw(topic,t):
314
- if not href.startswith('http'):href='https://tuoitre.vn'+href
315
- items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
316
- if len(items)>=limit:break
317
- except:pass
 
 
 
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);soup=BeautifulSoup(r.text,'lxml')
 
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));href=a.get('href','')
 
326
  if t and len(t)>15 and _has_kw(topic,t):
327
- if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
 
328
  items.append({'title':t,'url':href,'via':'TT&VH'})
329
- if len(items)>=limit:break
330
- except:pass
 
 
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':f'Google {source}'})
346
- except:pass
 
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:results[futs[f]]=f.result()
355
- except:results[futs[f]]=[]
356
- srcs=list(results.values());out=[];seen=set()
 
 
 
 
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:seen.add(s[i]['url']);out.append(s[i])
 
 
360
  if not out:
361
  try:
362
  google_items=_s_google_news(topic,limit)
363
  out.extend(google_items)
364
- except:pass
 
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': 'Nội dung đang được tải...'}],
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': 'Không đọc được', 'url': safe_url}
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:return _hot_cache['d']
499
- freq={};display={}
 
 
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);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
 
 
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:continue
507
- 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]
508
- if len(words)<2:continue
 
 
 
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:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
513
- except:continue
514
- ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
 
 
 
 
 
 
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:continue
518
- seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
519
- if len(topics)>=20:break
520
- 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']:
521
- if len(topics)>=24:break
522
- if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
523
- _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
 
 
 
 
 
 
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):return FileResponse(p,media_type='text/html')
 
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;start=page*per_page;end=start+per_page
 
 
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():return JSONResponse([])
 
 
545
  @app.get('/api/storage_status')
546
- def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
 
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 tức tóm tắt, AI rewrite, World Cup 2026"
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 = f'''<!DOCTYPE html>
558
- <html lang="vi">
559
- <head>
560
- <meta charset="utf-8">
561
- <meta name="viewport" content="width=device-width,initial-scale=1">
562
- <title>{_clean(safe_title)}</title>
563
- <meta property="og:title" content="{_clean(safe_title)}">
564
- <meta property="og:image" content="{_clean(safe_img)}">
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
- if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src):
580
- img_tag = f'<img src="/api/proxy/img?url={quote(img_src, safe="")}" class="slide-img" loading="lazy" onerror="this.style.display=&#39;none&#39;">'
581
- else:
582
- img_tag = f'<img src="{_clean(img_src)}" class="slide-img" loading="lazy" onerror="this.style.display=&#39;none&#39;">' if img_src else ''
583
- 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>'
 
 
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 = f'''<!DOCTYPE html>
595
- <html lang="vi">
596
- <head>
597
- <meta charset="utf-8">
598
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
599
- <title>{_clean(safe_title)}</title>
600
- <meta property="og:title" content="{_clean(safe_title)}">
601
- <meta property="og:image" content="{_clean(safe_img)}">
602
- <meta property="og:description" content="{description}">
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 - Tin tức'
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'''<!DOCTYPE html>
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 - Tin tức'
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'''<!DOCTYPE html>
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():return JSONResponse(get_wc2026_all())
 
790
  @app.get('/api/wc2026/fixtures')
791
- def _wf():return JSONResponse(scrape_fixtures())
 
792
  @app.get('/api/wc2026/standings')
793
- def _ws():return JSONResponse(scrape_standings())
 
794
  @app.get('/api/wc2026/stats')
795
- def _wst():return JSONResponse(scrape_stats())
 
796
  @app.get('/api/wc2026/history')
797
- def _whi():return JSONResponse(scrape_history())
 
798
  @app.get('/api/wc2026/news')
799
- def _wn():return JSONResponse(scrape_wc_news())
 
800
  @app.get('/api/wc2026/road')
801
- def _wr():return JSONResponse(scrape_road_to_wc())
 
802
  @app.get('/api/wc2026/h2h/{eid}')
803
- def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
 
804
  @app.get('/api/wc2026/lineups/{eid}')
805
- def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
 
806
  @app.get('/api/wc2026/match/{eid}')
807
- def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
 
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):return json.load(open(p,'r',encoding='utf-8'))
821
- except:pass
822
- return{}
 
 
823
  def _sj(p,d):
824
- try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
825
- except:pass
 
 
 
826
 
827
  @app.post('/api/v2/interact')
828
  async def _int(request:Request):
829
- b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
830
- if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
831
- 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])
 
 
 
 
 
 
 
 
832
 
833
  @app.get('/api/v2/interactions')
834
  def _gi(id:str=Query(...)):
835
- with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
 
836
 
837
  @app.get('/api/v2/comments')
838
  def _gc(id:str=Query(...)):
839
- with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
 
840
 
841
  @app.post('/api/v2/comment')
842
  async def _pc(request:Request):
843
- b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
844
- if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
 
 
 
845
  c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
846
- with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
847
- with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
 
 
 
 
 
 
 
 
 
 
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 mới') or 'Video mới'
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 = f"wall_{post_id}{ext}"
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 qua lon ({file_size_mb:.1f}MB). Toi da 50MB."}, status_code=400)
899
- video_url = f"/api/wall/video/{video_filename}"
900
  except Exception as e:
901
- return JSONResponse({"error": f"Loi luu video: {str(e)}"}, status_code=500)
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)}")