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'', '', re.sub(r']*>', '', 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
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 "

Không tìm thấy bài viết.

" 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'''
24h

{title}

''') return f'''
🎬 Video Highlight
{''.join(items)}
''' 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'''
{badge_txt}

{title}

''') return f'''
🔥 Tin Nổi Bật
{''.join(items)}
''' def render_homepage_html(articles, h24_videos=None): if not articles: return "

Không tìm thấy tin tức.

" 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'
{video_carousel}{news_carousel}
⏱ {now}📰 Tin nổi bật
'] 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'

{gn}

') for i,art in enumerate(display[:6]): parts.append(_list_card(art,i
') parts.append('
') 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'' else: video_tag = f'' slides.append(f'''
{video_tag}
{src_label}

{v.get("title","")}

🔇 Nhấn để bật tiếng
{vi+1}/{len(tiktok_videos)}
''') tiktok_html = f'''
{''.join(slides)}
''' # 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 = ['
'] grid_parts.append(f'
⏱ {now}📰 {len(articles)} video · BDP + 24h
') if feat: grid_parts.append('') if reg: grid_parts.append('
') for a in reg[:40]: grid_parts.append(_list_card(a,False,tiktok=True)) grid_parts.append('
') grid_parts.append('
') grid_html = '\n'.join(grid_parts) return tiktok_html + grid_html def render_list_html(articles,group_name=""): if not articles: return "

Không tìm thấy tin tức.

" 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'
⏱ {now}📰 {len(articles)} tin · {group_name}
'] if feat: parts.append('') if reg: parts.append('
') for a in reg[:40]: parts.append(_list_card(a,False)) parts.append('
') parts.append('
') 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 = '
' if is_video else "" img_html=f'
{play_overlay}
' time_html=f'🕐 {art["time"]}' if art.get("time") else "" summ_html=f'

{art["summary"][:120]}...

' 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'{grp or "VnExpress"}' elif art.get("source")=="24h": badge=f'{grp or "24h"}' elif art.get("source")=="bdp": badge=f'{grp or "BongDaPlus"}' 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"""
{img_html}
{badge}

{art['title']}

{summ_html}
""" 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'
' parts=[f"""{seo}

{article['title']}

"""] if article.get("summary"): parts.append(f'
{article["summary"]}
') 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'

{caption}

' if caption else "" vtype=item.get("vtype","mp4") vsrc=item["src"] if vtype=="hls": parts.append(f'
{cap_html}
') else: parts.append(f'
{cap_html}
') elif item["type"]=="img": alt=item.get("alt",""); cap=f'
{alt}
' if alt else "" parts.append(f'
{alt}{cap}
') elif item["type"]=="p": parts.append(f'

{item["html"]}

') elif item["type"]=="heading": parts.append(f'

{item["text"]}

') elif item["type"]=="quote": parts.append(f'
{item["text"]}
') if article.get("related"): parts.append('') parts.append(f"""

💬 Bình luận

""") parts.append('
') 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 = """ """ 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='
Chưa có bình luận. Hãy là người đầu tiên!
';return;}var h='';for(var i=c.length-1;i>=0;i--){var x=c[i];h+='
'+x.name+''+x.date+'
'+x.text.replace(//g,'>')+'
';}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=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'
{icon}{label}
') return f'
{"".join(items)}
' 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('

📰 Tin Tức Việt Nam

VnExpress · BongDaPlus · 24h · Thời sự · Thế giới · Kinh doanh · Công nghệ · Thể thao · Giải trí · Video

') 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)