bep40 commited on
Commit
5ed2bc0
·
verified ·
1 Parent(s): 8bea5bd

Add /api/shorts/update + /api/shorts/status endpoints, prioritize runtime cache in scrape_shorts

Browse files
Files changed (1) hide show
  1. main.py +99 -30
main.py CHANGED
@@ -63,9 +63,9 @@ _cache_ttl_yt = 1800
63
  SHORTS_FALLBACK = [
64
  {"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
65
  {"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
66
- {"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"},
67
  {"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"},
68
- {"id":"tvPewsc2ph4","title":"Tính năng �n trên iPhone giúp giảm m�i m�t","channel":"baodantri7941"},
69
  {"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","channel":"baodantri7941"},
70
  {"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ù","channel":"baodantri7941"},
71
  {"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"},
@@ -75,24 +75,24 @@ SHORTS_FALLBACK = [
75
  {"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"},
76
  {"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"},
77
  {"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"},
78
- {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn TP.HCM","channel":"baodantri7941"},
79
  {"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"},
80
- {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người tr� ở TPHCM","channel":"baodantri7941"},
81
- {"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"},
82
  {"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"},
83
- {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười �n rau?","channel":"baosuckhoedoisongboyte"},
84
- {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip n�t pô đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
85
  {"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","channel":"baosuckhoedoisongboyte"},
86
- {"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô bẻ cần gạt nước ở Đà N�ng","channel":"baosuckhoedoisongboyte"},
87
- {"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái đứng trên xe máy khi đổ đèo ở Đ�k L�k","channel":"baosuckhoedoisongboyte"},
88
- {"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","channel":"baosuckhoedoisongboyte"},
89
  {"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","channel":"baosuckhoedoisongboyte"},
90
  {"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","channel":"baosuckhoedoisongboyte"},
91
- {"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","channel":"baosuckhoedoisongboyte"},
92
  {"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"},
93
  {"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mày bay trong ngày cưới","channel":"baosuckhoedoisongboyte"},
94
  {"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","channel":"baosuckhoedoisongboyte"},
95
- {"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","channel":"baosuckhoedoisongboyte"},
96
  ]
97
  for _v in SHORTS_FALLBACK:
98
  _v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"])
@@ -119,10 +119,10 @@ def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
119
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
120
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
121
  HL_LEAGUES = {
122
- "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"�������"},
123
- "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":""},
124
  "bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},
125
- "serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮"},
126
  "la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},
127
  "champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},
128
  "europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
@@ -222,7 +222,7 @@ def proxy_img(url: str = Query(...)):
222
  def _scrape_xemlaibongda_page(page_path, limit=20):
223
  """
224
  Scrape video từ xemlaibongda.top - Simple & Reliable
225
- Dùng logic cũ đã test, không fetch t�ng trang (tránh timeout)
226
  """
227
  try:
228
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
@@ -242,7 +242,7 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
242
  if not href.startswith("http"):
243
  href = "https://xemlaibongda.top" + href
244
 
245
- # B� query params
246
  clean_href = href.split("?")[0].split("#")[0]
247
  if clean_href in seen:
248
  continue
@@ -312,7 +312,7 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
312
  if text and len(text) >= 5:
313
  title = text[:100]
314
 
315
- # Fallback: tạo title t� slug
316
  if not title or len(title) < 3:
317
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
318
  title = slug.replace("-", " ").replace("_", " ").title()
@@ -342,7 +342,7 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
342
  return []
343
 
344
  def _extract_img_src(a_tag):
345
- """Extract image URL từ th� <a> và parent elements"""
346
  img = a_tag.find("img")
347
  if not img and a_tag.parent:
348
  img = a_tag.parent.find("img")
@@ -418,7 +418,7 @@ def _extract_title(a_tag, href):
418
  if alt and len(alt) >= 3:
419
  return alt
420
 
421
- # 4. Thử text content của <a> (loại b� quá dài)
422
  text = a_tag.get_text(" ", strip=True)
423
  if text and len(text) >= 3:
424
  # Lấy dòng đầu tiên nếu có nhiều dòng
@@ -504,14 +504,27 @@ def _yt_channel_shorts_requests(channel, count=15):
504
  except: return []
505
 
506
  def scrape_shorts():
 
 
 
 
 
 
507
  vids=[]
508
- with ThreadPoolExecutor(3) as ex:
509
- futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
510
- for f in as_completed(futs):
511
- try:
512
- r=f.result()
513
- if r: vids.extend(r)
514
- except: pass
 
 
 
 
 
 
 
515
  merged=[]; seen=set()
516
  for v in vids:
517
  vid=v.get("id")
@@ -569,6 +582,10 @@ def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f
569
 
570
  from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
571
 
 
 
 
 
572
  @app.get("/api/match/{event_id}/detail")
573
  def api_match_detail(event_id: int, url: str = Query(default="")):
574
  try:
@@ -725,7 +742,7 @@ def api_bdp_videos():
725
  return JSONResponse(_cached("bdp_videos",_f))
726
 
727
  # ===== NEWS =====
728
- 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")}
729
 
730
  def scrape_vne(cat_url):
731
  try:
@@ -871,7 +888,59 @@ def api_storage_status():
871
  def api_hot_topics():
872
  return JSONResponse({"topics":[]})
873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  @app.get("/", response_class=HTMLResponse)
875
  async def root():
876
- return HTMLResponse("<h1>VNEWS</h1><p>Running</p>")
877
- # v14 rebuild 2026-06-18T12:21:41.503230
 
 
63
  SHORTS_FALLBACK = [
64
  {"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
65
  {"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
66
+ {"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"},
67
  {"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"},
68
+ {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
69
  {"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","channel":"baodantri7941"},
70
  {"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ù","channel":"baodantri7941"},
71
  {"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"},
 
75
  {"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"},
76
  {"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"},
77
  {"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"},
78
+ {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn TP.HCM","channel":"baodantri7941"},
79
  {"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"},
80
+ {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
81
+ {"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"},
82
  {"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"},
83
+ {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
84
+ {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
85
  {"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","channel":"baosuckhoedoisongboyte"},
86
+ {"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô bẻ cần gạt nước ở Đà Nẵng","channel":"baosuckhoedoisongboyte"},
87
+ {"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái đứng trên xe máy khi đổ đèo ở Đắk Lắk","channel":"baosuckhoedoisongboyte"},
88
+ {"id":"U0Ft6ChWAIo","title":"Cô giáo kể phút tháo chạy khỏi xe khách trước khi bị vò nát ở Cao Bằng","channel":"baosuckhoedoisongboyte"},
89
  {"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","channel":"baosuckhoedoisongboyte"},
90
  {"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","channel":"baosuckhoedoisongboyte"},
91
+ {"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","channel":"baosuckhoedoisongboyte"},
92
  {"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"},
93
  {"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mày bay trong ngày cưới","channel":"baosuckhoedoisongboyte"},
94
  {"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","channel":"baosuckhoedoisongboyte"},
95
+ {"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","channel":"baosuckhoedoisongboyte"},
96
  ]
97
  for _v in SHORTS_FALLBACK:
98
  _v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"])
 
119
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
120
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
121
  HL_LEAGUES = {
122
+ "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},
123
+ "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
124
  "bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},
125
+ "serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},
126
  "la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},
127
  "champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},
128
  "europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
 
222
  def _scrape_xemlaibongda_page(page_path, limit=20):
223
  """
224
  Scrape video từ xemlaibongda.top - Simple & Reliable
225
+ Dùng logic cũ đã test, không fetch từng trang (tránh timeout)
226
  """
227
  try:
228
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
 
242
  if not href.startswith("http"):
243
  href = "https://xemlaibongda.top" + href
244
 
245
+ # Bỏ query params
246
  clean_href = href.split("?")[0].split("#")[0]
247
  if clean_href in seen:
248
  continue
 
312
  if text and len(text) >= 5:
313
  title = text[:100]
314
 
315
+ # Fallback: tạo title từ slug
316
  if not title or len(title) < 3:
317
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
318
  title = slug.replace("-", " ").replace("_", " ").title()
 
342
  return []
343
 
344
  def _extract_img_src(a_tag):
345
+ """Extract image URL từ thẻ <a> và parent elements"""
346
  img = a_tag.find("img")
347
  if not img and a_tag.parent:
348
  img = a_tag.parent.find("img")
 
418
  if alt and len(alt) >= 3:
419
  return alt
420
 
421
+ # 4. Thử text content của <a> (loại bỏ quá dài)
422
  text = a_tag.get_text(" ", strip=True)
423
  if text and len(text) >= 3:
424
  # Lấy dòng đầu tiên nếu có nhiều dòng
 
504
  except: return []
505
 
506
  def scrape_shorts():
507
+ """Priority 1: runtime cache from GitHub Actions updater"""
508
+ cached = get_runtime_cache()
509
+ if cached and len(cached) >= 10:
510
+ return cached[:60]
511
+
512
+ # Priority 2: try live YouTube scrape (fast-fail on HF Spaces)
513
  vids=[]
514
+ try:
515
+ with ThreadPoolExecutor(3) as ex:
516
+ futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
517
+ for f in as_completed(futs, timeout=8): # 8s cap
518
+ try:
519
+ r=f.result()
520
+ if r: vids.extend(r)
521
+ except: pass
522
+ except: pass
523
+
524
+ # Priority 3: hardcoded fallback
525
+ if not vids:
526
+ vids = list(SHORTS_FALLBACK)
527
+
528
  merged=[]; seen=set()
529
  for v in vids:
530
  vid=v.get("id")
 
582
 
583
  from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
584
 
585
+ # ===== SHORTS RUNTIME CACHE (GitHub Actions updater) =====
586
+ from shorts_cache import get_runtime_cache, set_runtime_cache, get_cache_status, SHORTS_UPDATE_SECRET
587
+ import hmac, hashlib as _hashlib
588
+
589
  @app.get("/api/match/{event_id}/detail")
590
  def api_match_detail(event_id: int, url: str = Query(default="")):
591
  try:
 
742
  return JSONResponse(_cached("bdp_videos",_f))
743
 
744
  # ===== NEWS =====
745
+ 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")}
746
 
747
  def scrape_vne(cat_url):
748
  try:
 
888
  def api_hot_topics():
889
  return JSONResponse({"topics":[]})
890
 
891
+ # ===== SHORTS EXTERNAL UPDATER ENDPOINTS =====
892
+ @app.get("/api/shorts/status")
893
+ def api_shorts_updater_status():
894
+ """Check shorts cache status"""
895
+ return JSONResponse(get_cache_status())
896
+
897
+ @app.post("/api/shorts/update")
898
+ async def api_shorts_updater(request: Request):
899
+ """Receive fresh shorts from GitHub Actions updater
900
+
901
+ Headers:
902
+ X-Signature: HMAC-SHA256(body, SHORTS_UPDATE_SECRET)
903
+ Body:
904
+ {"items": [...], "source": "yt-dlp", "fetched_at": timestamp}
905
+ """
906
+ body = await request.body()
907
+ sig = request.headers.get("X-Signature", "")
908
+
909
+ # Verify HMAC signature
910
+ expected = _hashlib.new('sha256', body, usedforsecurity=False)
911
+ # Use hmac properly
912
+ import hmac as hmac_mod
913
+ expected_sig = hmac_mod.new(
914
+ SHORTS_UPDATE_SECRET.encode(),
915
+ body,
916
+ _hashlib.sha256
917
+ ).hexdigest()
918
+
919
+ if not hmac_mod.compare_digest(sig, expected_sig):
920
+ from fastapi import HTTPException
921
+ raise HTTPException(status_code=401, detail="invalid signature")
922
+
923
+ try:
924
+ data = json.loads(body)
925
+ except:
926
+ from fastapi import HTTPException
927
+ raise HTTPException(status_code=400, detail="invalid JSON")
928
+
929
+ items = data.get("items", [])
930
+ if not items:
931
+ from fastapi import HTTPException
932
+ raise HTTPException(status_code=400, detail="no items")
933
+
934
+ count = set_runtime_cache(items)
935
+ return JSONResponse({
936
+ "ok": True,
937
+ "saved": count,
938
+ "source": data.get("source", "unknown"),
939
+ "fetched_at": data.get("fetched_at", time.time()),
940
+ })
941
+
942
  @app.get("/", response_class=HTMLResponse)
943
  async def root():
944
+ return HTMLResponse("<h1>VNEWS v15</h1><p>Shorts external updater active</p>")
945
+ # v15 2026-06-29 - shorts external updater
946
+