VAIVI / app.py
bep40's picture
Update app.py
73ee85d verified
import gradio as gr
import requests
import re
import hashlib
import json
from urllib.parse import quote as urlquote
from bs4 import BeautifulSoup
from datetime import datetime, timezone, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
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",
}
BASE_BDP = "https://bongdaplus.vn"
BASE_24H = "https://www.24h.com.vn"
REFRESH_SECONDS = 300
SPACE_URL = "https://bep40-bongdaplus-news.hf.space"
CATEGORIES = {
"🏠 Trang Chủ (Nổi Bật)": "mix::home::Trang Chủ",
"🎬 Video Tổng Hợp": "mix::video::Video",
"📰 Thời Sự": "vne::https://vnexpress.net/thoi-su::Thời Sự",
"🌍 Thế Giới": "vne::https://vnexpress.net/the-gioi::Thế Giới",
"💰 Kinh Doanh": "vne::https://vnexpress.net/kinh-doanh::Kinh Doanh",
"💻 Công Nghệ": "vne::https://vnexpress.net/so-hoa::Công Nghệ",
"🔬 Khoa Học": "vne::https://vnexpress.net/khoa-hoc::Khoa Học",
"🎬 Giải Trí": "vne::https://vnexpress.net/giai-tri::Giải Trí",
"🏥 Sức Khỏe": "vne::https://vnexpress.net/suc-khoe::Sức Khỏe",
"🎓 Giáo Dục": "vne::https://vnexpress.net/giao-duc::Giáo Dục",
"✈️ Du Lịch": "vne::https://vnexpress.net/du-lich::Du Lịch",
"⚽ Thể Thao": "vne::https://vnexpress.net/the-thao::Thể Thao",
"⚽ Bóng Đá QT": "vne::https://vnexpress.net/the-thao/bong-da::Bóng Đá",
"🏴󠁧󠁢󠁥󠁮󠁧󠁿 Ngoại Hạng Anh": "bdp::https://bongdaplus.vn/ngoai-hang-anh::Bóng Đá",
"🇪🇸 La Liga": "bdp::https://bongdaplus.vn/la-liga::Bóng Đá",
"🏆 Champions League": "bdp::https://bongdaplus.vn/champions-league-cup-c1::Bóng Đá",
"🇻🇳 Bóng Đá VN": "bdp::https://bongdaplus.vn/bong-da-viet-nam::Bóng Đá",
"🔄 Chuyển Nhượng": "bdp::https://bongdaplus.vn/tin-chuyen-nhuong::Bóng Đá",
}
# Mapping for icon grid: (icon, short_label, hash_slug)
CAT_ICONS = [
("🏠","Trang Chủ","trang-chu"),
("🎬","Video","video"),
("📰","Thời Sự","thoi-su"),
("🌍","Thế Giới","the-gioi"),
("💰","Kinh Doanh","kinh-doanh"),
("💻","Công Nghệ","cong-nghe"),
("🔬","Khoa Học","khoa-hoc"),
("🎬","Giải Trí","giai-tri"),
("🏥","Sức Khỏe","suc-khoe"),
("🎓","Giáo Dục","giao-duc"),
("✈️","Du Lịch","du-lich"),
("⚽","Thể Thao","the-thao"),
("⚽","Bóng Đá QT","bong-da-qt"),
("⚽","Ngoại Hạng Anh","ngoai-hang-anh"),
("⚽","La Liga","la-liga"),
("🏆","Champions League","champions-league"),
("⚽","Bóng Đá VN","bong-da-vn"),
("🔄","Chuyển Nhượng","chuyen-nhuong"),
]
CAT_KEYS = list(CATEGORIES.keys())
CAT_HASH_TO_KEY = {ci[2]: CAT_KEYS[i] for i, ci in enumerate(CAT_ICONS)}
HOMEPAGE_SOURCES = [
("vne","https://vnexpress.net/thoi-su","Thời Sự"),
("vne","https://vnexpress.net/the-gioi","Thế Giới"),
("vne","https://vnexpress.net/kinh-doanh","Kinh Doanh"),
("vne","https://vnexpress.net/so-hoa","Công Nghệ"),
("vne","https://vnexpress.net/the-thao","Thể Thao"),
("vne","https://vnexpress.net/giai-tri","Giải Trí"),
("bdp","https://bongdaplus.vn/tin-moi","Bóng Đá"),
]
# ── Helpers ────────────────────────────────────────────────────────────────────
def strip_links(html):
return re.sub(r'</a>', '', re.sub(r'<a\s[^>]*>', '', html))
def esc(text):
return text.replace("\\","\\\\").replace("'","\\'").replace('"','\\"').replace("\n"," ")
def safe_url(url):
if not url: return ""
try:
return urlquote(url, safe=':/?#[]@!$&()*+,;=-._~%')
except Exception:
return url.replace(" ","%20").replace("'","%27").replace('"',"%22")
def make_id(url):
return hashlib.md5(url.encode()).hexdigest()[:12]
def slug(text):
s = text.lower().strip()
for p,r in [('[àáạảãâầấậẩẫăằắặẳẵ]','a'),('[èéẹẻẽêềếệểễ]','e'),('[ìíịỉĩ]','i'),
('[òóọỏõôồốộổỗơờớợởỡ]','o'),('[ùúụủũưừứựửữ]','u'),('[ỳýỵỷỹ]','y'),('[đ]','d')]:
s = re.sub(p,r,s)
return re.sub(r'[\s-]+','-',re.sub(r'[^a-z0-9\s-]','',s)).strip('-')[:60]
def _extract_bdp_video_id(url):
m = re.search(r'-(\d{6,})\.html', url)
return m.group(1) if m else None
# ══════════════════════════════════════════════════════════════════════════════
# SCRAPERS
# ══════════════════════════════════════════════════════════════════════════════
def _get(url):
r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
return BeautifulSoup(r.text, "lxml")
def _get_raw(url):
r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
return r.text, BeautifulSoup(r.text, "lxml")
# ── BDP Video Embed Fetcher ────────────────────────────────────────────────────
def fetch_bdp_embed_data(video_id):
try:
embed_url = f"{BASE_BDP}/video-embed/{video_id}.html"
html, soup = _get_raw(embed_url)
video = soup.select_one("video#videoPlayer")
if not video: return None
source = video.find("source")
result = {"mp4": source.get("src","") if source else "", "poster": video.get("poster","")}
carousel_match = re.findall(r'vdo\.relatedCarousel\((\[.*?\])\)', html, re.DOTALL)
if carousel_match:
posters = re.findall(r"poster:\s*'([^']+)'", carousel_match[0])
vdorefs = re.findall(r"vdoref:\s*'([^']+)'", carousel_match[0])
titles = re.findall(r"title:\s*'([^']*)'", carousel_match[0])
result["related"] = [
{"poster": p, "link": BASE_BDP + v if not v.startswith("http") else v, "title": t}
for p, v, t in zip(posters, vdorefs, titles)
]
return result
except: return None
# ── BDP Scrapers ───────────────────────────────────────────────────────────────
def scrape_bdp_list(url):
try:
soup = _get(url); articles,seen=[],set()
for sel,feat in [("div.news.fst",True),("div.sld-itm.news",True),("li.news",False)]:
for it in soup.select(sel):
tag = it.find("a",class_="title") or it.find("a",href=True)
if not tag: continue
t=tag.get_text(strip=True); lk=tag.get("href","")
if not t or len(t)<5: continue
if lk and not lk.startswith("http"): lk=BASE_BDP+lk
if lk in seen: continue
im=it.find("img"); img=(im.get("data-src") or im.get("src")) if im else None
sm=it.find("p",class_="summ"); tt=it.find("div",class_="in-time")
is_video = "/video/" in lk
articles.append({"title":t,"link":lk,"img":img,"summary":sm.get_text(strip=True) if sm else "",
"time":tt.get_text(strip=True) if tt else "","featured":feat,"source":"bdp","group":"","is_video":is_video})
seen.add(lk)
return articles
except Exception as e:
return [{"title":f"⚠️ {e}","link":"#","img":None,"summary":"","time":"","featured":False,"source":"bdp","group":"","is_video":False}]
def scrape_bdp_video_list():
try:
soup = _get(f"{BASE_BDP}/video")
articles, seen = [], set()
for a in soup.find_all("a", href=True):
href = a.get("href", "")
if "/video/" not in href: continue
if href in ("/video/", "/video/ban-thang-dep", "/video/highlight"): continue
if not href.startswith("http"): href = BASE_BDP + href
if href in seen: continue
title = a.get_text(strip=True)
title = re.sub(r'^\d{2}:\d{2}', '', title).strip()
if not title or len(title) < 5: continue
img_tag = a.find("img")
if not img_tag:
parent = a.parent
if parent: img_tag = parent.find("img")
img = None
if img_tag: img = img_tag.get("data-src") or img_tag.get("src") or img_tag.get("data-original")
articles.append({"title": title, "link": href, "img": img, "summary": "", "time": "",
"featured": len(articles) < 3, "source": "bdp", "group": "BongDaPlus Video", "is_video": True})
seen.add(href)
return articles[:30]
except Exception as e:
return [{"title": f"⚠️ {e}", "link": "#", "img": None, "summary": "", "time": "",
"featured": False, "source": "bdp", "group": "", "is_video": False}]
def scrape_bdp_article(url):
try:
soup = _get(url)
h1=soup.select_one(".lead-title h1") or soup.select_one("h1")
te=soup.select_one(".emobar .rgt"); se=soup.select_one("div.summary")
og=soup.find("meta",property="og:image"); og_img=og.get("content","") if og else ""
cd=soup.select_one("div.content#postContent") or soup.select_one("div.content")
body=_extract_body(cd) if cd else []
is_video_page = "/video/" in url
if is_video_page:
vid_id = _extract_bdp_video_id(url)
if vid_id:
embed = fetch_bdp_embed_data(vid_id)
if embed and embed.get("mp4"):
body.insert(0, {"type": "video", "src": embed["mp4"], "poster": embed.get("poster", ""), "vtype": "mp4"})
if not og_img or "logo" in og_img.lower(): og_img = embed.get("poster", og_img)
else:
for iframe in soup.select("iframe"):
src = iframe.get("src", "")
if "video-embed" in src:
vid = _fetch_bdp_video(src)
if vid: body.insert(0, vid)
return {"title":h1.get_text(strip=True) if h1 else "","time":te.get_text(strip=True) if te else "",
"summary":se.get_text(strip=True) if se else "","body":body,"related":_bdp_relates(soup),
"source_url":url,"source":"bdp","og_image":og_img}
except Exception as e:
return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"bdp","og_image":""}
def _fetch_bdp_video(embed_src):
try:
full = embed_src if embed_src.startswith("http") else BASE_BDP+embed_src
html, soup = _get_raw(full)
video = soup.select_one("video#videoPlayer") or soup.select_one("video")
if video:
source = video.find("source")
if source:
return {"type":"video","src":source.get("src",""),"poster":video.get("poster",""),"vtype":"mp4"}
except: pass
return None
# ── 24h.com.vn Scrapers ───────────────────────────────────────────────────────
def scrape_24h_video_list():
"""Scrape 24h.com.vn video-highlight page using <article> tags."""
try:
url = f"{BASE_24H}/video-highlight-c953.html"
r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
soup = BeautifulSoup(r.text, "lxml")
articles, seen = [], set()
for art in soup.find_all("article"):
a = art.find("a", href=True)
if not a: continue
href = a.get("href","")
if not href.startswith("http"): href = BASE_24H + href
if href in seen: continue
img_tag = art.find("img")
title = ""
if img_tag: title = img_tag.get("alt","")
if not title: title = a.get("title","") or a.get_text(strip=True)
if not title or len(title) < 10: continue
img_src = None
if img_tag:
for attr in ["data-original","data-src","src"]:
v = img_tag.get(attr,"")
if v and "base64" not in v and len(v) > 20:
img_src = v; break
seen.add(href)
articles.append({"title": title, "link": href, "img": img_src, "summary": "", "time": "",
"featured": len(articles) < 3, "source": "24h", "group": "24h Video", "is_video": True})
return articles[:30]
except:
return []
def scrape_24h_article(url):
"""Scrape a 24h.com.vn article - extract m3u8 video URL."""
try:
r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
soup = BeautifulSoup(r.text, "lxml")
h1 = soup.select_one("h1")
title = h1.get_text(strip=True) if h1 else ""
og = soup.find("meta", property="og:image")
og_img = og.get("content","") if og else ""
desc_meta = soup.find("meta", property="og:description")
summary = desc_meta.get("content","") if desc_meta else ""
# Extract m3u8 video URLs
m3u8s = re.findall(r'(https?://cdn\.24h\.com\.vn/[^\s"\'\\]+\.m3u8)', r.text)
videos = [u for u in m3u8s if '_720p' not in u]
if not videos: videos = m3u8s
body = []
for vsrc in videos[:3]:
body.append({"type": "video", "src": vsrc, "poster": og_img, "vtype": "hls"})
# Extract text content
content_selectors = ["div.the-article-body", "div.nws-mainContent", "div.nwsCt",
"div#ctl00_mainContent_ctl00_divNewsContent", "div.detail-content"]
cd = None
for sel in content_selectors:
cd = soup.select_one(sel)
if cd: break
if cd:
for ch in cd.children:
if not hasattr(ch,'name') or not ch.name: continue
if ch.name == "p":
t = ch.get_text(strip=True)
if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))})
elif ch.name in ("h2","h3","h4"):
body.append({"type":"heading","text":ch.get_text(strip=True)})
return {"title": title, "time": "", "summary": summary[:200], "body": body,
"related": [], "source_url": url, "source": "24h", "og_image": og_img}
except Exception as e:
return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"24h","og_image":""}
def _extract_24h_video_urls(article_url):
"""Extract ALL m3u8 URLs (multiple halves/parts) + poster from a 24h article.
Pattern: ...name1.m3u8 → ...name2.m3u8, ...name3.m3u8, etc.
Returns list of {src, poster, vtype} or empty list."""
try:
r = requests.get(article_url, headers=HEADERS, timeout=10); r.encoding="utf-8"
m3u8s = re.findall(r'(https?://cdn\.24h\.com\.vn/[^\s"\'\\<>]+\.m3u8)', r.text)
masters = list(dict.fromkeys(u for u in m3u8s if '_720p' not in u))
if not masters: return []
soup = BeautifulSoup(r.text, "lxml")
og = soup.find("meta", property="og:image")
poster = og.get("content","") if og else ""
results = [{"src": masters[0], "poster": poster, "vtype": "hls"}]
# Probe numbered parts: ...name1.m3u8 → ...name2.m3u8, etc.
base_match = re.match(r'(.+?)(\d+)(\.m3u8)$', masters[0])
if base_match:
base, start_num, ext = base_match.group(1), int(base_match.group(2)), base_match.group(3)
for i in range(start_num + 1, start_num + 10):
part_url = f"{base}{i}{ext}"
if part_url in masters:
results.append({"src": part_url, "poster": poster, "vtype": "hls"})
continue
try:
tr = requests.head(part_url, headers=HEADERS, timeout=3, allow_redirects=True)
if tr.status_code == 200:
results.append({"src": part_url, "poster": poster, "vtype": "hls"})
else: break
except: break
return results
except: return []
# ── VNE Scrapers ───────────────────────────────────────────────────────────────
def scrape_vne_list(url):
try:
soup=_get(url); articles,seen=[],set()
for i,it in enumerate(soup.select("article.item-news")):
a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a") or it.find("a",href=True,title=True)
if not a: continue
t=a.get("title","") or a.get_text(strip=True); lk=a.get("href","")
if not t or len(t)<5 or lk in seen: continue
im=it.find("img"); img=None
if im:
img=im.get("data-src") or im.get("src")
if img and 'blank' in img:
src=it.find("source")
if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
desc=it.select_one("p.description")
articles.append({"title":t,"link":lk,"img":img,"summary":desc.get_text(strip=True)[:150] if desc else "",
"time":"","featured":i==0,"source":"vne","group":"","is_video":False})
seen.add(lk)
return articles
except Exception as e:
return [{"title":f"⚠️ {e}","link":"#","img":None,"summary":"","time":"","featured":False,"source":"vne","group":"","is_video":False}]
def scrape_vne_article(url):
try:
soup=_get(url)
h1=soup.select_one("h1.title-detail"); desc=soup.select_one("p.description"); dt=soup.select_one("span.date")
og=soup.find("meta",property="og:image"); og_img=og.get("content","") if og else ""
cd=soup.select_one("article.fck_detail"); body=[]
if cd:
for ch in cd.children:
if not hasattr(ch,'name') or not ch.name: continue
if ch.name=="p":
vid=ch.find("video")
if vid:
vsrc=vid.get("src","")
if vsrc:
vtype="hls" if ("m3u8" in vsrc or "mpegURL" in (vid.get("type","") or "")) else "mp4"
body.append({"type":"video","src":vsrc,"poster":vid.get("poster",""),"vtype":vtype})
continue
im=ch.find("img")
if im:
s=im.get("data-src") or im.get("src")
if s: body.append({"type":"img","src":s,"alt":im.get("alt","")})
t=ch.get_text(strip=True)
if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))})
elif ch.name=="figure":
vid=ch.find("video")
if vid:
vsrc=vid.get("src","")
cap=ch.find("figcaption")
if vsrc:
vtype="hls" if ("m3u8" in vsrc or "mpegURL" in (vid.get("type","") or "")) else "mp4"
body.append({"type":"video","src":vsrc,"poster":vid.get("poster",""),"vtype":vtype,
"caption":cap.get_text(strip=True) if cap else ""})
continue
im=ch.find("img"); cap=ch.find("figcaption")
if im:
s=im.get("data-src") or im.get("src")
if s: body.append({"type":"img","src":s,"alt":cap.get_text(strip=True) if cap else ""})
elif ch.name in ("h2","h3","h4"):
body.append({"type":"heading","text":ch.get_text(strip=True)})
return {"title":h1.get_text(strip=True) if h1 else "","time":dt.get_text(strip=True) if dt else "",
"summary":desc.get_text(strip=True) if desc else "","body":body,"related":[],
"source_url":url,"source":"vne","og_image":og_img}
except Exception as e:
return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"vne","og_image":""}
def _extract_body(cd):
body=[]
for ch in cd.children:
if not hasattr(ch,'name') or not ch.name: continue
if ch.name=="p":
im=ch.find("img")
if im:
s=im.get("src") or im.get("data-src")
if s: body.append({"type":"img","src":s,"alt":im.get("alt","")})
t=ch.get_text(strip=True)
if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))})
elif ch.name in ("h2","h3","h4"):
body.append({"type":"heading","text":ch.get_text(strip=True)})
elif ch.name=="blockquote":
body.append({"type":"quote","text":ch.get_text(strip=True)})
return body
def _bdp_relates(soup):
rels=[]
rd=soup.select_one("div.relates")
if rd:
for a in rd.find_all("a",href=True):
t=a.get_text(strip=True); h=a.get("href","")
if t and len(t)>5:
if not h.startswith("http"): h=BASE_BDP+h
rels.append({"title":t,"link":h})
return rels[:5]
def fetch_video_list():
"""Fetch videos from 24h only."""
try:
return scrape_24h_video_list()
except:
return []
def fetch_tiktok_feed_videos():
"""Pre-fetch video URLs for TikTok fullscreen feed. 24h only."""
results = []
def _fetch_24h_vid(art):
vids = _extract_24h_video_urls(art["link"])
if not vids: return []
out = []
for pi, v in enumerate(vids):
label = f" (Phần {pi+1})" if len(vids)>1 else ""
out.append({"title": art["title"]+label, "src": v["src"], "poster": v["poster"],
"vtype": v["vtype"], "source": "24h", "link": art["link"]})
return out
h24_list = scrape_24h_video_list()[:15]
with ThreadPoolExecutor(max_workers=8) as ex:
h24_futures = {ex.submit(_fetch_24h_vid, a): a for a in h24_list}
for f in as_completed(h24_futures):
try:
r = f.result()
if r:
if isinstance(r, list): results.extend(r)
else: results.append(r)
except: pass
return results[:25]
# ── Dispatch ───────────────────────────────────────────────────────────────────
def fetch_homepage():
all_articles=[]
h24_videos=[]
def _fetch(src,url,group):
arts=scrape_bdp_list(url) if src=="bdp" else scrape_vne_list(url)
for a in arts: a["group"]=group
return arts
def _fetch_24h():
nonlocal h24_videos
try: h24_videos=scrape_24h_video_list()[:15]
except: pass
with ThreadPoolExecutor(max_workers=6) as ex:
ex.submit(_fetch_24h)
futures={ex.submit(_fetch,s,u,g):g for s,u,g in HOMEPAGE_SOURCES}
for f in as_completed(futures):
try: all_articles.extend(f.result())
except: pass
return all_articles, h24_videos
def fetch_news_list(category):
val=CATEGORIES.get(category,list(CATEGORIES.values())[0])
parts=val.split("::"); src,url_or_key,group=parts[0],parts[1],parts[2]
if src=="mix" and url_or_key=="home":
articles, h24_videos = fetch_homepage()
return render_homepage_html(articles, h24_videos)
if src=="mix" and url_or_key=="video":
return render_video_page_html()
articles=scrape_bdp_list(url_or_key) if src=="bdp" else scrape_vne_list(url_or_key)
for a in articles: a["group"]=group
return render_list_html(articles,group)
def read_article(url):
if not url or url=="#" or len(url)<10: return "<p>Không tìm thấy bài viết.</p>"
if "vnexpress.net" in url: return render_article_html(scrape_vne_article(url))
if "24h.com.vn" in url: return render_article_html(scrape_24h_article(url))
return render_article_html(scrape_bdp_article(url))
# ══════════════════════════════════════════════════════════════════════════════
# HTML RENDERERS
# ══════════════════════════════════════════════════════════════════════════════
def render_video_carousel_html(videos):
"""Carousel video 24h highlights, dùng dữ liệu list page (không fetch từng bài)."""
vids_with_img = [v for v in videos if v.get("img")]
if not vids_with_img: return ""
items = []
for i, v in enumerate(vids_with_img[:15]):
img = safe_url(v.get("img",""))
link = v.get("link","#")
title = v.get("title","")
aid = make_id(link); sl = slug(title)
click_js = f"window.bdpOpenTikTok('{esc(link)}','{aid}')"
items.append(f'''<div class="vslide-item" onclick="{click_js}">
<div class="vslide-thumb"><img src="{img}" alt="" class="bdp-lazy-img">
<div class="vslide-play">▶</div><span class="vslide-badge vslide-badge-24h">24h</span></div>
<p class="vslide-title">{title}</p></div>''')
return f'''<div class="vslide-wrap">
<div class="vslide-header"><span class="vslide-label">🎬 Video Highlight</span>
<div class="vslide-nav"><button class="vslide-btn" onclick="window.bdpSlideScroll(-1,'vslide-video')">◀</button>
<button class="vslide-btn" onclick="window.bdpSlideScroll(1,'vslide-video')">▶</button></div></div>
<div class="vslide-track" id="vslide-video">{''.join(items)}</div></div>'''
def render_featured_carousel_html(articles):
"""Carousel tin nổi bật + mới nhất, lấy từ articles đã fetch sẵn (không fetch thêm)."""
# Lấy bài featured có ảnh, ưu tiên bài đầu mỗi nhóm
top = []
seen = set()
for a in articles:
if a.get("img") and a.get("link","#") != "#" and a["link"] not in seen:
top.append(a)
seen.add(a["link"])
if len(top) >= 15: break
if not top: return ""
items = []
for i, a in enumerate(top):
img = safe_url(a.get("img",""))
link = a.get("link","#")
title = a.get("title","")
aid = make_id(link); sl = slug(title)
grp = a.get("group","")
src = a.get("source","")
badge_cls = "vslide-badge-vne" if src=="vne" else "vslide-badge-bdp" if src=="bdp" else "vslide-badge-24h"
badge_txt = grp or ("VnExpress" if src=="vne" else "BDP" if src=="bdp" else "24h")
click_js = f"window.bdpOpen('{esc(link)}','{aid}','{sl}')"
items.append(f'''<div class="vslide-item" onclick="{click_js}">
<div class="vslide-thumb"><img src="{img}" alt="" class="bdp-lazy-img">
<span class="vslide-badge {badge_cls}">{badge_txt}</span></div>
<p class="vslide-title">{title}</p></div>''')
return f'''<div class="vslide-wrap">
<div class="vslide-header"><span class="vslide-label">🔥 Tin Nổi Bật</span>
<div class="vslide-nav"><button class="vslide-btn" onclick="window.bdpSlideScroll(-1,'vslide-news')">◀</button>
<button class="vslide-btn" onclick="window.bdpSlideScroll(1,'vslide-news')">▶</button></div></div>
<div class="vslide-track" id="vslide-news">{''.join(items)}</div></div>'''
def render_homepage_html(articles, h24_videos=None):
if not articles: return "<p class='bdp-empty'>Không tìm thấy tin tức.</p>"
now=datetime.now(timezone(timedelta(hours=7))).strftime("%H:%M:%S %d/%m/%Y")
video_carousel = render_video_carousel_html(h24_videos or [])
news_carousel = render_featured_carousel_html(articles)
groups={}
for a in articles: groups.setdefault(a.get("group","Khác"),[]).append(a)
parts=[f'<div class="bdp-wrap">{video_carousel}{news_carousel}<div class="bdp-topbar"><span>⏱ {now}</span><span>📰 Tin nổi bật</span></div>']
for gn in ["Thời Sự","Thế Giới","Kinh Doanh","Công Nghệ","Thể Thao","Giải Trí","Bóng Đá"]:
arts=groups.get(gn,[])
if not arts: continue
feat=[a for a in arts if a.get("featured")][:2]
reg=[a for a in arts if not a.get("featured")][:4]
display=feat+reg
if not display: continue
parts.append(f'<div class="bdp-section"><h2 class="bdp-section-title">{gn}</h2><div class="bdp-grid bdp-grid-home">')
for i,art in enumerate(display[:6]): parts.append(_list_card(art,i<len(feat)))
parts.append('</div></div>')
parts.append('</div>')
return '\n'.join(parts)
def render_video_page_html():
"""Render video page with fullscreen TikTok feed + grid list."""
articles = fetch_video_list()
tiktok_videos = fetch_tiktok_feed_videos()
now = datetime.now(timezone(timedelta(hours=7))).strftime("%H:%M:%S %d/%m/%Y")
# Build TikTok fullscreen feed HTML
tiktok_html = ""
if tiktok_videos:
slides = []
for vi, v in enumerate(tiktok_videos):
poster = safe_url(v.get("poster",""))
poster_attr = f' poster="{poster}"' if poster else ""
vsrc = v["src"]
vtype = v.get("vtype","mp4")
title_esc = esc(v.get("title",""))
src_label = "24h" if v.get("source")=="24h" else "BDP"
badge_cls = "bdp-badge-24h" if v.get("source")=="24h" else "bdp-badge-bdp"
aid = make_id(v.get("link",""))
vid = make_id(vsrc)
sl = slug(v.get("title",""))
open_js = f"event.stopPropagation();window.bdpOpen('{esc(v.get('link',''))}','{aid}','{sl}')"
share_js = f"event.stopPropagation();window.bdpShareTikTok('{title_esc}','{sl}','{vid}')"
if vtype == "hls":
video_tag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} data-hls-src="{vsrc}" muted loop></video>'
else:
video_tag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} muted loop><source src="{safe_url(vsrc)}" type="video/mp4"></video>'
slides.append(f'''<div class="tiktok-slide" data-index="{vi}" data-aid="{aid}" data-vid="{vid}">
{video_tag}
<div class="tiktok-pause-icon">▶</div>
<div class="tiktok-seek-controls">
<button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,-10)">⏪ 10s</button>
<button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,10)">10s ⏩</button>
</div>
<div class="tiktok-bottom">
<span class="bdp-badge {badge_cls}">{src_label}</span>
<p class="tiktok-title">{v.get("title","")}</p>
<div class="tiktok-actions">
<button class="tiktok-action-btn" onclick="{open_js}">📰 Đọc</button>
<button class="tiktok-action-btn" onclick="{share_js}">📤 Chia sẻ</button>
</div>
</div>
<div class="tiktok-unmute-hint" onclick="window.bdpTikTokUnmute(this)">🔇 Nhấn để bật tiếng</div>
<span class="tiktok-counter">{vi+1}/{len(tiktok_videos)}</span>
</div>''')
tiktok_html = f'''<div class="tiktok-fullscreen-container" id="tiktok-fullscreen-feed">
<div class="tiktok-fullscreen-feed">{''.join(slides)}</div>
</div>'''
# Also render article grid below
grid_html = ""
if articles:
feat=[a for a in articles if a.get("featured")]
reg=[a for a in articles if not a.get("featured")]
grid_parts = ['<div class="bdp-wrap">']
grid_parts.append(f'<div class="bdp-topbar"><span>⏱ {now}</span><span>📰 {len(articles)} video · BDP + 24h</span></div>')
if feat:
grid_parts.append('<div class="bdp-grid bdp-grid-featured">')
for a in feat[:6]: grid_parts.append(_list_card(a,True,tiktok=True))
grid_parts.append('</div>')
if reg:
grid_parts.append('<div class="bdp-grid">')
for a in reg[:40]: grid_parts.append(_list_card(a,False,tiktok=True))
grid_parts.append('</div>')
grid_parts.append('</div>')
grid_html = '\n'.join(grid_parts)
return tiktok_html + grid_html
def render_list_html(articles,group_name=""):
if not articles: return "<p class='bdp-empty'>Không tìm thấy tin tức.</p>"
now=datetime.now(timezone(timedelta(hours=7))).strftime("%H:%M:%S %d/%m/%Y")
feat=[a for a in articles if a.get("featured")]
reg=[a for a in articles if not a.get("featured")]
parts=[f'<div class="bdp-wrap"><div class="bdp-topbar"><span>⏱ {now}</span><span>📰 {len(articles)} tin · {group_name}</span></div>']
if feat:
parts.append('<div class="bdp-grid bdp-grid-featured">')
for a in feat[:6]: parts.append(_list_card(a,True))
parts.append('</div>')
if reg:
parts.append('<div class="bdp-grid">')
for a in reg[:40]: parts.append(_list_card(a,False))
parts.append('</div>')
parts.append('</div>')
return '\n'.join(parts)
def _list_card(art,big,tiktok=False):
img_html=""
if art.get("img"):
c="bdp-card-img bdp-card-img-big" if big else "bdp-card-img"
is_video = art.get("is_video", False) or "/video/" in art.get("link","")
play_overlay = '<div class="bdp-play-overlay">▶</div>' if is_video else ""
img_html=f'<div class="{c}"><img src="{safe_url(art["img"])}" alt="" class="bdp-lazy-img">{play_overlay}</div>'
time_html=f'<span class="bdp-card-time">🕐 {art["time"]}</span>' if art.get("time") else ""
summ_html=f'<p class="bdp-card-summ">{art["summary"][:120]}...</p>' if art.get("summary") and len(art["summary"])>10 else ""
link=art.get("link","#"); aid=make_id(link)
tc="bdp-card-title bdp-card-title-big" if big else "bdp-card-title"
grp=art.get("group",""); badge=""
if art.get("source")=="vne": badge=f'<span class="bdp-badge bdp-badge-vne">{grp or "VnExpress"}</span>'
elif art.get("source")=="24h": badge=f'<span class="bdp-badge bdp-badge-24h">{grp or "24h"}</span>'
elif art.get("source")=="bdp": badge=f'<span class="bdp-badge bdp-badge-bdp">{grp or "BongDaPlus"}</span>'
sl=slug(art["title"])
share_js=f"event.stopPropagation();window.bdpShareHash('{esc(art['title'])}','{sl}','{aid}')"
if tiktok:
click_js=f"window.bdpOpenTikTok('{esc(link)}','{aid}')"
else:
click_js=f"window.bdpOpen('{esc(link)}','{aid}','{sl}')"
return f"""<div class="bdp-card" onclick="{click_js}">
{img_html}<div class="bdp-card-body">{badge}<h3 class="{tc}">{art['title']}</h3>
{summ_html}<div class="bdp-card-footer">{time_html}
<button class="bdp-share-btn" onclick="{share_js}" title="Chia sẻ">📤</button></div></div></div>"""
def render_article_html(article):
aid=make_id(article["source_url"]); sl=slug(article["title"])
src_url=article.get("source_url","")
og_img=safe_url(article.get("og_image",""))
share_js=f"window.bdpShareHash('{esc(article['title'])}','{sl}','{aid}')"
src_map={"vne":"VnExpress","bdp":"BongDaPlus","24h":"24h.com.vn"}
src_label=src_map.get(article.get("source",""),"")
seo=f'<div style="display:none" itemscope itemtype="https://schema.org/NewsArticle"><meta itemprop="headline" content="{esc(article["title"])}"><meta itemprop="image" content="{og_img}"><meta itemprop="description" content="{esc(article.get("summary","")[:160])}"></div>'
parts=[f"""{seo}<div class="bdp-article">
<h1 class="bdp-article-title">{article['title']}</h1>
<div class="bdp-article-meta"><span>🕐 {article['time']} · {src_label}</span>
<button class="bdp-share-article-btn" onclick="{share_js}">📤 Chia sẻ</button></div>"""]
if article.get("summary"):
parts.append(f'<div class="bdp-article-summary">{article["summary"]}</div>')
for item in article.get("body",[]):
if item["type"]=="video":
poster=safe_url(item.get("poster",""))
poster_attr=f' poster="{poster}"' if poster else ""
caption=item.get("caption","")
cap_html=f'<p class="bdp-figcap">{caption}</p>' if caption else ""
vtype=item.get("vtype","mp4")
vsrc=item["src"]
if vtype=="hls":
parts.append(f'<div class="bdp-video-wrap"><video controls playsinline preload="metadata"{poster_attr} class="bdp-video" data-hls-src="{vsrc}"></video>{cap_html}</div>')
else:
parts.append(f'<div class="bdp-video-wrap"><video controls playsinline preload="metadata"{poster_attr} class="bdp-video"><source src="{safe_url(vsrc)}" type="video/mp4"></video>{cap_html}</div>')
elif item["type"]=="img":
alt=item.get("alt",""); cap=f'<figcaption class="bdp-figcap">{alt}</figcaption>' if alt else ""
parts.append(f'<figure class="bdp-figure"><img src="{safe_url(item["src"])}" alt="{alt}" class="bdp-lazy-img">{cap}</figure>')
elif item["type"]=="p":
parts.append(f'<p class="bdp-article-p">{item["html"]}</p>')
elif item["type"]=="heading":
parts.append(f'<h2 class="bdp-article-h2">{item["text"]}</h2>')
elif item["type"]=="quote":
parts.append(f'<blockquote class="bdp-quote">{item["text"]}</blockquote>')
if article.get("related"):
parts.append('<div class="bdp-related"><h3>📰 Tin liên quan</h3>')
for rel in article["related"]:
rid=make_id(rel["link"]); rs=slug(rel["title"])
parts.append(f'<div class="bdp-related-item" onclick="window.bdpOpen(\'{esc(rel["link"])}\',\'{rid}\',\'{rs}\')"><span>▸ {rel["title"]}</span></div>')
parts.append('</div>')
parts.append(f"""<div class="bdp-comments" id="comments-{aid}"><h3>💬 Bình luận</h3>
<div id="cmt-list-{aid}"></div><div class="bdp-cmt-form">
<input id="cmt-name-{aid}" class="bdp-cmt-input" placeholder="Tên của bạn..." maxlength="50">
<textarea id="cmt-text-{aid}" class="bdp-cmt-textarea" placeholder="Viết bình luận..." rows="3" maxlength="500"></textarea>
<button class="bdp-cmt-submit" onclick="window.bdpAddCmt('{aid}')">Gửi bình luận</button></div></div>""")
parts.append('</div>')
return '\n'.join(parts)
# ══════════════════════════════════════════════════════════════════════════════
CSS = """
body,html{margin:0!important;padding:0!important;overflow-x:hidden;background:#111!important}
.gradio-container{max-width:100%!important;width:100%!important;margin:0!important;padding:0!important;border-radius:0!important;background:#111!important}
.main,.contain{max-width:100%!important;width:100%!important;padding:0!important;margin:0!important}
.gradio-container>.main>.contain{padding-top:0!important}
.gap{gap:0!important}
footer,.built-with{display:none!important}
#article-url-input,#btn-read-article{display:none!important;height:0!important;overflow:hidden!important}
.bdp-header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:14px 16px;text-align:center}
.bdp-header h1{color:#fff;font-size:20px;margin:0;font-weight:800;text-shadow:0 2px 6px rgba(0,0,0,.4)}
.bdp-header p{color:rgba(255,255,255,.6);font-size:11px;margin:2px 0 0}
@media(min-width:768px){.bdp-header h1{font-size:26px}.bdp-header{padding:20px}}
#cat-input,#btn-switch-cat{display:none!important;height:0!important;overflow:hidden!important}
.vslide-wrap{margin:8px;background:#1a1a1a;border-radius:12px;overflow:hidden;border:1px solid #2a2a2a}
.vslide-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px}
.vslide-label{color:#f0c040;font-size:15px;font-weight:700}
.vslide-nav{display:flex;gap:6px}
.vslide-btn{background:#333;color:#fff;border:none;width:30px;height:30px;border-radius:50%;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;transition:background .15s}
.vslide-btn:hover{background:#555}
.vslide-track{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;gap:10px;padding:6px 14px 14px;scrollbar-width:none}
.vslide-track::-webkit-scrollbar{display:none}
.vslide-item{flex:0 0 200px;scroll-snap-align:start;cursor:pointer;transition:transform .15s}
.vslide-item:hover{transform:scale(1.03)}
@media(min-width:768px){.vslide-item{flex:0 0 240px}}
.vslide-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:8px;overflow:hidden;background:#222}
.vslide-thumb img{width:100%;height:100%;object-fit:cover}
.vslide-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:40px;height:40px;background:rgba(0,0,0,.6);border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:16px;pointer-events:none;transition:background .15s}
.vslide-item:hover .vslide-play{background:rgba(220,50,50,.8)}
.vslide-title{color:#ccc;font-size:12px;margin:6px 0 0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.bdp-wrap{padding:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
.bdp-topbar{display:flex;justify-content:space-between;padding:4px 4px 8px;color:#666;font-size:11px}
.bdp-empty{text-align:center;color:#666;padding:60px 20px}
.bdp-section{margin-bottom:16px}
.bdp-section-title{font-size:16px;font-weight:700;color:#5cb87a;margin:4px 0 8px;border-left:3px solid #5cb87a;padding-left:8px}
@media(min-width:768px){.bdp-section-title{font-size:18px}}
.bdp-grid{display:grid;grid-template-columns:1fr;gap:8px}
.bdp-grid-featured,.bdp-grid-home{margin-bottom:4px}
@media(min-width:420px){.bdp-grid{grid-template-columns:repeat(2,1fr)}}
@media(min-width:768px){.bdp-grid{grid-template-columns:repeat(3,1fr);gap:10px}}
@media(min-width:1100px){.bdp-grid{grid-template-columns:repeat(4,1fr)}}
.bdp-card{background:#1a1a1a;border-radius:10px;overflow:hidden;cursor:pointer;transition:transform .15s,box-shadow .15s;border:1px solid #222}
.bdp-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.5)}
.bdp-card:active{transform:scale(.98)}
.bdp-card-img{width:100%;height:130px;overflow:hidden;background:#222;position:relative}
.bdp-card-img-big{height:170px}
.bdp-card-img img{width:100%;height:100%;object-fit:cover}
@media(min-width:768px){.bdp-card-img{height:150px}.bdp-card-img-big{height:190px}}
.bdp-play-overlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:36px;height:36px;background:rgba(0,0,0,.55);border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:15px;pointer-events:none}
.bdp-card-body{padding:8px 10px 6px}
.bdp-card-title{font-size:13px;font-weight:600;color:#eee;margin:0;line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.bdp-card-title-big{font-size:14.5px}
@media(min-width:768px){.bdp-card-title{font-size:13.5px}.bdp-card-title-big{font-size:15px}}
.bdp-card-summ{font-size:11.5px;color:#777;margin:4px 0 0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.bdp-card-footer{display:flex;justify-content:space-between;align-items:center;margin-top:6px}
.bdp-card-time{color:#555;font-size:10.5px}
.bdp-share-btn{background:none;border:none;cursor:pointer;font-size:15px;padding:3px 5px;border-radius:6px;transition:background .15s;color:#777}
.bdp-share-btn:hover{background:#333}
.bdp-badge{font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:4px}
.bdp-badge-vne{background:#c0392b;color:#fff}
.bdp-badge-bdp{background:#1a5c35;color:#fff}
.bdp-badge-24h{background:#e67e22;color:#fff}
.bdp-article{padding:14px 12px 30px;max-width:720px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
@media(min-width:768px){.bdp-article{padding:20px 16px 50px}}
.bdp-article-title{font-size:21px;font-weight:800;color:#f0f0f0;line-height:1.3;margin:0 0 8px}
@media(min-width:768px){.bdp-article-title{font-size:27px}}
.bdp-article-meta{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;color:#666;font-size:12px;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #2a2a2a}
.bdp-share-article-btn{background:linear-gradient(135deg,#1a5c35,#2d8659);color:#fff;border:none;padding:6px 14px;border-radius:18px;font-size:12px;cursor:pointer;font-weight:600}
.bdp-share-article-btn:hover{opacity:.85}
.bdp-article-summary{background:#1a2a1f;border-left:4px solid #2d8659;padding:12px 14px;margin-bottom:16px;border-radius:0 8px 8px 0;font-weight:600;color:#ccc;line-height:1.5;font-size:14.5px}
.bdp-article-p{font-size:15.5px;line-height:1.75;color:#ccc;margin:0 0 12px}
@media(min-width:768px){.bdp-article-p{font-size:16.5px}}
.bdp-article-h2{font-size:19px;font-weight:700;color:#eee;margin:24px 0 10px}
.bdp-quote{border-left:4px solid #b8960c;padding:10px 14px;margin:14px 0;background:#1a1a10;font-style:italic;color:#bbb;border-radius:0 6px 6px 0}
.bdp-figure{margin:14px 0;text-align:center}
.bdp-figure img{max-width:100%;height:auto;border-radius:8px}
.bdp-figcap{color:#666;font-size:11.5px;margin-top:4px;font-style:italic}
.bdp-video-wrap{margin:14px 0;border-radius:10px;overflow:hidden;background:#000}
.bdp-video{width:100%;max-height:70vh;display:block;border-radius:10px}
.bdp-related{margin-top:24px;padding-top:14px;border-top:1px solid #2a2a2a}
.bdp-related h3{font-size:16px;color:#eee;margin:0 0 8px}
.bdp-related-item{padding:8px 10px;margin-bottom:5px;border:1px solid #262626;border-radius:8px;cursor:pointer;transition:background .15s}
.bdp-related-item:hover{background:#222}
.bdp-related-item span{font-size:13.5px;color:#5cb87a;font-weight:500}
.bdp-comments{margin-top:28px;padding-top:16px;border-top:1px solid #2a2a2a}
.bdp-comments h3{font-size:16px;color:#eee;margin:0 0 10px}
.bdp-cmt-item{background:#1a1a1a;border:1px solid #262626;border-radius:8px;padding:10px 12px;margin-bottom:8px}
.bdp-cmt-author{font-weight:700;color:#5cb87a;font-size:13px}
.bdp-cmt-date{color:#555;font-size:11px;margin-left:8px}
.bdp-cmt-body{color:#ccc;font-size:14px;margin-top:4px;line-height:1.5}
.bdp-cmt-form{margin-top:12px}
.bdp-cmt-input,.bdp-cmt-textarea{width:100%;padding:8px 10px;background:#1a1a1a;border:1px solid #333;border-radius:6px;color:#eee;font-size:13px;box-sizing:border-box}
.bdp-cmt-input{margin-bottom:6px}
.bdp-cmt-textarea{resize:vertical}
.bdp-cmt-submit{background:linear-gradient(135deg,#1a5c35,#2d8659);color:#fff;border:none;padding:8px 20px;border-radius:18px;font-size:13px;cursor:pointer;font-weight:600;margin-top:8px}
.bdp-cmt-submit:hover{opacity:.85}
.bdp-cmt-empty{color:#555;font-size:13px;font-style:italic;padding:8px 0}
.bdp-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 22px;border-radius:22px;font-size:13px;z-index:9999;opacity:0;transition:opacity .3s;pointer-events:none;font-weight:500}
.bdp-toast.show{opacity:1}
.gr-group,.gr-box,.gr-panel{background:#111!important;border:none!important}
.label-wrap{background:#111!important}
/* ══ TikTok Fullscreen Video Feed ══ */
.tiktok-fullscreen-container{width:100%;background:#000;position:relative;height:calc(100vh - 60px);max-height:900px;min-height:500px;border-radius:0}
.tiktok-fullscreen-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.tiktok-fullscreen-feed::-webkit-scrollbar{display:none}
.tiktok-slide{height:calc(100vh - 60px);max-height:900px;min-height:500px;scroll-snap-align:start;scroll-snap-stop:always;position:relative;display:flex;align-items:center;justify-content:center;background:#000}
.tiktok-video{width:100%;height:100%;object-fit:contain;display:block}
.tiktok-bottom{position:absolute;bottom:0;left:0;right:0;padding:16px 14px 24px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}
.tiktok-title{color:#fff;font-size:14px;font-weight:600;margin:4px 0 8px;line-height:1.4;text-shadow:0 1px 4px rgba(0,0,0,.8);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.tiktok-actions{display:flex;gap:8px}
.tiktok-action-btn{background:rgba(255,255,255,.15);color:#fff;border:none;padding:6px 14px;border-radius:18px;font-size:12px;cursor:pointer;backdrop-filter:blur(6px);font-weight:500;transition:background .15s}
.tiktok-action-btn:hover{background:rgba(255,255,255,.3)}
.tiktok-counter{position:absolute;top:12px;left:12px;background:rgba(0,0,0,.5);color:#fff;font-size:11px;padding:3px 10px;border-radius:12px;z-index:4;backdrop-filter:blur(4px)}
.tiktok-unmute-hint{position:absolute;top:12px;right:12px;background:rgba(0,0,0,.6);color:#fff;font-size:12px;padding:6px 12px;border-radius:18px;cursor:pointer;z-index:4;backdrop-filter:blur(4px);transition:opacity .3s}
.tiktok-pause-icon{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:70px;height:70px;background:rgba(0,0,0,.5);border-radius:50%;color:#fff;font-size:28px;z-index:5;pointer-events:none;display:none;align-items:center;justify-content:center;line-height:70px;text-align:center}
.tiktok-slide.paused .tiktok-pause-icon{display:block}
.tiktok-seek-controls{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);display:flex;gap:40px;z-index:6;pointer-events:auto;opacity:0;transition:opacity .3s;pointer-events:none}
.tiktok-slide.show-controls .tiktok-seek-controls{opacity:1;pointer-events:auto}
.tiktok-seek-btn{background:rgba(0,0,0,.4);color:#fff;border:none;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer;backdrop-filter:blur(4px);font-weight:600;transition:opacity .2s}
.tiktok-seek-btn:hover{opacity:1}
.tiktok-seek-btn:active{transform:scale(.9)}
.vslide-badge{position:absolute;top:6px;left:6px;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;z-index:2}
.vslide-badge-24h{background:#e67e22;color:#fff}
/* ══ Category Icon Grid ══ */
.cat-grid-wrap{padding:6px 8px 2px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;background:#111}
.cat-grid-wrap::-webkit-scrollbar{display:none}
.cat-grid{display:flex;gap:6px;min-width:max-content}
.cat-icon-btn{display:flex;flex-direction:column;align-items:center;justify-content:center;min-width:62px;padding:7px 4px 5px;border-radius:10px;cursor:pointer;transition:background .15s,transform .1s;background:#1a1a1a;border:1.5px solid transparent;user-select:none;-webkit-tap-highlight-color:transparent;text-decoration:none}
.cat-icon-btn:hover{background:#252525;transform:scale(1.04)}
.cat-icon-btn:active{transform:scale(.95)}
.cat-icon-btn.active{background:#1a3a2a;border-color:#5cb87a}
.cat-icon-emoji{font-size:22px;line-height:1.2}
.cat-icon-label{font-size:9.5px;color:#aaa;margin-top:2px;white-space:nowrap;font-weight:500;text-align:center;max-width:68px;overflow:hidden;text-overflow:ellipsis}
.cat-icon-btn.active .cat-icon-label{color:#5cb87a;font-weight:700}
@media(min-width:768px){.cat-icon-btn{min-width:72px;padding:8px 6px 6px}.cat-icon-emoji{font-size:25px}.cat-icon-label{font-size:10.5px;max-width:76px}}
"""
HEAD_META = """
<meta name="description" content="Tin tức tổng hợp nhanh nhất - VnExpress, BongDaPlus, 24h">
<meta property="og:title" content="Tin Tức Việt Nam - Tổng Hợp">
<meta property="og:description" content="Thời sự, thế giới, kinh doanh, công nghệ, thể thao, giải trí.">
<meta property="og:type" content="website">
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="https://bep40-bongdaplus-news.hf.space">
"""
JS_FUNC = """
function() {
var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js';document.head.appendChild(s);
var SPACE_BASE='""" + SPACE_URL + """';
window.bdpOpen=function(url,aid,sl){
window.location.hash='#/'+sl+'/'+aid;
try{localStorage.setItem('bdp_url_'+aid,url);}catch(e){}
var el=document.getElementById('article-url-input');
if(el){var ta=el.querySelector('textarea');if(ta){ta.value=url;ta.dispatchEvent(new Event('input',{bubbles:true}));}}
var btn=document.getElementById('btn-read-article');
if(btn){var b=btn.querySelector('button');if(b)b.click();else btn.click();}
window.scrollTo({top:0,behavior:'smooth'});
};
window.bdpShareHash=async function(title,sl,aid){
var hashUrl=SPACE_BASE+'/#/'+sl+'/'+aid;
var sd={title:title,url:hashUrl,text:title};
if(navigator.share){try{await navigator.share(sd);return;}catch(e){if(e.name==='AbortError')return;}}
if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(hashUrl);window.bdpToast('Đã sao chép liên kết!');return;}catch(e){}}
var ta=document.createElement('textarea');ta.value=hashUrl;ta.style.cssText='position:fixed;opacity:0';
document.body.appendChild(ta);ta.focus();ta.select();
try{document.execCommand('copy');window.bdpToast('Đã sao chép liên kết!');}catch(e){window.prompt('Sao chép:',hashUrl);}
document.body.removeChild(ta);
};
window.bdpShareTikTok=async function(title,sl,vid){
var hashUrl=SPACE_BASE+'/#/v/'+sl+'/'+vid;
var sd={title:title,url:hashUrl,text:title};
if(navigator.share){try{await navigator.share(sd);return;}catch(e){if(e.name==='AbortError')return;}}
if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(hashUrl);window.bdpToast('Đã sao chép liên kết!');return;}catch(e){}}
var ta=document.createElement('textarea');ta.value=hashUrl;ta.style.cssText='position:fixed;opacity:0';
document.body.appendChild(ta);ta.focus();ta.select();
try{document.execCommand('copy');window.bdpToast('Đã sao chép liên kết!');}catch(e){window.prompt('Sao chép:',hashUrl);}
document.body.removeChild(ta);
};
window.bdpToast=function(m){var e=document.getElementById('bdp-toast');if(!e){e=document.createElement('div');e.id='bdp-toast';e.className='bdp-toast';document.body.appendChild(e);}e.innerText=m;e.classList.add('show');setTimeout(function(){e.classList.remove('show');},2200);};
window.bdpSlideScroll=function(dir,trackId){
var track=document.getElementById(trackId||'vslide-track');
if(track){track.scrollBy({left:dir*260,behavior:'smooth'});}
};
/* ══ Category Icon Grid ══ */
window.bdpSelectCat=function(catKey,hashSlug){
window.location.hash='#cat/'+hashSlug;
/* Update active state visually */
document.querySelectorAll('.cat-icon-btn').forEach(function(b){
b.classList.toggle('active',b.getAttribute('data-cat')===catKey);
});
/* Trigger server-side category switch via hidden textbox+button */
window._bdpSetCat(catKey);
};
window._bdpSetCat=function(catKey){
var el=document.getElementById('cat-input');
if(el){var ta=el.querySelector('textarea')||el.querySelector('input');if(ta){ta.value=catKey;ta.dispatchEvent(new Event('input',{bubbles:true}));}}
var btn=document.getElementById('btn-switch-cat');
if(btn){var b=btn.querySelector('button');if(b)b.click();else btn.click();}
window.scrollTo({top:0,behavior:'smooth'});
};
function gc(a){try{return JSON.parse(localStorage.getItem('bdp_cmt_'+a))||[];}catch(e){return[];}}
function sc(a,c){try{localStorage.setItem('bdp_cmt_'+a,JSON.stringify(c));}catch(e){}}
window.bdpRenderCmt=function(a){var l=document.getElementById('cmt-list-'+a);if(!l)return;var c=gc(a);if(!c.length){l.innerHTML='<div class="bdp-cmt-empty">Chưa có bình luận. Hãy là người đầu tiên!</div>';return;}var h='';for(var i=c.length-1;i>=0;i--){var x=c[i];h+='<div class="bdp-cmt-item"><span class="bdp-cmt-author">'+x.name+'</span><span class="bdp-cmt-date">'+x.date+'</span><div class="bdp-cmt-body">'+x.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</div></div>';}l.innerHTML=h;};
window.bdpAddCmt=function(a){var n=document.getElementById('cmt-name-'+a),t=document.getElementById('cmt-text-'+a);if(!n||!t)return;var nm=n.value.trim(),tx=t.value.trim();if(!nm){window.bdpToast('Nhập tên');n.focus();return;}if(!tx){window.bdpToast('Nhập bình luận');t.focus();return;}var c=gc(a);c.push({name:nm,text:tx,date:new Date().toLocaleString('vi-VN')});sc(a,c);t.value='';window.bdpRenderCmt(a);window.bdpToast('Đã gửi!');};
function initHlsVideo(v){
if(v._hlsInit) return; v._hlsInit=true;
var src=v.getAttribute('data-hls-src'); if(!src) return;
if(v.canPlayType('application/vnd.apple.mpegURL')){v.src=src;}
else if(window.Hls&&Hls.isSupported()){var h=new Hls({maxBufferLength:30,maxMaxBufferLength:60});h.loadSource(src);h.attachMedia(v);h.on(Hls.Events.ERROR,function(e,data){if(data.fatal){v.src=src;}});}
else{v.src=src;}
}
/* ══ TikTok Fullscreen Feed ══ */
window.bdpSeek=function(btn,sec){
var slide=btn.closest('.tiktok-slide');
if(!slide) return;
var v=slide.querySelector('.tiktok-video');
if(!v) return;
v.currentTime=Math.max(0,Math.min(v.duration||9999,v.currentTime+sec));
};
window.bdpOpenTikTok=function(url,aid){
/* Switch to Video tab and scroll TikTok feed to matching video */
try{localStorage.setItem('bdp_url_'+aid,url);}catch(e){}
window.location.hash='#cat/video';
/* Update icon grid active state */
document.querySelectorAll('.cat-icon-btn').forEach(function(b){
b.classList.toggle('active',b.getAttribute('data-hash')==='video');
});
/* Trigger server-side switch to Video category */
var el=document.getElementById('cat-input');
if(el){var ta=el.querySelector('textarea')||el.querySelector('input');if(ta){ta.value='\U0001f3ac Video T\u1ed5ng H\u1ee3p';ta.dispatchEvent(new Event('input',{bubbles:true}));}}
var btn=document.getElementById('btn-switch-cat');
if(btn){var b=btn.querySelector('button');if(b)b.click();else btn.click();}
/* After content loads, scroll to the matching video in TikTok feed */
var attempts=0;
var finder=setInterval(function(){
attempts++;
var feed=document.querySelector('.tiktok-fullscreen-feed');
if(feed){
clearInterval(finder);
var slides=feed.querySelectorAll('.tiktok-slide');
var targetIdx=-1;
slides.forEach(function(sl,i){
if(targetIdx<0 && sl.getAttribute('data-aid')===aid) targetIdx=i;
});
if(targetIdx>=0 && slides[targetIdx]){
slides[targetIdx].scrollIntoView({behavior:'smooth'});
}
window.scrollTo({top:0,behavior:'smooth'});
}
if(attempts>30) clearInterval(finder);
},300);
};
window.bdpTikTokUnmute=function(hint){
var feed=hint.closest('.tiktok-fullscreen-feed')||hint.closest('.tiktok-feed');
var slide=hint.closest('.tiktok-slide');
if(!slide) return;
var video=slide.querySelector('.tiktok-video');
if(!video) return;
video.muted=!video.muted;
var isMuted=video.muted;
hint.textContent=isMuted?'🔇 Nhấn để bật tiếng':'🔊 Đang phát tiếng';
if(feed){
feed.querySelectorAll('.tiktok-video').forEach(function(v){v.muted=isMuted;});
feed.querySelectorAll('.tiktok-unmute-hint').forEach(function(h){
h.textContent=isMuted?'🔇 Nhấn để bật tiếng':'🔊 Đang phát tiếng';
});
}
};
function initTikTokFullscreen(container){
if(container._tikInit) return;
container._tikInit=true;
var feed=container.querySelector('.tiktok-fullscreen-feed');
if(!feed) return;
var slides=feed.querySelectorAll('.tiktok-slide');
if(!slides.length) return;
/* Init all HLS videos first */
slides.forEach(function(sl){
var v=sl.querySelector('video[data-hls-src]');
if(v) initHlsVideo(v);
var v2=sl.querySelector('video:not([data-hls-src])');
if(v2 && !v2._initDone){v2._initDone=true;v2.load();}
});
var currentIdx=-1;
function tryPlay(v){
var p=v.play();
if(p&&p.catch) p.catch(function(){setTimeout(function(){v.play().catch(function(){});},500);});
}
function activateSlide(idx){
if(idx===currentIdx) return;
slides.forEach(function(sl,i){
var v=sl.querySelector('.tiktok-video');
if(!v) return;
if(i===idx){
v.currentTime=0;
tryPlay(v);
sl.classList.remove('paused');
} else {
v.pause();
sl.classList.remove('paused');
}
});
currentIdx=idx;
}
/* Use scroll event instead of IntersectionObserver for reliability inside Gradio */
var scrollTimer=null;
feed.addEventListener('scroll',function(){
if(scrollTimer) clearTimeout(scrollTimer);
scrollTimer=setTimeout(function(){
var feedRect=feed.getBoundingClientRect();
var feedCenter=feedRect.top+feedRect.height/2;
var best=-1,bestDist=99999;
slides.forEach(function(sl,i){
var r=sl.getBoundingClientRect();
var center=r.top+r.height/2;
var dist=Math.abs(center-feedCenter);
if(dist<bestDist){bestDist=dist;best=i;}
});
if(best>=0) activateSlide(best);
},150);
});
/* Start first video after short delay for HLS init */
setTimeout(function(){activateSlide(0);},800);
/* Tap to pause/play + show/hide seek controls */
slides.forEach(function(sl){
var v=sl.querySelector('.tiktok-video');
var hideTimer=null;
function showSeekControls(){
sl.classList.add('show-controls');
if(hideTimer) clearTimeout(hideTimer);
hideTimer=setTimeout(function(){sl.classList.remove('show-controls');},3000);
}
if(v){
v.addEventListener('click',function(e){
e.preventDefault();
if(v.paused){tryPlay(v);sl.classList.remove('paused');}
else{v.pause();sl.classList.add('paused');}
showSeekControls();
});
v.addEventListener('touchstart',function(){showSeekControls();},{passive:true});
}
});
}
new MutationObserver(function(muts){muts.forEach(function(m){m.addedNodes.forEach(function(n){
if(!n.querySelectorAll) return;
n.querySelectorAll('img.bdp-lazy-img').forEach(function(img){if(!img._errBound){img._errBound=true;img.addEventListener('error',function(){this.style.display='none';});}});
n.querySelectorAll('video[data-hls-src]').forEach(initHlsVideo);
n.querySelectorAll('video.bdp-video:not([data-hls-src])').forEach(function(v){if(!v._initDone){v._initDone=true;v.load();}});
n.querySelectorAll('[id^="cmt-list-"]').forEach(function(d){window.bdpRenderCmt(d.id.replace('cmt-list-',''));});
n.querySelectorAll('.tiktok-fullscreen-container').forEach(initTikTokFullscreen);
if(n.classList && n.classList.contains('tiktok-fullscreen-container')) initTikTokFullscreen(n);
});});}).observe(document.body,{childList:true,subtree:true});
setInterval(function(){
document.querySelectorAll('video[data-hls-src]').forEach(initHlsVideo);
document.querySelectorAll('video.bdp-video:not([data-hls-src])').forEach(function(v){if(!v._initDone){v._initDone=true;v.load();}});
document.querySelectorAll('.tiktok-fullscreen-container').forEach(initTikTokFullscreen);
},1500);
var hh=window.location.hash;
if(hh&&hh.startsWith('#/v/')){
/* TikTok video hash: #/v/slug/vid - switch to video page then scroll to vid */
var vps=hh.slice(4).split('/');
var vid=vps[vps.length-1];
setTimeout(function(){
/* Switch to Video category */
window._bdpSetCat('\U0001f3ac Video T\u1ed5ng H\u1ee3p');
document.querySelectorAll('.cat-icon-btn').forEach(function(b){
b.classList.toggle('active',b.getAttribute('data-hash')==='video');
});
/* Poll for TikTok feed to appear then scroll to matching vid */
var att=0;
var ff=setInterval(function(){
att++;
var feed=document.querySelector('.tiktok-fullscreen-feed');
if(feed){
clearInterval(ff);
var slides=feed.querySelectorAll('.tiktok-slide');
slides.forEach(function(sl,i){
if(sl.getAttribute('data-vid')===vid){
sl.scrollIntoView({behavior:'smooth'});
}
});
window.scrollTo({top:0,behavior:'smooth'});
}
if(att>30) clearInterval(ff);
},300);
},1500);
} else if(hh&&hh.startsWith('#cat/')){
/* Category hash: #cat/video, #cat/thoi-su, etc. */
var catSlug=hh.slice(5);
var catMap={""" + ",".join(f"'{ci[2]}':'{esc(CAT_KEYS[i])}'" for i,ci in enumerate(CAT_ICONS)) + """};
if(catMap[catSlug]){
setTimeout(function(){
window._bdpSetCat(catMap[catSlug]);
document.querySelectorAll('.cat-icon-btn').forEach(function(b){
b.classList.toggle('active',b.getAttribute('data-hash')===catSlug);
});
},1500);
}
} else if(hh&&hh.startsWith('#/')){var ps=hh.slice(2).split('/');if(ps.length>=2){var aid=ps[ps.length-1];try{var url=localStorage.getItem('bdp_url_'+aid);if(url)setTimeout(function(){window.bdpOpen(url,aid,ps.slice(0,-1).join('/'));},2000);}catch(e){}}}
}
"""
# ══════════════════════════════════════════════════════════════════════════════
def _build_cat_grid_html():
"""Build the category icon grid HTML."""
items = []
for i, (icon, label, hslug) in enumerate(CAT_ICONS):
cat_key = CAT_KEYS[i]
active = " active" if i == 0 else ""
click_js = f"window.bdpSelectCat('{esc(cat_key)}','{hslug}')"
items.append(f'<div class="cat-icon-btn{active}" data-cat="{esc(cat_key)}" data-hash="{hslug}" onclick="{click_js}"><span class="cat-icon-emoji">{icon}</span><span class="cat-icon-label">{label}</span></div>')
return f'<div class="cat-grid-wrap"><div class="cat-grid">{"".join(items)}</div></div>'
with gr.Blocks(title="Tin Tức Việt Nam",css=CSS,head=HEAD_META,js=JS_FUNC,theme=gr.themes.Base(),fill_width=True) as demo:
gr.HTML('<div class="bdp-header"><h1>📰 Tin Tức Việt Nam</h1><p>VnExpress · BongDaPlus · 24h · Thời sự · Thế giới · Kinh doanh · Công nghệ · Thể thao · Giải trí · Video</p></div>')
gr.HTML(_build_cat_grid_html())
article_url=gr.Textbox(value="",visible=False,elem_id="article-url-input")
cat_input=gr.Textbox(value="",visible=False,elem_id="cat-input")
back_btn=gr.Button("← Quay lại",variant="secondary",visible=False)
news_list=gr.HTML()
article_view=gr.HTML(visible=False)
read_btn=gr.Button("Đọc",visible=False,elem_id="btn-read-article")
cat_btn=gr.Button("Cat",visible=False,elem_id="btn-switch-cat")
def show_article(url):
if not url or url=="#" or len(url)<10:
return gr.update(visible=True),gr.update(visible=False),gr.update(visible=False),""
return (gr.update(visible=False),gr.update(value=read_article(url),visible=True),gr.update(visible=True),"")
def switch_cat(cat_key):
cat_key=cat_key.strip()
if not cat_key or cat_key not in CATEGORIES:
cat_key=list(CATEGORIES.keys())[0]
return (gr.update(value=fetch_news_list(cat_key),visible=True),gr.update(visible=False),gr.update(visible=False),"")
def show_list_home():
return (gr.update(value=fetch_news_list(list(CATEGORIES.keys())[0]),visible=True),gr.update(visible=False),gr.update(visible=False))
read_btn.click(fn=show_article,inputs=[article_url],outputs=[news_list,article_view,back_btn,article_url])
cat_btn.click(fn=switch_cat,inputs=[cat_input],outputs=[news_list,article_view,back_btn,cat_input])
back_btn.click(fn=show_list_home,inputs=[],outputs=[news_list,article_view,back_btn])
timer=gr.Timer(value=REFRESH_SECONDS,active=True)
timer.tick(fn=lambda: fetch_news_list(list(CATEGORIES.keys())[0]),inputs=None,outputs=news_list)
demo.load(fn=lambda: fetch_news_list(list(CATEGORIES.keys())[0]),inputs=None,outputs=news_list)
if __name__=="__main__":
demo.launch(ssr_mode=False)