V.AIZ / app.py
bep40's picture
Revert to working version + fix JS injection (head= strips scripts, use js= instead)
b17f42d verified
import gradio as gr
import requests
import re
import hashlib
from urllib.parse import quote as urlquote
from bs4 import BeautifulSoup
from datetime import datetime
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"
REFRESH_SECONDS = 300
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 Đá",
}
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):
"""Extract numeric video ID from BDP URL like ...-950382604.html"""
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):
"""Fetch video MP4 URL + poster from BDP embed page."""
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",""),
}
# Also extract relatedCarousel data
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():
"""Scrape BDP /video page - uses <a> tags with /video/ href pattern."""
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
# Skip category links
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)
# Clean time prefix like "09:30" or "01:00"
title = re.sub(r'^\d{2}:\d{2}', '', title).strip()
if not title or len(title) < 5: continue
# Find image
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 []
# BDP video: check for iframe video-embed and fetch real MP4
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"
})
# Use poster as og_image if default logo
if not og_img or "logo" in og_img.lower():
og_img = embed.get("poster", og_img)
else:
# Non-video: check for iframe embeds
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
# ── 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:
# VNE uses HLS (m3u8) streams
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]
# ── Video aggregator for slide + video tab ─────────────────────────────────────
def fetch_latest_videos():
"""Fetch latest BDP videos with poster images for the homepage slide."""
try:
# Use any BDP video embed page to get the relatedCarousel (always has latest 20 videos)
# First get a recent video ID from /video page
soup = _get(f"{BASE_BDP}/video")
first_link = None
for a in soup.find_all("a", href=True):
href = a.get("href", "")
if "/video/" in href and re.search(r'-\d{6,}\.html', href):
first_link = href if href.startswith("http") else BASE_BDP + href
break
if not first_link:
return []
vid_id = _extract_bdp_video_id(first_link)
if not vid_id:
return []
embed = fetch_bdp_embed_data(vid_id)
if not embed:
return []
# Build video list from relatedCarousel (has title + poster for all)
videos = []
for rel in embed.get("related", []):
videos.append({
"title": rel["title"],
"link": rel["link"],
"img": rel["poster"],
"source": "bdp"
})
return videos[:15]
except:
return []
def fetch_video_list():
"""Fetch video articles from BDP video page."""
return scrape_bdp_video_list()
# ── Dispatch ───────────────────────────────────────────────────────────────────
def fetch_homepage():
all_articles=[]
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
with ThreadPoolExecutor(max_workers=5) as ex:
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
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":
return render_homepage_html(fetch_homepage())
if src=="mix" and url_or_key=="video":
return render_list_html(fetch_video_list(),"Video Tổng Hợp")
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))
return render_article_html(scrape_bdp_article(url))
# ══════════════════════════════════════════════════════════════════════════════
# HTML RENDERERS
# ══════════════════════════════════════════════════════════════════════════════
def render_video_slider_html(videos):
"""Render a horizontal video slider for the homepage."""
if not videos:
return ""
items = []
for i, v in enumerate(videos):
img = safe_url(v.get("img", ""))
link = v.get("link", "#")
title = v.get("title", "")
aid = make_id(link)
sl = slug(title) if title else f"video-{i}"
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"><div class="vslide-play">▶</div></div>
<p class="vslide-title">{title}</p></div>''')
return f'''<div class="vslide-wrap">
<div class="vslide-header"><span class="vslide-label">🎬 Video Mới Nhất</span>
<div class="vslide-nav"><button class="vslide-btn" onclick="window.bdpSlideScroll(-1)">◀</button>
<button class="vslide-btn" onclick="window.bdpSlideScroll(1)">▶</button></div></div>
<div class="vslide-track" id="vslide-track">{''.join(items)}</div></div>'''
def render_homepage_html(articles):
if not articles: return "<p class='bdp-empty'>Không tìm thấy tin tức.</p>"
now=datetime.now().strftime("%H:%M:%S %d/%m/%Y")
# Fetch latest videos for slider (in parallel with rendering)
try:
videos = fetch_latest_videos()
except:
videos = []
slider_html = render_video_slider_html(videos)
groups={}
for a in articles: groups.setdefault(a.get("group","Khác"),[]).append(a)
parts=[f'<div class="bdp-wrap">{slider_html}<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_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().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):
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")=="bdp": badge=f'<span class="bdp-badge bdp-badge-bdp">{grp or "BongDaPlus"}</span>'
sl=slug(art["title"])
share_js=f"event.stopPropagation();window.bdpShareOriginal('{esc(art['title'])}','{esc(link)}')"
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.bdpShareOriginal('{esc(article['title'])}','{esc(src_url)}')"
src_label="VnExpress" if article.get("source")=="vne" else "BongDaPlus"
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":
# HLS stream - use data-hls-src, handled by global MutationObserver + HLS.js
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:
# Direct MP4
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}}
.controls-row{padding:0 8px!important;background:#111!important}
.controls-row>div{background:#111!important;border:none!important}
.controls-row label{color:#aaa!important;font-size:12px!important}
.controls-row .wrap{border-color:#333!important;background:#1a1a1a!important}
.controls-row input,.controls-row select,.controls-row button{color:#eee!important}
/* ── Video Slider ── */
.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}
/* ── Main content ── */
.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-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}
"""
# ══════════════════════════════════════════════════════════════════════════════
# JS — ALL logic in head=, NO inline <script> or onerror in HTML content
# ══════════════════════════════════════════════════════════════════════════════
HEAD_META = """
<meta name="description" content="Tin tức tổng hợp nhanh nhất - VnExpress, BongDaPlus">
<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() {
/* Load HLS.js dynamically */
var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js';document.head.appendChild(s);
/* ── Navigation ── */
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'});
};
/* ── Share ORIGINAL article URL ── */
window.bdpShareOriginal=async function(title,originalUrl){
var sd={title:title,url:originalUrl,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(originalUrl);window.bdpToast('✅ Đã sao chép liên kết!');return;}catch(e){}}
var ta=document.createElement('textarea');ta.value=originalUrl;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:',originalUrl);}
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);};
/* ── Video Slider scroll ── */
window.bdpSlideScroll=function(dir){
var track=document.getElementById('vslide-track');
if(track){track.scrollBy({left:dir*260,behavior:'smooth'});}
};
/* ── Comments ── */
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!');};
/* ── HLS + Video init ── */
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;}
}
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-',''));});
});});}).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();}});
},1500);
/* ── Hash routing on page load ── */
var hh=window.location.hash;
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){}}}
}
"""
# ══════════════════════════════════════════════════════════════════════════════
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 · Thời sự · Thế giới · Kinh doanh · Công nghệ · Thể thao · Giải trí · Video</p></div>')
article_url=gr.Textbox(value="",visible=False,elem_id="article-url-input")
with gr.Row(elem_classes=["controls-row"]):
cat=gr.Dropdown(choices=list(CATEGORIES.keys()),value="🏠 Trang Chủ (Nổi Bật)",label="Chuyên mục",scale=3,interactive=True)
ref_btn=gr.Button("🔄 Làm mới",variant="primary",scale=1)
back_btn=gr.Button("← Quay lại",variant="secondary",scale=1,visible=False)
news_list=gr.HTML()
article_view=gr.HTML(visible=False)
read_btn=gr.Button("Đọc",visible=False,elem_id="btn-read-article")
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),gr.update(visible=False),""
return (gr.update(visible=False),gr.update(value=read_article(url),visible=True),gr.update(visible=False),gr.update(visible=True),"")
def show_list(c):
return (gr.update(value=fetch_news_list(c),visible=True),gr.update(visible=False),gr.update(visible=True),gr.update(visible=False))
read_btn.click(fn=show_article,inputs=[article_url],outputs=[news_list,article_view,ref_btn,back_btn,article_url])
back_btn.click(fn=show_list,inputs=[cat],outputs=[news_list,article_view,ref_btn,back_btn])
ref_btn.click(fn=show_list,inputs=[cat],outputs=[news_list,article_view,ref_btn,back_btn])
cat.change(fn=show_list,inputs=[cat],outputs=[news_list,article_view,ref_btn,back_btn])
timer=gr.Timer(value=REFRESH_SECONDS,active=True)
timer.tick(fn=fetch_news_list,inputs=cat,outputs=news_list)
demo.load(fn=fetch_news_list,inputs=cat,outputs=news_list)
if __name__=="__main__":
demo.launch()