bep40 commited on
Commit
73ea05e
·
verified ·
1 Parent(s): 4c5f84f

Fix: restore full main.py 951 lines with BONGDA_HEADERS

Browse files
Files changed (1) hide show
  1. main.py +831 -59
main.py CHANGED
@@ -1,68 +1,116 @@
1
- """
2
- VNEWS FastAPI backend with livescore + xemlaibongda highlights + YouTube FPT shorts
3
- """
4
- import re, json, os, time, threading
5
  from datetime import datetime
6
- from urllib.parse import quote
7
  from fastapi import FastAPI, Query, Request
8
- from fastapi.responses import HTMLResponse, JSONResponse, Response
9
  from fastapi.staticfiles import StaticFiles
10
- from fastapi.templating import Jinja2Templates
11
- from bs4 import BeautifulSoup
12
  import requests
 
 
 
13
 
14
- app = FastAPI(title="VNEWS")
15
- app.mount("/static", StaticFiles(directory="static"), name="static")
16
- templates = Jinja2Templates(directory="static")
17
-
18
- # ===== CONFIG =====
19
- HEADERS = {
20
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
21
- "Referer": "https://bongda.com.vn/",
22
- "Accept-Language": "vi-VN,vi;q=0.9",
23
- }
24
- BONGDA_API_HEADERS = {
25
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
26
- "Accept": "application/json, text/javascript, */*; q=0.01",
27
- "X-Requested-With": "XMLHttpRequest",
28
- "Referer": "https://bongda.com.vn/",
29
- }
30
- # Backward compatibility alias
31
- BONGDA_HEADERS = BONGDA_API_HEADERS
32
  _cache = {}
33
- _cache_lock = threading.Lock()
34
- _cache_ttl = 60
35
- _cache_ttl_live = 30
36
- _cache_ttl_yt = 300
37
- LEAGUE_IDS = {"nha": 27110, "laliga": 27113, "pl": 27109, "seria": 27116, "bundesliga": 27115, "ligue1": 27114, "ucl": 27108, "vleague": 27123, "worldcup": 27255}
38
- PRIORITY_LEAGUES = ["World Cup", "V.League", "Ngoại hạng Anh", "Premier League", "La Liga", "Serie A", "Bundesliga", "Ligue 1", "C1", "Champions League", "Europa", "C2", "C3", "ASEAN", "AFF", "U23", "Olympic", "Euro", "Copa"]
39
-
40
- def _cached(key, func, ttl=60):
41
- with _cache_lock:
42
- if key in _cache and time.time() - _cache[key][0] < ttl:
43
- return _cache[key][1]
44
- try:
45
- result = func()
46
- with _cache_lock:
47
- _cache[key] = (time.time(), result)
48
- return result
49
- except:
50
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def fetch_bongda_api(endpoint):
53
  try:
54
- r = requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_API_HEADERS, timeout=10)
55
- if r.status_code == 200:
56
- return r.json().get("html", "")
57
- except: pass
58
- return ""
59
-
60
- @app.get("/", response_class=HTMLResponse)
61
- async def index(request: Request):
62
- with open("static/index_v2.html", encoding="utf-8") as f:
63
- return HTMLResponse(f.read())
64
-
65
- # ===== PARSE MATCH FROM LI =====
66
  def _parse_match_from_li(li, status_type="live"):
67
  match_div=li.select_one("div.match")
68
  if not match_div:return None
@@ -79,7 +127,7 @@ def _parse_match_from_li(li, status_type="live"):
79
  if len(spans)>=4:minute=spans[3].get_text(strip=True)
80
  if not score and status_el and status_el.select_one(".vs"):score="VS"
81
  league=league_el.get_text(strip=True) if league_el else ""
82
- # Extract full URL with slug for featured match direct scraping
83
  match_url = ""
84
  if status_el:
85
  href = status_el.get("href", "")
@@ -119,8 +167,118 @@ def proxy_video(url: str = Query(...), request: Request = None):
119
  resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
120
  if "Content-Range" in r.headers:resp_headers["Content-Range"] = r.headers["Content-Range"]
121
  if "Content-Length" in r.headers:resp_headers["Content-Length"] = r.headers["Content-Length"]
122
- return Response(content=r.content,status_code=r.status_code,headers=resp_headers,media_type=r.headers.get("Content-Type","video/mp4"))
123
- except:return Response(status_code=502, content="error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  # ===== LIVESCORE =====
126
  @app.get("/api/livescore/live")
@@ -178,3 +336,617 @@ def api_livescore_featured():
178
  return all_matches[0]
179
  return None
180
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
  from fastapi.staticfiles import StaticFiles
9
+ from urllib.parse import unquote, quote, urlencode
 
10
  import requests
11
+ from bs4 import BeautifulSoup
12
+
13
+ app = FastAPI()
14
 
15
+ # ===== VTV CHANNELS API (VTV1-VTV10 + VTVPrime) =====
16
+ from vtv_api import router as vtv_router
17
+ app.include_router(vtv_router)
18
+
19
+ HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
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
26
+ _cache_ttl_yt = 1800
27
+ SHORTS_FALLBACK = [
28
+ {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ xử người phụ nữ chửi bới, tát nam tài xế ô tô ở Hà Nội | #shorts","channel":"baodantri7941"},
29
+ {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà mật người cha quá cố gửi 26 năm trước | #shorts","channel":"baodantri7941"},
30
+ {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt | #shorts","channel":"baodantri7941"},
31
+ {"id":"b1Nxzv9ixlU","title":"Y án 3 năm tù với nữ tài xế uống 8 lon bia lái xe tông chủ tịch xã tử vong | #shorts","channel":"baodantri7941"},
32
+ {"id":"Xp5eTwAZAis","title":"Người đánh hàng xóm tại chung cư ở Hà Nội bị tuyên hơn 4 tháng tù | #shorts","channel":"baodantri7941"},
33
+ {"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron đặc biệt? | #shorts","channel":"baodantri7941"},
34
+ {"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam | #shorts","channel":"baodantri7941"},
35
+ {"id":"IVaRc6moEv8","title":"Người nông dân Trung Quốc đột quỵ, bệnh viện giúp bán sạch 4 tấn táo | #shorts","channel":"baodantri7941"},
36
+ {"id":"uVxqPxToItU","title":"Công an vào cuộc vụ người phụ nữ chửi bới, hành hung tài xế ô tô ở Hà Nội | #shorts","channel":"baodantri7941"},
37
+ {"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai | #shorts","channel":"baodantri7941"},
38
+ {"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm? | #shorts","channel":"baodantri7941"},
39
+ {"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn | #shorts","channel":"baodantri7941"},
40
+ {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM | #shorts","channel":"baodantri7941"},
41
+ {"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu | #shorts","channel":"baodantri7941"},
42
+ {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg: Câu chuyện mưu sinh của người trẻ ở TPHCM | #shorts","channel":"baodantri7941"},
43
+ {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tham gia tìm kiếm 5 học sinh tử vong ở sông Lô | SKĐS","channel":"baosuckhoedoisongboyte"},
44
+ {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS","channel":"baosuckhoedoisongboyte"},
45
+ {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau? | SKĐS #shorts","channel":"baosuckhoedoisongboyte"},
46
+ {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô, đánh võng đăng TikTok bị xử lý | SKĐS","channel":"baosuckhoedoisongboyte"},
47
+ {"id":"OV7_oGdQGII","title":"Bố cô dâu khóc sụt sùi rồi quẩy cực sung gây bão mạng | SKĐS","channel":"baosuckhoedoisongboyte"},
48
+ {"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô, bẻ cần gạt nước ở Đà Nẵng | SKĐS","channel":"baosuckhoedoisongboyte"},
49
+ {"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái, đứng trên xe máy khi đổ đèo ở Đắk Lắk | SKĐS","channel":"baosuckhoedoisongboyte"},
50
+ {"id":"U0Ft6ChWAIo","title":"Cô giáo kể phút tháo chạy khỏi xe khách trước khi bị lũ vò nát ở Cao Bằng | SKĐS","channel":"baosuckhoedoisongboyte"},
51
+ {"id":"hH0ANeze_4E","title":"Liên tiếp hàng chục con bò bị sét đánh chết trong ngày mưa dông | SKĐS","channel":"baosuckhoedoisongboyte"},
52
+ {"id":"pXWt0QbAzRQ","title":"Va chạm giao thông, người phụ nữ lăng mạ tài xế ô tô | SKĐS","channel":"baosuckhoedoisongboyte"},
53
+ {"id":"UWWLPY1OYt4","title":"CSGT chặn xe khách khống chế đối tượng cướp dây chuyền tại Gia Lai | SKĐS","channel":"baosuckhoedoisongboyte"},
54
+ {"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp | SKĐS #shorts","channel":"baosuckhoedoisongboyte"},
55
+ {"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mà bay trong ngày cưới | SKĐS","channel":"baosuckhoedoisongboyte"},
56
+ {"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm, thanh niên đấm CSGT | SKĐS","channel":"baosuckhoedoisongboyte"},
57
+ {"id":"LFxJ9Ik6W0A","title":"Mệnh lệnh từ trái tim: CSGT Hà Nội mở đường đưa bé 5 tháng tuổi đi cấp cứu | SKĐS","channel":"baosuckhoedoisongboyte"}
58
+ ]
59
+ for _v in SHORTS_FALLBACK:
60
+ _v["link"]="https://www.youtube.com/watch?v="+_v["id"]
61
+ _v["img"]="https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg"
62
+ _v["source"]="yt"
63
+ SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
64
+ _short_lock = threading.Lock()
65
+ def _load_short_db():
66
+ try:
67
+ if os.path.exists(SHORT_STATS_FILE):
68
+ with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f:return json.load(f)
69
+ except:pass
70
+ return {}
71
+ def _save_short_db(db):
72
+ try:
73
+ os.makedirs(os.path.dirname(SHORT_STATS_FILE),exist_ok=True)
74
+ tmp=SHORT_STATS_FILE+".tmp"
75
+ with open(tmp,"w",encoding="utf-8") as f:json.dump(db,f,ensure_ascii=False)
76
+ os.replace(tmp,SHORT_STATS_FILE)
77
+ except:pass
78
 
79
+ def _short_default():return {"views":0,"likes":0,"shares":0,"comments":[]}
80
+ WALL_FILE = "/data/wall_posts.json" if os.path.isdir("/data") else "/app/wall_posts.json"
81
+ def _load_wall():
82
+ try:
83
+ if os.path.exists(WALL_FILE):
84
+ with open(WALL_FILE,"r",encoding="utf-8") as f:return json.load(f)
85
+ except:pass
86
+ return []
87
+ def _save_wall(posts):
88
+ try:
89
+ os.makedirs(os.path.dirname(WALL_FILE),exist_ok=True)
90
+ tmp=WALL_FILE+".tmp"
91
+ with open(tmp,"w",encoding="utf-8") as f:json.dump(posts[:100],f,ensure_ascii=False)
92
+ os.replace(tmp,WALL_FILE)
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"]
100
+ try:data=fn()
101
+ except:data=_cache.get(key,{}).get("d",[])
102
+ _cache[key]={"d":data,"t":now};return data
103
+ def _get(url,headers=None):
104
+ h=headers or HEADERS;r=requests.get(url,headers=h,timeout=15);r.encoding="utf-8"
105
+ return BeautifulSoup(r.text,"lxml")
106
  def fetch_bongda_api(endpoint):
107
  try:
108
+ r=requests.get(f"https://bongda.com.vn{endpoint}",headers=BONGDA_HEADERS,timeout=10)
109
+ if r.status_code==200:
110
+ data=r.json()
111
+ if data.get("status")=="success":return data.get("html","")
112
+ return ""
113
+ except:return ""
 
 
 
 
 
 
114
  def _parse_match_from_li(li, status_type="live"):
115
  match_div=li.select_one("div.match")
116
  if not match_div:return None
 
127
  if len(spans)>=4:minute=spans[3].get_text(strip=True)
128
  if not score and status_el and status_el.select_one(".vs"):score="VS"
129
  league=league_el.get_text(strip=True) if league_el else ""
130
+ # Extract full URL with slug for direct scraping
131
  match_url = ""
132
  if status_el:
133
  href = status_el.get("href", "")
 
167
  resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
168
  if "Content-Range" in r.headers:resp_headers["Content-Range"] = r.headers["Content-Range"]
169
  if "Content-Length" in r.headers:resp_headers["Content-Length"] = r.headers["Content-Length"]
170
+ return StreamingResponse(r.iter_content(chunk_size=256*1024),status_code=r.status_code,headers=resp_headers)
171
+ except:return Response(status_code=502, content="proxy error")
172
+
173
+ @app.get("/api/proxy/img")
174
+ def proxy_img(url: str = Query(...)):
175
+ """Proxy images from sources that block hotlinking (DanTri CDN)."""
176
+ try:
177
+ r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
178
+ if r.status_code != 200:return Response(status_code=502)
179
+ ct = r.headers.get("Content-Type", "image/jpeg")
180
+ return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
181
+ except:return Response(status_code=502)
182
+
183
+ # ===== XEMLAIBONGDA HIGHLIGHTS =====
184
+ def _scrape_xemlaibongda_page(page_path, limit=20):
185
+ try:
186
+ url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
187
+ r=requests.get(url,headers=HEADERS,timeout=15)
188
+ if r.status_code!=200:return[]
189
+ r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");videos=[];seen=set()
190
+ for a in soup.find_all("a",href=True):
191
+ href=a.get("href","")
192
+ if"/video/" not in href:continue
193
+ if not href.startswith("http"):href="https://xemlaibongda.top"+href
194
+ if href in seen:continue
195
+ seen.add(href);slug=href.split("/video/")[-1].rstrip("/")
196
+ title=slug.replace("-"," ").title()
197
+ title=re.sub(r'\d{4}\s*\d{2}\s*\d{2}$','',title).strip()
198
+ title=re.sub(r'\s+V\s+',' vs ',title);title=re.sub(r'\s+Vs\s+',' vs ',title)
199
+ img=a.find("img") or (a.parent.find("img") if a.parent else None)
200
+ img_src=""
201
+ if img:img_src=img.get("data-src","") or img.get("src","") or img.get("data-lazy","")
202
+ if not img_src:img_src=f"https://img.refooty.com/thumbnail/{slug}.webp"
203
+ videos.append({"title":title,"link":href,"img":img_src,"source":"xemlaibongda"})
204
+ if len(videos)>=limit:break
205
+ return videos
206
+ except:return[]
207
+
208
+ def scrape_xemlaibongda():return _scrape_xemlaibongda_page("",20)
209
+ def scrape_highlights_by_league(league_key):
210
+ if league_key not in HL_LEAGUES:return[]
211
+ return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"],20)
212
+
213
+ def scrape_all_league_highlights():
214
+ results = {}
215
+ def _fetch(key):return key, scrape_highlights_by_league(key)
216
+ with ThreadPoolExecutor(8) as ex:
217
+ futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
218
+ for f in as_completed(futs):
219
+ try:
220
+ key, vids = f.result()
221
+ if vids:results[key] = vids
222
+ except:pass
223
+ return results
224
+
225
+ def extract_xemlaibongda_video(url):
226
+ try:
227
+ r=requests.get(url,headers=HEADERS,timeout=15)
228
+ if r.status_code!=200:return None
229
+ r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");video=soup.find("video")
230
+ if video:
231
+ src=video.get("src","");poster=video.get("poster","")
232
+ if not src:
233
+ source=video.find("source")
234
+ if source:src=source.get("src","")
235
+ if src:return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
236
+ m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
237
+ if m3u8s:
238
+ og=soup.find("meta",property="og:image");poster=og.get("content","") if og else ""
239
+ return{"src":m3u8s[0],"poster":poster,"type":"hls"}
240
+ return None
241
+ except:return None
242
+
243
+ # ===== YOUTUBE SHORTS =====
244
+ def _yt_channel_shorts(channel, count=15):
245
+ """Fast scrape YouTube shorts tab without yt-dlp. Returns newest-first IDs/titles."""
246
+ try:
247
+ url=f"https://www.youtube.com/@{channel}/shorts"
248
+ r=requests.get(url,headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"},timeout=15)
249
+ if r.status_code!=200:return[]
250
+ html=r.text
251
+ ids=[];items=[]
252
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
253
+ vid=m.group(1)
254
+ if vid in ids:continue
255
+ ids.append(vid)
256
+ snip=html[max(0,m.start()-900):m.start()+1600]
257
+ title=""
258
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
259
+ if not mt:mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
260
+ if mt:title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
261
+ if not title:title="YouTube Short"
262
+ items.append({"title":title,"link":f"https://www.youtube.com/watch?v={vid}","img":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","source":"yt","id":vid,"channel":channel})
263
+ if len(items)>=count:break
264
+ return items
265
+ except:return[]
266
+ def scrape_shorts():
267
+ """Stable shorts feed: fast HTML scrape + static fallback so slide never disappears."""
268
+ vids=[]
269
+ with ThreadPoolExecutor(2) as ex:
270
+ futs=[ex.submit(_yt_channel_shorts,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte"]]
271
+ for f in as_completed(futs):
272
+ try:
273
+ r=f.result()
274
+ if r:vids.extend(r)
275
+ except:pass
276
+ merged=[];seen=set()
277
+ for v in vids+SHORTS_FALLBACK:
278
+ vid=v.get("id")
279
+ if not vid or vid in seen:continue
280
+ seen.add(vid);merged.append(v)
281
+ return merged[:40]
282
 
283
  # ===== LIVESCORE =====
284
  @app.get("/api/livescore/live")
 
336
  return all_matches[0]
337
  return None
338
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
339
+
340
+ # ===== VIDEO APIs =====
341
+ @app.get("/api/shorts")
342
+ def api_shorts():return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
343
+ @app.get("/api/short-stats")
344
+ def api_short_stats(ids:str=Query(default="")):
345
+ arr=[x for x in ids.split(",") if x]
346
+ with _short_lock:
347
+ db=_load_short_db();out={}
348
+ for vid in arr:
349
+ st=db.get(vid) or _short_default()
350
+ out[vid]={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
351
+ return JSONResponse({"stats":out})
352
+
353
+ @app.post("/api/short-action")
354
+ async def api_short_action(request:Request):
355
+ try:body=await request.json()
356
+ except:body={}
357
+ vid=str(body.get("id","")).strip();action=str(body.get("action","")).strip();txt=str(body.get("text","")).strip()
358
+ if not vid:return JSONResponse({"error":"missing id"},status_code=400)
359
+ with _short_lock:
360
+ db=_load_short_db();st=db.get(vid) or _short_default()
361
+ if action=="view":st["views"]=int(st.get("views",0))+1
362
+ elif action=="like":st["likes"]=int(st.get("likes",0))+1
363
+ elif action=="share":st["shares"]=int(st.get("shares",0))+1
364
+ elif action=="comment" and txt:
365
+ comments=st.get("comments",[])
366
+ comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
367
+ st["comments"]=comments[:80]
368
+ st["updated"]=int(time.time());db[vid]=st;_save_short_db(db)
369
+ out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
370
+ return JSONResponse({"stats":out})
371
+
372
+ @app.get("/api/highlights")
373
+ def api_highlights():return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
374
+ @app.get("/api/highlights/leagues")
375
+ def api_highlights_leagues():return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
376
+ @app.get("/api/highlights/{league}")
377
+ def api_highlights_league(league:str):
378
+ if league not in HL_LEAGUES:return JSONResponse({"error":"league not found"})
379
+ return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
380
+ @app.get("/api/highlights_config")
381
+ def api_highlights_config():return JSONResponse(HL_LEAGUES)
382
+ @app.get("/api/video_url")
383
+ def api_video_url(url:str=Query(...)):
384
+ if "youtube.com" in url or "youtu.be" in url:
385
+ m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
386
+ if m:vid=m.group(1);return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
387
+ if "xemlaibongda.top" in url:
388
+ v=extract_xemlaibongda_video(url)
389
+ if v:
390
+ if v["type"]=="hls":v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
391
+ return JSONResponse(v)
392
+ if "bongdaplus.vn" in url:
393
+ try:
394
+ m=re.search(r'-(\d{6,})\.html',url)
395
+ if m:
396
+ r=requests.get(f"{BASE_BDP}/video-embed/{m.group(1)}.html",headers=HEADERS,timeout=10);r.encoding="utf-8"
397
+ soup=BeautifulSoup(r.text,"lxml");video=soup.select_one("video#videoPlayer")
398
+ if video:
399
+ source=video.find("source");src=source.get("src","") if source else "";poster=video.get("poster","")
400
+ if src:return JSONResponse({"src":"/api/proxy/video?url="+quote(src,safe=""),"poster":poster,"type":"video"})
401
+ except:pass
402
+ return JSONResponse({"error":"not found"})
403
+ @app.get("/api/bdp_videos")
404
+ def api_bdp_videos():
405
+ def _f():
406
+ try:
407
+ soup=_get(f"{BASE_BDP}/video");arts=[];seen=set()
408
+ for a in soup.find_all("a",href=True):
409
+ href=a.get("href","")
410
+ if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
411
+ if not href.startswith("http"):href=BASE_BDP+href
412
+ if href in seen:continue
413
+ title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
414
+ if not title or len(title)<5:continue
415
+ img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
416
+ img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
417
+ seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bdp"})
418
+ return arts[:20]
419
+ except:return[]
420
+ return JSONResponse(_cached("bdp_videos",_f))
421
+ # ===== NEWS =====
422
+ def scrape_vne(cat_url):
423
+ try:
424
+ soup=_get(cat_url);arts=[]
425
+ for it in soup.select("article.item-news")[:15]:
426
+ a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
427
+ if not a:continue
428
+ t=a.get("title","") or a.get_text(strip=True);lk=a.get("href","")
429
+ if not t or not lk:continue
430
+ im=it.find("img");img=(im.get("data-src") or im.get("src","")) if im else ""
431
+ if img and'blank'in img:
432
+ src=it.find("source")
433
+ if src:img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
434
+ arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
435
+ return arts
436
+ except:return[]
437
+ def scrape_vne_article(url):
438
+ try:
439
+ soup=_get(url);h1=soup.select_one("h1.title-detail");desc=soup.select_one("p.description")
440
+ og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
441
+ cd=soup.select_one("article.fck_detail");body=[]
442
+ if cd:
443
+ for ch in cd.children:
444
+ if not hasattr(ch,'name') or not ch.name:continue
445
+ if ch.name=="p":t=ch.get_text(strip=True);(body.append({"type":"p","text":t}) if t else None)
446
+ elif ch.name=="figure":
447
+ im=ch.find("img")
448
+ if im:s=im.get("data-src") or im.get("src","");body.append({"type":"img","src":s})
449
+ elif ch.name in("h2","h3"):body.append({"type":"heading","text":ch.get_text(strip=True)})
450
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc.get_text(strip=True) if desc else "","og_image":og_img,"body":body,"source":"vne","url":url}
451
+ except:return None
452
+ def _scrape_dantri_homepage(cat_filter=None):
453
+ try:
454
+ soup=_get("https://dantri.com.vn/");arts=[];seen=set()
455
+ for a in soup.find_all("a",href=True):
456
+ href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
457
+ if not title or len(title)<15 or"javascript:" in href:continue
458
+ if not href.startswith("http"):href="https://dantri.com.vn"+href
459
+ if href in seen or not href.endswith(".htm"):continue
460
+ if cat_filter and f"/{cat_filter}/" not in href:continue
461
+ img_tag=a.find("img")
462
+ if not img_tag and a.parent:img_tag=a.parent.find("img")
463
+ img_src=""
464
+ if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
465
+ if not img_src or "cdn" not in img_src:continue
466
+ proxied_img="/api/proxy/img?url="+quote(img_src,safe="")
467
+ seen.add(href);arts.append({"title":title,"link":href,"img":proxied_img,"source":"dantri"})
468
+ if len(arts)>=15:break
469
+ return arts
470
+ except:return[]
471
+ def scrape_dantri_hot():return _scrape_dantri_homepage()
472
+ def scrape_dantri_congnghe():
473
+ try:
474
+ soup=_get("https://dantri.com.vn/");arts=[];seen=set()
475
+ for a in soup.find_all("a",href=True):
476
+ href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
477
+ if not title or len(title)<15 or"javascript:" in href:continue
478
+ if not href.startswith("http"):href="https://dantri.com.vn"+href
479
+ if href in seen or not href.endswith(".htm"):continue
480
+ if"/cong-nghe/" not in href:continue
481
+ img_tag=a.find("img")
482
+ if not img_tag and a.parent:img_tag=a.parent.find("img")
483
+ img_src=""
484
+ if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
485
+ if img_src and "cdn" in img_src:img_src="/api/proxy/img?url="+quote(img_src,safe="")
486
+ else:img_src=""
487
+ seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"dantri"})
488
+ if len(arts)>=15:break
489
+ return arts
490
+ except:return[]
491
+ def scrape_genk_ai():
492
+ """Scrape AI articles from genk.vn - readable in-app"""
493
+ try:
494
+ r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
495
+ if r.status_code!=200:return[]
496
+ r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
497
+ articles=[];seen=set()
498
+ for a in soup.find_all("a",href=True):
499
+ href=a.get("href","")
500
+ if not href.endswith(".chn") or href=="/ai.chn":continue
501
+ if href.startswith("/"):href="https://genk.vn"+href
502
+ if href in seen or "genk.vn" not in href:continue
503
+ title=a.get("title","") or a.get_text(strip=True)
504
+ if not title or len(title)<20:continue
505
+ container=a.parent;img_src=""
506
+ for _ in range(6):
507
+ if container is None:break
508
+ for img in container.find_all("img"):
509
+ s=img.get("data-src","") or img.get("src","")
510
+ if s and "mediacdn" in s and "avatar" not in s and "logo" not in s:
511
+ img_src=s;break
512
+ if img_src:break
513
+ container=container.parent
514
+ seen.add(href)
515
+ if not img_src:
516
+ try:
517
+ og_r=requests.get(href,headers=HEADERS,timeout=8);og_r.encoding="utf-8"
518
+ og_soup=BeautifulSoup(og_r.text,"lxml");og_tag=og_soup.find("meta",property="og:image")
519
+ if og_tag:img_src=og_tag.get("content","")
520
+ except:pass
521
+ articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
522
+ if len(articles)>=30:break
523
+ return articles
524
+ except:return[]
525
+
526
+ def scrape_dantri_article(url):
527
+ try:
528
+ r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
529
+ for tag in soup.find_all(["script","style","nav","footer","aside"]):tag.decompose()
530
+ h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
531
+ if og_img and "cdnphoto.dantri" in og_img:og_img="/api/proxy/img?url="+quote(og_img,safe="")
532
+ content=soup.select_one("main") or soup.select_one("div.singular-content") or soup.select_one("article");body=[]
533
+ if content:
534
+ for el in content.find_all(["p","h2","h3","figure","img"],recursive=True):
535
+ if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
536
+ elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
537
+ elif el.name in("figure","img"):
538
+ im=el if el.name=="img" else el.find("img")
539
+ if im:
540
+ s=im.get("data-src") or im.get("src","")
541
+ if s and"base64" not in s:
542
+ if "cdnphoto.dantri" in s:s="/api/proxy/img?url="+quote(s,safe="")
543
+ body.append({"type":"img","src":s})
544
+ desc="";sapo=soup.select_one("h2.singular-sapo") or soup.select_one("h2[class*=sapo]")
545
+ if not sapo:
546
+ og_desc=soup.find("meta",property="og:description")
547
+ if og_desc:desc=og_desc.get("content","")
548
+ else:desc=sapo.get_text(strip=True)
549
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"dantri","url":url}
550
+ except:return None
551
+ def scrape_bbc_vietnamese():
552
+ try:
553
+ r=requests.get("https://www.bbc.com/vietnamese",headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
554
+ soup=BeautifulSoup(r.text,"lxml");arts=[];seen=set()
555
+ for a in soup.select("a[href*='/vietnamese/']"):
556
+ href=a.get("href","")
557
+ if not href or href=="/vietnamese" or href.count("/")<3:continue
558
+ if not href.startswith("http"):href="https://www.bbc.com"+href
559
+ if href in seen:continue
560
+ title=a.get_text(strip=True)
561
+ if not title or len(title)<15 or any(x in title.lower() for x in["đăng nhập","trang chủ","bbc news"]):continue
562
+ img="";container=a.parent
563
+ for _ in range(3):
564
+ if container:
565
+ im=container.find("img")
566
+ if im:img=im.get("src","") or im.get("data-src","");break
567
+ container=container.parent
568
+ seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bbc"})
569
+ if len(arts)>=15:break
570
+ return arts
571
+ except:return[]
572
+ def scrape_bbc_article(url):
573
+ try:
574
+ r=requests.get(url,headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
575
+ soup=BeautifulSoup(r.text,"lxml");h1=soup.find("h1")
576
+ og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
577
+ body=[]
578
+ for p in soup.select("[data-component='text-block'] p, article p, main p"):
579
+ t=p.get_text(strip=True)
580
+ if t and len(t)>20:body.append({"type":"p","text":t})
581
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":"","og_image":og_img,"body":body,"source":"bbc","url":url}
582
+ except:return None
583
+
584
+ def scrape_ttvh_worldcup():
585
+ """Scrape all World Cup 2026 articles from The Thao Van Hoa RSS."""
586
+ try:
587
+ r=requests.get("https://thethaovanhoa.vn/rss/world-cup-2026.rss",headers=HEADERS,timeout=15);r.encoding="utf-8"
588
+ soup=BeautifulSoup(r.text,"xml");arts=[];seen=set()
589
+ for it in soup.find_all("item"):
590
+ title=(it.find("title").get_text(strip=True) if it.find("title") else "")
591
+ link=(it.find("link").get_text(strip=True) if it.find("link") else "")
592
+ desc=(it.find("description").get_text(" ",strip=True) if it.find("description") else "")
593
+ img="";ds=BeautifulSoup(desc,"lxml");im=ds.find("img")
594
+ if im:img=im.get("src","") or im.get("data-src","")
595
+ if title and link and link not in seen:
596
+ seen.add(link);arts.append({"title":title,"link":link,"img":img,"source":"ttvh"})
597
+ if arts:return arts
598
+ except:pass
599
+ try:
600
+ soup=_get("https://thethaovanhoa.vn/world-cup-2026.htm");arts=[];seen=set()
601
+ for a in soup.find_all("a",href=True):
602
+ href=a.get("href","")
603
+ if not href.startswith("http"):href="https://thethaovanhoa.vn"+href
604
+ if href in seen or "thethaovanhoa.vn" not in href:continue
605
+ if not re.search(r"/[^/]+-\d{8,}\.htm",href):continue
606
+ title=a.get("title","") or a.get_text(" ",strip=True)
607
+ img=None;p=a
608
+ for _ in range(5):
609
+ if p is None:break
610
+ img=p.find("img")
611
+ if img:break
612
+ p=p.parent
613
+ img_src=""
614
+ if img:
615
+ img_src=img.get("data-src","") or img.get("src","") or img.get("data-original","") or img.get("data-thumb","")
616
+ if len(title)<15:title=img.get("alt","") or img.get("title","") or title
617
+ if not title or len(title)<15:continue
618
+ seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"ttvh"})
619
+ if len(arts)>=24:break
620
+ return arts
621
+ except:return[]
622
+
623
+ def scrape_ttvh_article(url):
624
+ try:
625
+ soup=_get(url);h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
626
+ og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
627
+ desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
628
+ cd=soup.select_one(".detail-content") or soup.select_one(".content-detail") or soup.select_one("article") or soup.select_one("main")
629
+ body=[]
630
+ if cd:
631
+ for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
632
+ if el.name=="p":
633
+ t=el.get_text(strip=True)
634
+ if t and len(t)>20 and "Theo dõi" not in t:body.append({"type":"p","text":t})
635
+ elif el.name in ("h2","h3"):
636
+ t=el.get_text(strip=True)
637
+ if t:body.append({"type":"heading","text":t})
638
+ elif el.name in ("figure","img"):
639
+ im=el if el.name=="img" else el.find("img")
640
+ if im:
641
+ src=im.get("data-src") or im.get("src","") or im.get("data-original","")
642
+ if src and "base64" not in src:body.append({"type":"img","src":src})
643
+ if not body and desc:body=[{"type":"p","text":desc}]
644
+ return {"title":h1.get_text(strip=True) if h1 else fallback_title,"summary":desc,"og_image":og_img,"body":body,"source":"ttvh","url":url}
645
+ except:return None
646
+
647
+ 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")}
648
+ @app.get("/api/homepage")
649
+ def api_homepage():
650
+ def _f():
651
+ articles=[]
652
+ with ThreadPoolExecutor(12) as ex:
653
+ futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]}
654
+ futs[ex.submit(scrape_bbc_vietnamese)]="BBC"
655
+ for f in as_completed(futs):
656
+ try:
657
+ for a in f.result():a["group"]=futs[f];articles.append(a)
658
+ except:pass
659
+ return articles
660
+ return JSONResponse(_cached("homepage",_f))
661
+ @app.get("/api/category/{cat_id}")
662
+ def api_category(cat_id:str):
663
+ def _f():
664
+ if cat_id=="bbc":return scrape_bbc_vietnamese()
665
+ if cat_id=="cong-nghe":return scrape_genk_ai()
666
+ if cat_id in VNE_CATS:arts=scrape_vne(VNE_CATS[cat_id][0]);[a.update({"group":VNE_CATS[cat_id][1]}) for a in arts];return arts
667
+ return[]
668
+ return JSONResponse(_cached(f"cat_{cat_id}",_f))
669
+ @app.get("/api/categories")
670
+ def api_categories():
671
+ cats=[{"id":"bbc","name":"BBC Tiếng Việt","source":"bbc"},{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
672
+ for k,(u,n) in VNE_CATS.items():cats.append({"id":k,"name":n,"source":"vne"})
673
+ return JSONResponse(cats)
674
+ @app.get("/api/dantri_hot")
675
+ def api_dantri_hot():return JSONResponse(_cached("dantri_hot",scrape_dantri_hot))
676
+ @app.get("/api/genk_ai")
677
+ def api_genk_ai():return JSONResponse(_cached("genk_ai",scrape_genk_ai,ttl=_cache_ttl))
678
+ @app.get("/api/worldcup2026")
679
+ def api_worldcup2026():return JSONResponse(_cached("ttvh_worldcup",scrape_ttvh_worldcup,ttl=_cache_ttl))
680
+ def scrape_genk_article(url):
681
+ try:
682
+ r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
683
+ h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
684
+ og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
685
+ desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
686
+ cd=soup.select_one(".knc-content");body=[]
687
+ if cd:
688
+ for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
689
+ if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
690
+ elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
691
+ elif el.name in("figure","img"):
692
+ im=el if el.name=="img" else el.find("img")
693
+ if im:s=im.get("data-src") or im.get("src","");(body.append({"type":"img","src":s}) if s and"base64" not in s else None)
694
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"genk","url":url}
695
+ except:return None
696
+
697
+ @app.get("/api/article")
698
+ def api_article(url:str=Query(...)):
699
+ if"vnexpress.net" in url:data=scrape_vne_article(url)
700
+ elif"bbc.com" in url:data=scrape_bbc_article(url)
701
+ elif"dantri.com.vn" in url:data=scrape_dantri_article(url)
702
+ elif"genk.vn" in url:data=scrape_genk_article(url)
703
+ elif"thethaovanhoa.vn" in url:data=scrape_ttvh_article(url)
704
+ else:data=None
705
+ return JSONResponse(data if data else{"error":"not supported"})
706
+ def _web_context(topic):
707
+ """Collect real web/news context for a topic."""
708
+ bits=[]
709
+ try:
710
+ rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
711
+ r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
712
+ soup=BeautifulSoup(r.text,"xml")
713
+ for it in soup.find_all("item")[:8]:
714
+ title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
715
+ src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
716
+ if title:bits.append((title+(" — "+src if src else ""))[:280])
717
+ except:pass
718
+ if bits:return "\n".join(bits)
719
+ try:
720
+ r=requests.get("https://html.duckduckgo.com/html/?q="+quote(topic),headers=HEADERS,timeout=12);r.encoding="utf-8"
721
+ soup=BeautifulSoup(r.text,"lxml")
722
+ for res in soup.select(".result")[:6]:
723
+ t=res.select_one(".result__title");sn=res.select_one(".result__snippet")
724
+ line=((t.get_text(" ",strip=True) if t else "")+" — "+(sn.get_text(" ",strip=True) if sn else "")).strip(" —")
725
+ if line:bits.append(line[:280])
726
+ except:pass
727
+ return "\n".join(bits)
728
+
729
+ def _jina_read(url):
730
+ try:
731
+ ju="https://r.jina.ai/http://"+url
732
+ r=requests.get(ju,headers=HEADERS,timeout=25);r.encoding="utf-8"
733
+ if r.status_code!=200 or not r.text:return None
734
+ lines=[x.rstrip() for x in r.text.splitlines()]
735
+ title="";img="";body=[];summary=""
736
+ for ln in lines[:40]:
737
+ if ln.startswith("Title:"):title=ln.replace("Title:","",1).strip()
738
+ elif ln.startswith("Image:"):img=ln.replace("Image:","",1).strip()
739
+ elif ln.startswith("Description:"):summary=ln.replace("Description:","",1).strip()
740
+ for ln in lines:
741
+ t=ln.strip()
742
+ if not t or t.startswith(("Title:","URL Source:","Published Time:","Markdown Content:","Image:","Description:")):continue
743
+ if len(t)>40:body.append({"type":"p","text":t})
744
+ if not body and summary:body=[{"type":"p","text":summary}]
745
+ return {"title":title or url,"summary":summary,"og_image":img,"body":body[:80],"source":"jina","url":url}
746
+ except:return None
747
+
748
+ def _scrape_generic_article(url):
749
+ try:
750
+ hdr={**HEADERS,"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
751
+ r=requests.get(url,headers=hdr,timeout=15);r.encoding="utf-8"
752
+ ct=r.headers.get("content-type","").lower()
753
+ if r.status_code>=400 or "text/html" not in ct:
754
+ jr=_jina_read(url)
755
+ if jr:return jr
756
+ soup=BeautifulSoup(r.text,"lxml")
757
+ for tag in soup.find_all(["script","style","nav","footer","aside","form"]):tag.decompose()
758
+ h1=soup.find("h1")
759
+ ogt=soup.find("meta",property="og:title");title=h1.get_text(strip=True) if h1 else (ogt.get("content","") if ogt else "")
760
+ ogd=soup.find("meta",property="og:description");desc=ogd.get("content","") if ogd else ""
761
+ ogi=soup.find("meta",property="og:image");img=ogi.get("content","") if ogi else ""
762
+ main=soup.find("article") or soup.find("main") or soup.body
763
+ body=[]
764
+ if main:
765
+ for el in main.find_all(["p","h2","h3","figure","img"],recursive=True):
766
+ if el.name=="p":
767
+ t=el.get_text(" ",strip=True)
768
+ if t and len(t)>35:body.append({"type":"p","text":t})
769
+ elif el.name in ("h2","h3"):
770
+ t=el.get_text(" ",strip=True)
771
+ if t:body.append({"type":"heading","text":t})
772
+ elif el.name in ("figure","img"):
773
+ im=el if el.name=="img" else el.find("img")
774
+ if im:
775
+ src=im.get("data-src") or im.get("src","") or im.get("data-original","")
776
+ if src and "base64" not in src:body.append({"type":"img","src":src})
777
+ if not body:
778
+ jr=_jina_read(url)
779
+ if jr and jr.get("body"):return jr
780
+ if not body and desc:body=[{"type":"p","text":desc}]
781
+ return {"title":title or url,"summary":desc,"og_image":img,"body":body,"source":"generic","url":url}
782
+ except:
783
+ return _jina_read(url)
784
+
785
+ def _article_by_url(url):
786
+ if "vnexpress.net" in url:return scrape_vne_article(url)
787
+ if "bbc.com" in url:return scrape_bbc_article(url)
788
+ if "dantri.com.vn" in url:return scrape_dantri_article(url)
789
+ if "genk.vn" in url:return scrape_genk_article(url)
790
+ if "thethaovanhoa.vn" in url:return scrape_ttvh_article(url)
791
+ return _scrape_generic_article(url)
792
+
793
+ def _call_qwen(prompt, max_tokens=1800):
794
+ """Try Qwen2.5-VL via HF router; return None if unavailable."""
795
+ try:
796
+ token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("VAISTUDIO")
797
+ if not token:return None
798
+ headers={"Authorization":"Bearer "+token,"Content-Type":"application/json"}
799
+ payload={"model":"Qwen/Qwen2.5-VL-7B-Instruct","messages":[{"role":"user","content":prompt}],"max_tokens":max_tokens,"temperature":0.7}
800
+ r=requests.post("https://router.huggingface.co/v1/chat/completions",headers=headers,json=payload,timeout=75)
801
+ if r.status_code>=300:return None
802
+ j=r.json();return j.get("choices",[{}])[0].get("message",{}).get("content")
803
+ except:return None
804
+
805
+ def _collect_article_text(data, limit=28000):
806
+ title=(data or {}).get("title","");summary=(data or {}).get("summary","")
807
+ parts=[]
808
+ if summary:parts.append(summary)
809
+ for b in (data or {}).get("body",[]):
810
+ if b.get("type")=="heading":parts.append("## "+b.get("text","") )
811
+ elif b.get("type")=="p":parts.append(b.get("text","") )
812
+ text="\n".join([p.strip() for p in parts if p and p.strip()])
813
+ return title,text[:limit]
814
+
815
+ def _ai_rewrite_article(data,tone="tu-nhien"):
816
+ title,text=_collect_article_text(data)
817
+ prompt=("Bạn là biên tập viên báo điện tử tiếng Việt. Hãy viết lại bài dưới đây bằng ngôn ngữ tự nhiên, mạch lạc, không cắt khúc, không bỏ ý quan trọng. "
818
+ "Giữ đúng sự thật, không bịa, không thêm thông tin ngoài bài. Văn phong: "+tone+". "
819
+ "Đầu ra gồm: tiêu đề hấp dẫn, đoạn sapo 2-3 câu, các đoạn nội dung ngắn dễ đọc, và 3 gạch đầu dòng điểm chính.\n\n"
820
+ "TIÊU ĐỀ GỐC: "+title+"\n\nNỘI DUNG GỐC:\n"+text)
821
+ out=_call_qwen(prompt,2200)
822
+ if out and len(out)>300:return out.strip()
823
+ # Fallback: complete non-truncated rewrite using full collected text chunks
824
+ paras=[p.strip() for p in text.split("\n") if len(p.strip())>30]
825
+ body="\n\n".join(paras[:18])
826
+ bullets="\n".join(["• "+p[:220]+("..." if len(p)>220 else "") for p in paras[:5]])
827
+ return ("Bản tin AI viết lại: "+title+"\n\n"+
828
+ (paras[0] if paras else "")+"\n\n"+body+"\n\nĐiểm chính:\n"+bullets).strip()
829
+
830
+ def _image_for_topic(topic):
831
+ return "https://image.pollinations.ai/prompt/"+quote("editorial illustration, Vietnamese news, "+topic,safe="")+"?width=1024&height=576&nologo=true"
832
+
833
+ def _topic_articles(topic,limit=5):
834
+ items=[];seen=set()
835
+ try:
836
+ rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
837
+ r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
838
+ soup=BeautifulSoup(r.text,"xml")
839
+ for it in soup.find_all("item")[:limit*3]:
840
+ title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
841
+ link=it.find("link").get_text(strip=True) if it.find("link") else ""
842
+ src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
843
+ if not title or not link or link in seen:continue
844
+ seen.add(link);items.append({"title":title,"link":link,"source":src})
845
+ if len(items)>=limit:break
846
+ except:pass
847
+ return items
848
+
849
+ def _topic_article_context(topic):
850
+ """Filter readable article sources by topic, then summarize actual article bodies."""
851
+ raw_keys=[k.lower() for k in re.findall(r"[\wÀ-ỹ]+",topic) if len(k)>2]
852
+ # Drop ultra-generic tokens; keep domain words such as giáo/dục, bóng/đá, world/cup.
853
+ stop={"trong","năm","the","and","của","cho","với","một","các","những","hiện","nay"}
854
+ keys=[k for k in raw_keys if k not in stop]
855
+ candidates=[];seen=set()
856
+ def add_items(items):
857
+ for a in items or []:
858
+ link=a.get("link","");title=a.get("title","")
859
+ if not link or link in seen:continue
860
+ seen.add(link);candidates.append(a)
861
+ try:add_items(scrape_genk_ai())
862
+ except:pass
863
+ try:add_items(scrape_dantri_congnghe())
864
+ except:pass
865
+ try:add_items(scrape_ttvh_worldcup())
866
+ except:pass
867
+ scored=[];img=""
868
+ for a in candidates[:40]:
869
+ data=_article_by_url(a.get("link",""))
870
+ if not data or not data.get("body"):continue
871
+ title=data.get("title") or a.get("title","")
872
+ ps=[b.get("text","") for b in data.get("body",[]) if b.get("type")=="p" and len(b.get("text",""))>40]
873
+ excerpt=" ".join(ps)[:1800] or data.get("summary","")
874
+ hay=(title+" "+excerpt).lower()
875
+ score=sum(1 for k in keys if k in hay)
876
+ # Require topic relevance when we have meaningful keys.
877
+ if keys and score==0:continue
878
+ 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
879
+ scored.append((score,title,a.get("link",""),excerpt,data.get("og_image") or a.get("img","") or ""))
880
+ scored=sorted(scored,key=lambda x:x[0],reverse=True)[:5]
881
+ chunks=[]
882
+ for score,title,link,excerpt,im in scored:
883
+ if not img and im:img=im
884
+ chunks.append("BÀI: "+title+"\nURL: "+link+"\nNỘI DUNG LỌC: "+excerpt)
885
+ if chunks:return "\n\n".join(chunks),img
886
+ return _web_context(topic),""
887
+
888
+ def _topic_post_text(topic):
889
+ ctx,img=_topic_article_context(topic)
890
+ 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+
891
+ ". 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. "
892
+ "Đầ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)
893
+ out=_call_qwen(prompt,1800)
894
+ if out and len(out)>300:return out.strip()
895
+ if ctx:
896
+ 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."
897
+ return "Chưa thu thập được dữ liệu đủ rõ cho chủ đề: "+topic
898
+
899
+ @app.get("/api/wall")
900
+ def api_wall():return JSONResponse({"posts":_load_wall()[:50]})
901
+
902
+ @app.post("/api/rewrite_share")
903
+ async def api_rewrite_share(request:Request):
904
+ try:body=await request.json()
905
+ except:body={}
906
+ url=str(body.get("url","")).strip();tone=str(body.get("tone","tu-nhien")).strip()
907
+ if not url:return JSONResponse({"error":"missing url"},status_code=400)
908
+ data=_article_by_url(url)
909
+ if not data or not data.get("title") or (not data.get("body") and not data.get("summary")):
910
+ return JSONResponse({"error":"Không đọc được bài viết"},status_code=422)
911
+ 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","")}
912
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
913
+ return JSONResponse({"post":post})
914
+
915
+ @app.post("/api/topic_post")
916
+ async def api_topic_post(request:Request):
917
+ try:body=await request.json()
918
+ except:body={}
919
+ topic=str(body.get("topic","")).strip()
920
+ if not topic:return JSONResponse({"error":"missing topic"},status_code=400)
921
+ ctx_img=_topic_article_context(topic)[1]
922
+ 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"}
923
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
924
+ return JSONResponse({"post":post})
925
+
926
+ @app.post("/api/url_wall")
927
+ async def api_url_wall(request:Request):
928
+ try:body=await request.json()
929
+ except:body={}
930
+ url=str(body.get("url","")).strip()
931
+ if not url:return JSONResponse({"error":"missing url"},status_code=400)
932
+ data=_article_by_url(url)
933
+ if not data or not data.get("title"):
934
+ return JSONResponse({"error":"Không đọc được URL"},status_code=422)
935
+ 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","")}
936
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
937
+ return JSONResponse({"post":post})
938
+
939
+ @app.get("/v")
940
+ async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")):
941
+ decoded_url=unquote(url);decoded_title=unquote(title)
942
+ 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>'
943
+ 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>')
944
+ @app.get("/s")
945
+ async def share_redirect(url:str=Query(default=""),title:str=Query(default="VNEWS"),img:str=Query(default="")):
946
+ decoded_url=unquote(url)
947
+ redirect_script=f'<script>localStorage.setItem("pending_article","{decoded_url}");location.href="{SPACE_URL}";</script>' if decoded_url else f'<script>location.href="{SPACE_URL}";</script>'
948
+ return HTMLResponse(f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{unquote(title)}</title></head><body>{redirect_script}</body></html>')
949
+ @app.get("/")
950
+ async def index():
951
+ with open("/app/static/index.html","r",encoding="utf-8") as f:return HTMLResponse(content=f.read())
952
+ app.mount("/static",StaticFiles(directory="/app/static"),name="static")