bep40 commited on
Commit
de3cc18
·
verified ·
1 Parent(s): ae576e1

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +43 -128
main.py CHANGED
@@ -1,7 +1,7 @@
1
  """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube FPT shorts"""
2
  import hashlib, re, time, subprocess, json, os, threading
3
  import html as html_lib
4
- from datetime import datetime
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
6
  from fastapi import FastAPI, Query, Request
7
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
@@ -20,6 +20,10 @@ HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/5
20
  BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
21
  BASE_BDP = "https://bongdaplus.vn"
22
  SPACE_URL = "https://bep40-vnews.hf.space"
 
 
 
 
23
  _cache = {}
24
  _cache_ttl = 300
25
  _cache_ttl_live = 60
@@ -93,7 +97,7 @@ def _save_wall(posts):
93
  except:pass
94
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
95
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
96
- HL_LEAGUES = {"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},"world-cup":{"path":"the-gioi/world-cup-qualifiers","name":"World Cup 2026","emoji":"🌍"}}
97
  def _cached(key, fn, ttl=None):
98
  now=time.time();t=ttl or _cache_ttl
99
  if key in _cache and now-_cache[key]["t"]<t:return _cache[key]["d"]
@@ -176,8 +180,9 @@ def proxy_img(url: str = Query(...)):
176
 
177
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
178
  def _scrape_xemlaibongda_page(page_path, limit=20):
 
179
  try:
180
- url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
181
  r=requests.get(url,headers=HEADERS,timeout=15)
182
  if r.status_code!=200:return[]
183
  r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");videos=[];seen=set()
@@ -187,14 +192,25 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
187
  if not href.startswith("http"):href="https://xemlaibongda.top"+href
188
  if href in seen:continue
189
  seen.add(href);slug=href.split("/video/")[-1].rstrip("/")
190
- title=slug.replace("-"," ").title()
191
- title=re.sub(r'\d{4}\s*\d{2}\s*\d{2}$','',title).strip()
192
- title=re.sub(r'\s+V\s+',' vs ',title);title=re.sub(r'\s+Vs\s+',' vs ',title)
193
  img=a.find("img") or (a.parent.find("img") if a.parent else None)
 
 
 
 
 
 
 
 
194
  img_src=""
195
- if img:img_src=img.get("data-src","") or img.get("src","") or img.get("data-lazy","")
196
- if not img_src:img_src=f"https://img.refooty.com/thumbnail/{slug}.webp"
197
- videos.append({"title":title,"link":href,"img":img_src,"source":"xemlaibongda"})
 
 
 
 
 
198
  if len(videos)>=limit:break
199
  return videos
200
  except:return[]
@@ -257,11 +273,17 @@ def _yt_channel_shorts(channel, count=15):
257
  if len(items)>=count:break
258
  return items
259
  except:return[]
 
 
 
 
 
260
  def scrape_shorts():
261
  """Stable shorts feed: fast HTML scrape + static fallback so slide never disappears."""
262
  vids=[]
263
- with ThreadPoolExecutor(2) as ex:
264
  futs=[ex.submit(_yt_channel_shorts,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte"]]
 
265
  for f in as_completed(futs):
266
  try:
267
  r=f.result()
@@ -272,7 +294,13 @@ def scrape_shorts():
272
  vid=v.get("id")
273
  if not vid or vid in seen:continue
274
  seen.add(vid);merged.append(v)
275
- return merged[:40]
 
 
 
 
 
 
276
 
277
  # ===== LIVESCORE =====
278
  @app.get("/api/livescore/live")
@@ -281,10 +309,10 @@ def api_livescore_live():return JSONResponse({"html":_cached("ls_live",lambda:fe
281
  def api_livescore_incoming():return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)})
282
  @app.get("/api/livescore/today")
283
  def api_livescore_today():
284
- today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)})
285
  @app.get("/api/livescore/results")
286
  def api_livescore_results():
287
- today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
288
  @app.get("/api/livescore/standings/{league}")
289
  def api_livescore_standings(league:str):
290
  tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
@@ -300,7 +328,6 @@ from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
300
 
301
  @app.get("/api/match/{event_id}/detail")
302
  def api_match_detail(event_id: int, url: str = Query(default="")):
303
- """Get full match detail by scraping bongda.com.vn server-side."""
304
  try:
305
  if url:
306
  data = fetch_match_detail_by_url(url)
@@ -313,7 +340,7 @@ def api_match_detail(event_id: int, url: str = Query(default="")):
313
  @app.get("/api/livescore/featured")
314
  def api_livescore_featured():
315
  def _f():
316
- sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now().strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")]
317
  for endpoint, stype in sources:
318
  html=fetch_bongda_api(endpoint)
319
  if not html or len(html)<100:continue
@@ -483,7 +510,6 @@ def scrape_dantri_congnghe():
483
  return arts
484
  except:return[]
485
  def scrape_genk_ai():
486
- """Scrape AI articles from genk.vn - readable in-app"""
487
  try:
488
  r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
489
  if r.status_code!=200:return[]
@@ -638,7 +664,7 @@ def scrape_ttvh_article(url):
638
  return {"title":h1.get_text(strip=True) if h1 else fallback_title,"summary":desc,"og_image":og_img,"body":body,"source":"ttvh","url":url}
639
  except:return None
640
 
641
- VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
642
  @app.get("/api/homepage")
643
  def api_homepage():
644
  def _f():
@@ -785,7 +811,6 @@ def _article_by_url(url):
785
  return _scrape_generic_article(url)
786
 
787
  def _call_qwen(prompt, max_tokens=1800):
788
- """Try Qwen2.5-VL via HF router; return None if unavailable."""
789
  try:
790
  token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("VAISTUDIO")
791
  if not token:return None
@@ -814,7 +839,6 @@ def _ai_rewrite_article(data,tone="tu-nhien"):
814
  "TIÊU ĐỀ GỐC: "+title+"\n\nNỘI DUNG GỐC:\n"+text)
815
  out=_call_qwen(prompt,2200)
816
  if out and len(out)>300:return out.strip()
817
- # Fallback: complete non-truncated rewrite using full collected text chunks
818
  paras=[p.strip() for p in text.split("\n") if len(p.strip())>30]
819
  body="\n\n".join(paras[:18])
820
  bullets="\n".join(["• "+p[:220]+("..." if len(p)>220 else "") for p in paras[:5]])
@@ -841,9 +865,7 @@ def _topic_articles(topic,limit=5):
841
  return items
842
 
843
  def _topic_article_context(topic):
844
- """Filter readable article sources by topic, then summarize actual article bodies."""
845
  raw_keys=[k.lower() for k in re.findall(r"[\wÀ-ỹ]+",topic) if len(k)>2]
846
- # Drop ultra-generic tokens; keep domain words such as giáo/dục, bóng/đá, world/cup.
847
  stop={"trong","năm","the","and","của","cho","với","một","các","những","hiện","nay"}
848
  keys=[k for k in raw_keys if k not in stop]
849
  candidates=[];seen=set()
@@ -859,110 +881,3 @@ def _topic_article_context(topic):
859
  try:add_items(scrape_ttvh_worldcup())
860
  except:pass
861
  scored=[];img=""
862
- for a in candidates[:40]:
863
- data=_article_by_url(a.get("link",""))
864
- if not data or not data.get("body"):continue
865
- title=data.get("title") or a.get("title","")
866
- ps=[b.get("text","") for b in data.get("body",[]) if b.get("type")=="p" and len(b.get("text",""))>40]
867
- excerpt=" ".join(ps)[:1800] or data.get("summary","")
868
- hay=(title+" "+excerpt).lower()
869
- score=sum(1 for k in keys if k in hay)
870
- # Require topic relevance when we have meaningful keys.
871
- if keys and score==0:continue
872
- if len(keys)>=2 and score<2 and not any(" ".join(keys[i:i+2]) in hay for i in range(len(keys)-1)):continue
873
- scored.append((score,title,a.get("link",""),excerpt,data.get("og_image") or a.get("img","") or ""))
874
- scored=sorted(scored,key=lambda x:x[0],reverse=True)[:5]
875
- chunks=[]
876
- for score,title,link,excerpt,im in scored:
877
- if not img and im:img=im
878
- chunks.append("BÀI: "+title+"\nURL: "+link+"\nNỘI DUNG LỌC: "+excerpt)
879
- if chunks:return "\n\n".join(chunks),img
880
- return _web_context(topic),""
881
-
882
- def _topic_post_text(topic):
883
- ctx,img=_topic_article_context(topic)
884
- prompt=("Bạn là cây bút báo điện tử tiếng Việt. Hãy lọc các thông tin thực tế trong những nguồn dưới đây để viết một bài tóm tắt theo chủ đề: "+topic+
885
- ". Không viết chung chung. Chỉ dùng dữ kiện có trong nguồn; nếu nguồn khác nhau thì tổng hợp khách quan. "
886
- "Đầu ra gồm: tiêu đề, sapo, các ý chính theo bullet, phần phân tích ngắn và kết luận.\n\nNGUỒN THỰC TẾ:\n"+ctx)
887
- out=_call_qwen(prompt,1800)
888
- if out and len(out)>300:return out.strip()
889
- if ctx:
890
- return "Bài tóm tắt theo chủ đề: "+topic+"\n\nDữ liệu thực tế đã lọc:\n"+ctx[:3500]+"\n\nTóm tắt: Các nguồn trên cho thấy chủ đề này đang có nhiều diễn biến đáng chú ý. Khi viết bài, nên nêu rõ bối cảnh, các điểm mới, tác động thực tế và những điều còn cần kiểm chứng."
891
- return "Chưa thu thập được dữ liệu đủ rõ cho chủ đề: "+topic
892
-
893
- @app.get("/api/wall")
894
- def api_wall():return JSONResponse({"posts":_load_wall()[:50]})
895
-
896
- @app.post("/api/rewrite_share")
897
- async def api_rewrite_share(request:Request):
898
- try:body=await request.json()
899
- except:body={}
900
- url=str(body.get("url","")).strip();tone=str(body.get("tone","tu-nhien")).strip()
901
- if not url:return JSONResponse({"error":"missing url"},status_code=400)
902
- data=_article_by_url(url)
903
- if not data or not data.get("title") or (not data.get("body") and not data.get("summary")):
904
- return JSONResponse({"error":"Không đọc được bài viết"},status_code=422)
905
- post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,tone),"ts":int(time.time()),"source":data.get("source","")}
906
- posts=_load_wall();posts.insert(0,post);_save_wall(posts)
907
- return JSONResponse({"post":post})
908
-
909
- @app.post("/api/topic_post")
910
- async def api_topic_post(request:Request):
911
- try:body=await request.json()
912
- except:body={}
913
- topic=str(body.get("topic","")).strip()
914
- if not topic:return JSONResponse({"error":"missing topic"},status_code=400)
915
- ctx_img=_topic_article_context(topic)[1]
916
- post={"id":hashlib.md5((topic+str(time.time())).encode()).hexdigest()[:12],"url":"","title":topic,"img":ctx_img or _image_for_topic(topic),"text":_topic_post_text(topic),"ts":int(time.time()),"source":"ai-topic"}
917
- posts=_load_wall();posts.insert(0,post);_save_wall(posts)
918
- return JSONResponse({"post":post})
919
-
920
- @app.post("/api/url_wall")
921
- async def api_url_wall(request:Request):
922
- try:body=await request.json()
923
- except:body={}
924
- url=str(body.get("url","")).strip()
925
- if not url:return JSONResponse({"error":"missing url"},status_code=400)
926
- data=_article_by_url(url)
927
- if not data or not data.get("title"):
928
- return JSONResponse({"error":"Không đọc được URL"},status_code=422)
929
- post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,"ngan-gon-tu-nhien"),"ts":int(time.time()),"source":data.get("source","")}
930
- posts=_load_wall();posts.insert(0,post);_save_wall(posts)
931
- return JSONResponse({"post":post})
932
-
933
- @app.get("/v")
934
- async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")):
935
- decoded_url=unquote(url);decoded_title=unquote(title)
936
- redirect_script=f'<script>localStorage.setItem("pending_video",JSON.stringify({{"url":"{decoded_url}","type":"{type}"}}));location.href="{SPACE_URL}";</script>' if decoded_url else f'<script>location.href="{SPACE_URL}";</script>'
937
- return HTMLResponse(f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{decoded_title}</title></head><body style="background:#111;color:#fff;text-align:center;padding:40px"><p>⏳</p>{redirect_script}</body></html>')
938
- @app.get("/s")
939
- async def share_redirect(url:str=Query(default=""),title:str=Query(default="VNEWS"),img:str=Query(default="")):
940
- from urllib.parse import quote as _q
941
- decoded_url=unquote(url)
942
- if decoded_url and decoded_url.startswith("http"):
943
- # Redirect đến VNEWS với hash fragment chứa article URL
944
- # VNEWS frontend sẽ đọc hash và load article
945
- # Đồng thời thử localStorage cho browser support
946
- article_hash = _q(decoded_url, safe='')
947
- target = f"{SPACE_URL}#{article_hash}"
948
- redirect_html = f'''<!DOCTYPE html><html><head><meta charset="utf-8">
949
- <meta http-equiv="refresh" content="1;url={target}">
950
- <title>{unquote(title)[:80]}</title></head>
951
- <body style="background:#111;color:#fff;text-align:center;padding:40px;font-family:sans-serif">
952
- <p>⏳ Đang mở VNEWS...</p>
953
- <p style="font-size:11px;color:#888;word-break:break-all">{decoded_url[:120]}</p>
954
- <script>
955
- try {{
956
- localStorage.setItem("pending_article","{decoded_url}");
957
- }} catch(e) {{}}
958
- location.href="{target}";
959
- </script>
960
- <noscript><meta http-equiv="refresh" content="0;url={decoded_url}"></noscript>
961
- </body></html>'''
962
- return HTMLResponse(redirect_html)
963
- from fastapi.responses import RedirectResponse
964
- return RedirectResponse(url=SPACE_URL, status_code=302)
965
- @app.get("/")
966
- async def index():
967
- with open("/app/static/index.html","r",encoding="utf-8") as f:return HTMLResponse(content=f.read())
968
- app.mount("/static",StaticFiles(directory="/app/static"),name="static")
 
1
  """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube FPT shorts"""
2
  import hashlib, re, time, subprocess, json, os, threading
3
  import html as html_lib
4
+ from datetime import datetime, timedelta, timezone
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
6
  from fastapi import FastAPI, Query, Request
7
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
 
20
  BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
21
  BASE_BDP = "https://bongdaplus.vn"
22
  SPACE_URL = "https://bep40-vnews.hf.space"
23
+
24
+ # Vietnam timezone
25
+ VN_TZ = timezone(timedelta(hours=7))
26
+
27
  _cache = {}
28
  _cache_ttl = 300
29
  _cache_ttl_live = 60
 
97
  except:pass
98
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
99
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
100
+ HL_LEAGUES = {"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},"world-cup":{"path":"the-gioi/world-cup","name":"World Cup","emoji":"🌍"}}
101
  def _cached(key, fn, ttl=None):
102
  now=time.time();t=ttl or _cache_ttl
103
  if key in _cache and now-_cache[key]["t"]<t:return _cache[key]["d"]
 
180
 
181
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
182
  def _scrape_xemlaibongda_page(page_path, limit=20):
183
+ """Scrape video highlights from xemlaibongda.top."""
184
  try:
185
+ url = f"https://xemlaibongda.top/{page_path.lstrip('/')}" if page_path else "https://xemlaibongda.top/"
186
  r=requests.get(url,headers=HEADERS,timeout=15)
187
  if r.status_code!=200:return[]
188
  r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");videos=[];seen=set()
 
192
  if not href.startswith("http"):href="https://xemlaibongda.top"+href
193
  if href in seen:continue
194
  seen.add(href);slug=href.split("/video/")[-1].rstrip("/")
195
+ # Extract title from alt attribute of img inside <a>
 
 
196
  img=a.find("img") or (a.parent.find("img") if a.parent else None)
197
+ title=""
198
+ if img:
199
+ alt=img.get("alt","")
200
+ if alt.startswith("Thumbnail for "):
201
+ title=alt[len("Thumbnail for "):]
202
+ if not title:
203
+ title=slug.replace("-"," ").title()
204
+ title=re.sub(r'\d{4} \d{2} \d{2}$','',title).strip()
205
  img_src=""
206
+ if img:
207
+ img_src=img.get("data-src","") or img.get("src","") or img.get("data-lazy","")
208
+ if not img_src or "placeholder" in img_src:
209
+ # Use refooty thumbnail pattern
210
+ img_src=f"https://img.refooty.com/thumbnail/{slug}-1.webp"
211
+ if not img_src:
212
+ img_src=f"https://img.refooty.com/thumbnail/{slug}-1.webp"
213
+ videos.append({"title":title[:80],"link":href,"img":img_src,"source":"xemlaibongda"})
214
  if len(videos)>=limit:break
215
  return videos
216
  except:return[]
 
273
  if len(items)>=count:break
274
  return items
275
  except:return[]
276
+
277
+ def _scrape_vtvnambo_shorts(count=15):
278
+ """Scrape shorts from @vtvnambo channel."""
279
+ return _yt_channel_shorts("vtvnambo", count)
280
+
281
  def scrape_shorts():
282
  """Stable shorts feed: fast HTML scrape + static fallback so slide never disappears."""
283
  vids=[]
284
+ with ThreadPoolExecutor(3) as ex:
285
  futs=[ex.submit(_yt_channel_shorts,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte"]]
286
+ futs.append(ex.submit(_scrape_vtvnambo_shorts, 24))
287
  for f in as_completed(futs):
288
  try:
289
  r=f.result()
 
294
  vid=v.get("id")
295
  if not vid or vid in seen:continue
296
  seen.add(vid);merged.append(v)
297
+ return merged[:60]
298
+
299
+ # API endpoint for VTVNamBo shorts specifically
300
+ @app.get("/api/shorts/vtvnambo")
301
+ def api_shorts_vtvnambo():
302
+ """Get shorts from @vtvnambo YouTube channel."""
303
+ return JSONResponse(_cached("yt_vtvnambo", _scrape_vtvnambo_shorts, ttl=_cache_ttl_yt))
304
 
305
  # ===== LIVESCORE =====
306
  @app.get("/api/livescore/live")
 
309
  def api_livescore_incoming():return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)})
310
  @app.get("/api/livescore/today")
311
  def api_livescore_today():
312
+ today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)})
313
  @app.get("/api/livescore/results")
314
  def api_livescore_results():
315
+ today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
316
  @app.get("/api/livescore/standings/{league}")
317
  def api_livescore_standings(league:str):
318
  tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
 
328
 
329
  @app.get("/api/match/{event_id}/detail")
330
  def api_match_detail(event_id: int, url: str = Query(default="")):
 
331
  try:
332
  if url:
333
  data = fetch_match_detail_by_url(url)
 
340
  @app.get("/api/livescore/featured")
341
  def api_livescore_featured():
342
  def _f():
343
+ sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now(VN_TZ).strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")]
344
  for endpoint, stype in sources:
345
  html=fetch_bongda_api(endpoint)
346
  if not html or len(html)<100:continue
 
510
  return arts
511
  except:return[]
512
  def scrape_genk_ai():
 
513
  try:
514
  r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
515
  if r.status_code!=200:return[]
 
664
  return {"title":h1.get_text(strip=True) if h1 else fallback_title,"summary":desc,"og_image":og_img,"body":body,"source":"ttvh","url":url}
665
  except:return None
666
 
667
+ VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-trí","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
668
  @app.get("/api/homepage")
669
  def api_homepage():
670
  def _f():
 
811
  return _scrape_generic_article(url)
812
 
813
  def _call_qwen(prompt, max_tokens=1800):
 
814
  try:
815
  token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("VAISTUDIO")
816
  if not token:return None
 
839
  "TIÊU ĐỀ GỐC: "+title+"\n\nNỘI DUNG GỐC:\n"+text)
840
  out=_call_qwen(prompt,2200)
841
  if out and len(out)>300:return out.strip()
 
842
  paras=[p.strip() for p in text.split("\n") if len(p.strip())>30]
843
  body="\n\n".join(paras[:18])
844
  bullets="\n".join(["• "+p[:220]+("..." if len(p)>220 else "") for p in paras[:5]])
 
865
  return items
866
 
867
  def _topic_article_context(topic):
 
868
  raw_keys=[k.lower() for k in re.findall(r"[\wÀ-ỹ]+",topic) if len(k)>2]
 
869
  stop={"trong","năm","the","and","của","cho","với","một","các","những","hiện","nay"}
870
  keys=[k for k in raw_keys if k not in stop]
871
  candidates=[];seen=set()
 
881
  try:add_items(scrape_ttvh_worldcup())
882
  except:pass
883
  scored=[];img=""