bep40 commited on
Commit
0e2f303
·
verified ·
1 Parent(s): cf826e9

Upload main.py with huggingface_hub

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