Spaces:
Running
Running
Update main.py
Browse files
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
|
| 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 |
-
|
| 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:
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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[:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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=""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|