bep40 commited on
Commit
d5ba0b5
·
verified ·
1 Parent(s): a478c48

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +65 -120
main.py CHANGED
@@ -13,7 +13,7 @@ from bs4 import BeautifulSoup
13
 
14
  app = FastAPI()
15
 
16
- # ===== VTV CHANNELS API (VTV1-VTV10 + VTVPrime) =====
17
  from vtv_api import router as vtv_router
18
  app.include_router(vtv_router)
19
 
@@ -27,12 +27,9 @@ _cache_ttl_live = 60
27
  _cache_ttl_yt = 1800
28
 
29
  # ===== VTV NAM BỘ SHORTS FALLBACK =====
30
- # These are ALWAYS available even if all scraping methods fail
31
  SHORTS_FALLBACK = [
32
- # VTV Nam Bộ shorts (verified working)
33
  {"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
34
  {"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
35
- # Dân trí shorts
36
  {"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","channel":"baodantri7941"},
37
  {"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","channel":"baodantri7941"},
38
  {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
@@ -48,8 +45,7 @@ SHORTS_FALLBACK = [
48
  {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","channel":"baodantri7941"},
49
  {"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu","channel":"baodantri7941"},
50
  {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg: Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
51
- # SKĐS shorts
52
- {"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ô","channel":"baosuckhoedoisongboyte"},
53
  {"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","channel":"baosuckhoedoisongboyte"},
54
  {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
55
  {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô, đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
@@ -72,14 +68,12 @@ for _v in SHORTS_FALLBACK:
72
 
73
  SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
74
  _short_lock = threading.Lock()
75
-
76
  def _load_short_db():
77
  try:
78
  if os.path.exists(SHORT_STATS_FILE):
79
  with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f: return json.load(f)
80
  except: pass
81
  return {}
82
-
83
  def _save_short_db(db):
84
  try:
85
  os.makedirs(os.path.dirname(SHORT_STATS_FILE), exist_ok=True)
@@ -87,18 +81,14 @@ def _save_short_db(db):
87
  with open(tmp,"w",encoding="utf-8") as f: json.dump(db, f, ensure_ascii=False)
88
  os.replace(tmp, SHORT_STATS_FILE)
89
  except: pass
90
-
91
  def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
92
-
93
  WALL_FILE = "/data/wall_posts.json" if os.path.isdir("/data") else "/app/wall_posts.json"
94
-
95
  def _load_wall():
96
  try:
97
  if os.path.exists(WALL_FILE):
98
  with open(WALL_FILE,"r",encoding="utf-8") as f: return json.load(f)
99
  except: pass
100
  return []
101
-
102
  def _save_wall(posts):
103
  try:
104
  os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
@@ -106,7 +96,6 @@ def _save_wall(posts):
106
  with open(tmp,"w",encoding="utf-8") as f: json.dump(posts[:100], f, ensure_ascii=False)
107
  os.replace(tmp, WALL_FILE)
108
  except: pass
109
-
110
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
111
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
112
  HL_LEAGUES = {
@@ -119,61 +108,41 @@ HL_LEAGUES = {
119
  "europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
120
  "world-cup":{"path":"the-gioi/world-cup","name":"World Cup 2026","emoji":"🌍"},
121
  }
122
-
123
  def _cached(key, fn, ttl=None):
124
- now = time.time(); t = ttl or _cache_ttl
125
- if key in _cache and now - _cache[key]["t"] < t: return _cache[key]["d"]
126
- try: data = fn()
127
- except: data = _cache.get(key, {}).get("d", [])
128
- _cache[key] = {"d": data, "t": now}
129
- return data
130
-
131
  def _get(url, headers=None):
132
- h = headers or HEADERS
133
- r = requests.get(url, headers=h, timeout=15)
134
- r.encoding = "utf-8"
135
- return BeautifulSoup(r.text, "lxml")
136
-
137
  def fetch_bongda_api(endpoint):
138
  try:
139
- r = requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
140
- if r.status_code == 200:
141
- data = r.json()
142
- if data.get("status") == "success": return data.get("html", "")
143
  return ""
144
  except: return ""
145
 
146
  def _parse_match_from_li(li, status_type="live"):
147
- match_div = li.select_one("div.match")
148
  if not match_div: return None
149
- home_el = match_div.select_one(".home-team .name")
150
- away_el = match_div.select_one(".away-team .name")
151
  if not home_el or not away_el: return None
152
- status_el = match_div.select_one(".status a")
153
- league_el = li.find_previous("strong")
154
- time_el = match_div.select_one(".match-time")
155
- home_logo = match_div.select_one(".home-team .logo img")
156
- away_logo = match_div.select_one(".away-team .logo img")
157
- event_id = ""
158
  if status_el:
159
- href = status_el.get("href", "")
160
- m = re.search(r'/tran-dau/(\d+)/', href)
161
- if m: event_id = m.group(1)
162
- spans = status_el.find_all("span") if status_el else []
163
- score = ""; minute = ""
164
- if len(spans) >= 3: score = f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
165
- if len(spans) >= 4: minute = spans[3].get_text(strip=True)
166
- if not score and status_el and status_el.select_one(".vs"): score = "VS"
167
- league = league_el.get_text(strip=True) if league_el else ""
168
- return {
169
- "home": home_el.get_text(strip=True), "away": away_el.get_text(strip=True),
170
- "score": score or "VS", "minute": minute, "league": league,
171
- "time": time_el.get_text(strip=True) if time_el else "",
172
- "event_id": event_id,
173
- "home_logo": home_logo.get("src","") if home_logo else "",
174
- "away_logo": away_logo.get("src","") if away_logo else "",
175
- "status": status_type
176
- }
177
 
178
  # ===== VIDEO PROXY =====
179
  @app.get("/api/proxy/m3u8")
@@ -185,7 +154,7 @@ def proxy_m3u8(url: str = Query(...)):
185
  for line in lines:
186
  if line.startswith('#') or not line.strip(): rewritten.append(line)
187
  else: rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe=""))
188
- 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"})
189
  except: return Response(status_code=502, content="proxy error")
190
 
191
  @app.get("/api/proxy/seg")
@@ -195,7 +164,7 @@ def proxy_segment(url: str = Query(...)):
195
  if r.status_code != 200: return Response(status_code=502, content="upstream error")
196
  data = r.content
197
  if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
198
- return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age:3600"})
199
  except: return Response(status_code=502, content="proxy error")
200
 
201
  @app.get("/api/proxy/video")
@@ -216,7 +185,7 @@ def proxy_img(url: str = Query(...)):
216
  r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
217
  if r.status_code != 200: return Response(status_code=502)
218
  ct = r.headers.get("Content-Type", "image/jpeg")
219
- return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age:86400", "Access-Control-Allow-Origin": "*"})
220
  except: return Response(status_code=502)
221
 
222
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
@@ -283,7 +252,6 @@ def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
283
  def scrape_highlights_by_league(league_key):
284
  if league_key not in HL_LEAGUES: return []
285
  return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
286
-
287
  def scrape_all_league_highlights():
288
  results = {}
289
  def _fetch(key): return key, scrape_highlights_by_league(key)
@@ -298,72 +266,70 @@ def scrape_all_league_highlights():
298
 
299
  def extract_xemlaibongda_video(url):
300
  try:
301
- r = requests.get(url, headers=HEADERS, timeout=15)
302
- if r.status_code != 200: return None
303
- r.encoding = "utf-8"; soup = BeautifulSoup(r.text, "lxml")
304
- video = soup.find("video")
305
  if video:
306
- src = video.get("src",""); poster = video.get("poster","")
307
  if not src:
308
- source = video.find("source")
309
- if source: src = source.get("src","")
310
- if src: return {"src":src,"poster":poster,"type":"hls" if ".m3u8" in src else "video"}
311
- m3u8s = re.findall(r'(https?://[^\s"\'<>]+\.m3u8)', r.text)
312
  if m3u8s:
313
- og = soup.find("meta", property="og:image")
314
- poster = og.get("content","") if og else ""
315
- return {"src":m3u8s[0],"poster":poster,"type":"hls"}
316
  return None
317
  except: return None
318
 
319
- # ===== YOUTUBE SHORTS SCRAPING (legacy) =====
320
  def _yt_channel_shorts_requests(channel, count=15):
321
  try:
322
- url = f"https://www.youtube.com/@{channel}/shorts"
323
- r = requests.get(url, headers={**HEADERS, "Accept-Language":"vi,en;q=0.8"}, timeout=15)
324
- if r.status_code != 200: return []
325
- html = r.text; ids = []; items = []
326
- for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"', html):
327
- vid = m.group(1)
328
  if vid in ids: continue
329
  ids.append(vid)
330
- snip = html[max(0, m.start()-900):m.start()+1600]
331
- title = ""
332
- mt = re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"', snip)
333
- if not mt: mt = re.search(r'"accessibilityText":"([^"]+)"', snip)
334
- if mt: title = html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
335
- if not title: title = "YouTube Short"
336
  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})
337
- if len(items) >= count: break
338
  return items
339
  except: return []
340
 
341
  def scrape_shorts():
342
- vids = []
343
  with ThreadPoolExecutor(3) as ex:
344
- futs = [ex.submit(_yt_channel_shorts_requests, ch, 24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
345
  for f in as_completed(futs):
346
  try:
347
- r = f.result()
348
  if r: vids.extend(r)
349
  except: pass
350
- merged = []; seen = set()
351
  for v in vids:
352
- vid = v.get("id")
353
  if not vid or vid in seen: continue
354
  seen.add(vid); merged.append(v)
355
  for v in SHORTS_FALLBACK:
356
- vid = v.get("id")
357
  if not vid or vid in seen: continue
358
  seen.add(vid); merged.append(v)
359
  return merged[:60]
360
 
361
- # ===== VTV NAM BỘ & WC SHORTS - using Piped API + fallback =====
362
- from piped_client import get_vtvnambo_shorts, get_wc_related_shorts
363
 
364
  @app.get("/api/shorts/vtvnamo")
365
  def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
366
- """Get all shorts from VTV Nam Bộ channel (@vtvnambo)."""
367
  items = get_vtvnambo_shorts(count)
368
  if not items:
369
  items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
@@ -375,7 +341,7 @@ def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
375
 
376
  @app.get("/api/shorts/wc")
377
  def api_shorts_wc(count: int = Query(default=50, le=100)):
378
- """Get World Cup / football related shorts."""
379
  items = get_wc_related_shorts(count)
380
  if not items:
381
  items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
@@ -530,26 +496,6 @@ def scrape_vne(cat_url):
530
  return arts
531
  except: return []
532
 
533
- def _scrape_dantri_homepage(cat_filter=None):
534
- try:
535
- soup=_get("https://dantri.com.vn/"); arts=[]; seen=set()
536
- for a in soup.find_all("a",href=True):
537
- href=a.get("href",""); title=a.get("title","") or a.get_text(strip=True)
538
- if not title or len(title)<15 or "javascript:" in href: continue
539
- if not href.startswith("http"): href="https://dantri.com.vn"+href
540
- if href in seen or not href.endswith(".htm"): continue
541
- if cat_filter and f"/{cat_filter}/" not in href: continue
542
- img_tag=a.find("img")
543
- if not img_tag and a.parent: img_tag=a.parent.find("img")
544
- img_src=""
545
- if img_tag: img_src=img_tag.get("data-src","") or img_tag.get("src","")
546
- if not img_src or "cdn" not in img_src: continue
547
- proxied_img="/api/proxy/img?url="+quote(img_src,safe="")
548
- seen.add(href); arts.append({"title":title,"link":href,"img":proxied_img,"source":"dantri"})
549
- if len(arts)>=15: break
550
- return arts
551
- except: return []
552
-
553
  def scrape_genk_ai():
554
  try:
555
  r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
@@ -584,7 +530,7 @@ def scrape_genk_ai():
584
 
585
  @app.get("/api/homepage")
586
  def api_homepage():
587
- def _f():
588
  articles=[]
589
  with ThreadPoolExecutor(12) as ex:
590
  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"]}
@@ -614,8 +560,7 @@ def api_categories():
614
 
615
  @app.get("/api/article")
616
  def api_article(url:str=Query(...)):
617
- data=None
618
- return JSONResponse(data if data else{"error":"not supported"})
619
 
620
  @app.get("/api/storage_status")
621
  def api_storage_status():
 
13
 
14
  app = FastAPI()
15
 
16
+ # ===== VTV CHANNELS API =====
17
  from vtv_api import router as vtv_router
18
  app.include_router(vtv_router)
19
 
 
27
  _cache_ttl_yt = 1800
28
 
29
  # ===== VTV NAM BỘ SHORTS FALLBACK =====
 
30
  SHORTS_FALLBACK = [
 
31
  {"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
32
  {"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
 
33
  {"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","channel":"baodantri7941"},
34
  {"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","channel":"baodantri7941"},
35
  {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
 
45
  {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","channel":"baodantri7941"},
46
  {"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu","channel":"baodantri7941"},
47
  {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg: Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
48
+ {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong ở sông Lô","channel":"baosuckhoedoisongboyte"},
 
49
  {"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","channel":"baosuckhoedoisongboyte"},
50
  {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
51
  {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô, đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
 
68
 
69
  SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
70
  _short_lock = threading.Lock()
 
71
  def _load_short_db():
72
  try:
73
  if os.path.exists(SHORT_STATS_FILE):
74
  with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f: return json.load(f)
75
  except: pass
76
  return {}
 
77
  def _save_short_db(db):
78
  try:
79
  os.makedirs(os.path.dirname(SHORT_STATS_FILE), exist_ok=True)
 
81
  with open(tmp,"w",encoding="utf-8") as f: json.dump(db, f, ensure_ascii=False)
82
  os.replace(tmp, SHORT_STATS_FILE)
83
  except: pass
 
84
  def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
 
85
  WALL_FILE = "/data/wall_posts.json" if os.path.isdir("/data") else "/app/wall_posts.json"
 
86
  def _load_wall():
87
  try:
88
  if os.path.exists(WALL_FILE):
89
  with open(WALL_FILE,"r",encoding="utf-8") as f: return json.load(f)
90
  except: pass
91
  return []
 
92
  def _save_wall(posts):
93
  try:
94
  os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
 
96
  with open(tmp,"w",encoding="utf-8") as f: json.dump(posts[:100], f, ensure_ascii=False)
97
  os.replace(tmp, WALL_FILE)
98
  except: pass
 
99
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
100
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
101
  HL_LEAGUES = {
 
108
  "europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
109
  "world-cup":{"path":"the-gioi/world-cup","name":"World Cup 2026","emoji":"🌍"},
110
  }
 
111
  def _cached(key, fn, ttl=None):
112
+ now=time.time(); t=ttl or _cache_ttl
113
+ if key in _cache and now-_cache[key]["t"]<t: return _cache[key]["d"]
114
+ try: data=fn()
115
+ except: data=_cache.get(key,{}).get("d",[])
116
+ _cache[key]={"d":data,"t":now}; return data
 
 
117
  def _get(url, headers=None):
118
+ h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8"
119
+ return BeautifulSoup(r.text,"lxml")
 
 
 
120
  def fetch_bongda_api(endpoint):
121
  try:
122
+ r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
123
+ if r.status_code==200:
124
+ data=r.json()
125
+ if data.get("status")=="success": return data.get("html","")
126
  return ""
127
  except: return ""
128
 
129
  def _parse_match_from_li(li, status_type="live"):
130
+ match_div=li.select_one("div.match")
131
  if not match_div: return None
132
+ home_el=match_div.select_one(".home-team .name"); away_el=match_div.select_one(".away-team .name")
 
133
  if not home_el or not away_el: return None
134
+ status_el=match_div.select_one(".status a"); league_el=li.find_previous("strong"); time_el=match_div.select_one(".match-time")
135
+ home_logo=match_div.select_one(".home-team .logo img"); away_logo=match_div.select_one(".away-team .logo img")
136
+ event_id=""
 
 
 
137
  if status_el:
138
+ href=status_el.get("href",""); m=re.search(r'/tran-dau/(\d+)/',href)
139
+ if m: event_id=m.group(1)
140
+ spans=status_el.find_all("span") if status_el else []; score=""; minute=""
141
+ if len(spans)>=3: score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
142
+ if len(spans)>=4: minute=spans[3].get_text(strip=True)
143
+ if not score and status_el and status_el.select_one(".vs"): score="VS"
144
+ league=league_el.get_text(strip=True) if league_el else ""
145
+ 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}
 
 
 
 
 
 
 
 
 
 
146
 
147
  # ===== VIDEO PROXY =====
148
  @app.get("/api/proxy/m3u8")
 
154
  for line in lines:
155
  if line.startswith('#') or not line.strip(): rewritten.append(line)
156
  else: rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe=""))
157
+ 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"})
158
  except: return Response(status_code=502, content="proxy error")
159
 
160
  @app.get("/api/proxy/seg")
 
164
  if r.status_code != 200: return Response(status_code=502, content="upstream error")
165
  data = r.content
166
  if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
167
+ return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
168
  except: return Response(status_code=502, content="proxy error")
169
 
170
  @app.get("/api/proxy/video")
 
185
  r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
186
  if r.status_code != 200: return Response(status_code=502)
187
  ct = r.headers.get("Content-Type", "image/jpeg")
188
+ return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
189
  except: return Response(status_code=502)
190
 
191
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
 
252
  def scrape_highlights_by_league(league_key):
253
  if league_key not in HL_LEAGUES: return []
254
  return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
 
255
  def scrape_all_league_highlights():
256
  results = {}
257
  def _fetch(key): return key, scrape_highlights_by_league(key)
 
266
 
267
  def extract_xemlaibongda_video(url):
268
  try:
269
+ r=requests.get(url, headers=HEADERS, timeout=15)
270
+ if r.status_code!=200: return None
271
+ r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); video=soup.find("video")
 
272
  if video:
273
+ src=video.get("src",""); poster=video.get("poster","")
274
  if not src:
275
+ source=video.find("source")
276
+ if source: src=source.get("src","")
277
+ if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
278
+ m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
279
  if m3u8s:
280
+ og=soup.find("meta",property="og:image"); poster=og.get("content","") if og else ""
281
+ return{"src":m3u8s[0],"poster":poster,"type":"hls"}
 
282
  return None
283
  except: return None
284
 
285
+ # ===== YOUTUBE SHORTS SCRAPING (legacy requests fallback) =====
286
  def _yt_channel_shorts_requests(channel, count=15):
287
  try:
288
+ url=f"https://www.youtube.com/@{channel}/shorts"
289
+ r=requests.get(url, headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"}, timeout=15)
290
+ if r.status_code!=200: return []
291
+ html=r.text; ids=[]; items=[]
292
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
293
+ vid=m.group(1)
294
  if vid in ids: continue
295
  ids.append(vid)
296
+ snip=html[max(0,m.start()-900):m.start()+1600]
297
+ title=""
298
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
299
+ if not mt: mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
300
+ if mt: title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
301
+ if not title: title="YouTube Short"
302
  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})
303
+ if len(items)>=count: break
304
  return items
305
  except: return []
306
 
307
  def scrape_shorts():
308
+ vids=[]
309
  with ThreadPoolExecutor(3) as ex:
310
+ futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
311
  for f in as_completed(futs):
312
  try:
313
+ r=f.result()
314
  if r: vids.extend(r)
315
  except: pass
316
+ merged=[]; seen=set()
317
  for v in vids:
318
+ vid=v.get("id")
319
  if not vid or vid in seen: continue
320
  seen.add(vid); merged.append(v)
321
  for v in SHORTS_FALLBACK:
322
+ vid=v.get("id")
323
  if not vid or vid in seen: continue
324
  seen.add(vid); merged.append(v)
325
  return merged[:60]
326
 
327
+ # ===== VTV NAM BỘ & WC SHORTS - using yt-dlp =====
328
+ from yt_scraper import get_vtvnambo_shorts, get_wc_related_shorts
329
 
330
  @app.get("/api/shorts/vtvnamo")
331
  def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
332
+ """Get all shorts from VTV Nam Bộ channel (@vtvnambo) using yt-dlp."""
333
  items = get_vtvnambo_shorts(count)
334
  if not items:
335
  items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
 
341
 
342
  @app.get("/api/shorts/wc")
343
  def api_shorts_wc(count: int = Query(default=50, le=100)):
344
+ """Get World Cup / football related shorts using yt-dlp."""
345
  items = get_wc_related_shorts(count)
346
  if not items:
347
  items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
 
496
  return arts
497
  except: return []
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  def scrape_genk_ai():
500
  try:
501
  r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
 
530
 
531
  @app.get("/api/homepage")
532
  def api_homepage():
533
+ def _f()
534
  articles=[]
535
  with ThreadPoolExecutor(12) as ex:
536
  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"]}
 
560
 
561
  @app.get("/api/article")
562
  def api_article(url:str=Query(...)):
563
+ return JSONResponse({"error":"not supported"})
 
564
 
565
  @app.get("/api/storage_status")
566
  def api_storage_status():