| 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 Đá", |
| } |
|
|
| |
| 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 Đá"), |
| ] |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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") |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 "" |
| |
| 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"}) |
| |
| 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"}] |
| |
| 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 [] |
|
|
| |
| 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] |
|
|
| |
| 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)) |
|
|
| |
| |
| |
|
|
| 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).""" |
| |
| 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") |
|
|
| |
| 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>''' |
|
|
| |
| 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,'<').replace(/>/g,'>')+'</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) |