import gradio as gr import requests import re import hashlib from urllib.parse import quote as urlquote from bs4 import BeautifulSoup from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8", } BASE_BDP = "https://bongdaplus.vn" REFRESH_SECONDS = 300 CATEGORIES = { "๐Ÿ  Trang Chแปง (Nแป•i Bแบญt)": "mix::home::Trang Chแปง", "๐ŸŽฌ Video Tแป•ng Hแปฃp": "mix::video::Video", "๐Ÿ“ฐ Thแปi Sแปฑ": "vne::https://vnexpress.net/thoi-su::Thแปi Sแปฑ", "๐ŸŒ Thแบฟ Giแป›i": "vne::https://vnexpress.net/the-gioi::Thแบฟ Giแป›i", "๐Ÿ’ฐ Kinh Doanh": "vne::https://vnexpress.net/kinh-doanh::Kinh Doanh", "๐Ÿ’ป Cรดng Nghแป‡": "vne::https://vnexpress.net/so-hoa::Cรดng Nghแป‡", "๐Ÿ”ฌ Khoa Hแปc": "vne::https://vnexpress.net/khoa-hoc::Khoa Hแปc", "๐ŸŽฌ Giแบฃi Trรญ": "vne::https://vnexpress.net/giai-tri::Giแบฃi Trรญ", "๐Ÿฅ Sแปฉc Khแปe": "vne::https://vnexpress.net/suc-khoe::Sแปฉc Khแปe", "๐ŸŽ“ Giรกo Dแปฅc": "vne::https://vnexpress.net/giao-duc::Giรกo Dแปฅc", "โœˆ๏ธ Du Lแป‹ch": "vne::https://vnexpress.net/du-lich::Du Lแป‹ch", "โšฝ Thแปƒ Thao": "vne::https://vnexpress.net/the-thao::Thแปƒ Thao", "โšฝ Bรณng ฤรก QT": "vne::https://vnexpress.net/the-thao/bong-da::Bรณng ฤรก", "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ Ngoแบกi Hแบกng Anh": "bdp::https://bongdaplus.vn/ngoai-hang-anh::Bรณng ฤรก", "๐Ÿ‡ช๐Ÿ‡ธ La Liga": "bdp::https://bongdaplus.vn/la-liga::Bรณng ฤรก", "๐Ÿ† Champions League": "bdp::https://bongdaplus.vn/champions-league-cup-c1::Bรณng ฤรก", "๐Ÿ‡ป๐Ÿ‡ณ Bรณng ฤรก VN": "bdp::https://bongdaplus.vn/bong-da-viet-nam::Bรณng ฤรก", "๐Ÿ”„ Chuyแปƒn Nhฦฐแปฃng": "bdp::https://bongdaplus.vn/tin-chuyen-nhuong::Bรณng ฤรก", } HOMEPAGE_SOURCES = [ ("vne","https://vnexpress.net/thoi-su","Thแปi Sแปฑ"), ("vne","https://vnexpress.net/the-gioi","Thแบฟ Giแป›i"), ("vne","https://vnexpress.net/kinh-doanh","Kinh Doanh"), ("vne","https://vnexpress.net/so-hoa","Cรดng Nghแป‡"), ("vne","https://vnexpress.net/the-thao","Thแปƒ Thao"), ("vne","https://vnexpress.net/giai-tri","Giแบฃi Trรญ"), ("bdp","https://bongdaplus.vn/tin-moi","Bรณng ฤรก"), ] # โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def strip_links(html): return re.sub(r'', '', 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): """Extract numeric video ID from BDP URL like ...-950382604.html""" m = re.search(r'-(\d{6,})\.html', url) return m.group(1) if m else None # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # SCRAPERS # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• def _get(url): r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8" return BeautifulSoup(r.text, "lxml") def _get_raw(url): r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8" return r.text, BeautifulSoup(r.text, "lxml") # โ”€โ”€ BDP Video Embed Fetcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def fetch_bdp_embed_data(video_id): """Fetch video MP4 URL + poster from BDP embed page.""" try: embed_url = f"{BASE_BDP}/video-embed/{video_id}.html" html, soup = _get_raw(embed_url) video = soup.select_one("video#videoPlayer") if not video: return None source = video.find("source") result = { "mp4": source.get("src","") if source else "", "poster": video.get("poster",""), } # Also extract relatedCarousel data carousel_match = re.findall(r'vdo\.relatedCarousel\((\[.*?\])\)', html, re.DOTALL) if carousel_match: posters = re.findall(r"poster:\s*'([^']+)'", carousel_match[0]) vdorefs = re.findall(r"vdoref:\s*'([^']+)'", carousel_match[0]) titles = re.findall(r"title:\s*'([^']*)'", carousel_match[0]) result["related"] = [ {"poster": p, "link": BASE_BDP + v if not v.startswith("http") else v, "title": t} for p, v, t in zip(posters, vdorefs, titles) ] return result except: return None # โ”€โ”€ BDP Scrapers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def scrape_bdp_list(url): try: soup = _get(url); articles,seen=[],set() for sel,feat in [("div.news.fst",True),("div.sld-itm.news",True),("li.news",False)]: for it in soup.select(sel): tag = it.find("a",class_="title") or it.find("a",href=True) if not tag: continue t=tag.get_text(strip=True); lk=tag.get("href","") if not t or len(t)<5: continue if lk and not lk.startswith("http"): lk=BASE_BDP+lk if lk in seen: continue im=it.find("img"); img=(im.get("data-src") or im.get("src")) if im else None sm=it.find("p",class_="summ"); tt=it.find("div",class_="in-time") is_video = "/video/" in lk articles.append({"title":t,"link":lk,"img":img,"summary":sm.get_text(strip=True) if sm else "", "time":tt.get_text(strip=True) if tt else "","featured":feat,"source":"bdp","group":"","is_video":is_video}) seen.add(lk) return articles except Exception as e: return [{"title":f"โš ๏ธ {e}","link":"#","img":None,"summary":"","time":"","featured":False,"source":"bdp","group":"","is_video":False}] def scrape_bdp_video_list(): """Scrape BDP /video page - uses tags with /video/ href pattern.""" try: soup = _get(f"{BASE_BDP}/video") articles, seen = [], set() for a in soup.find_all("a", href=True): href = a.get("href", "") if "/video/" not in href: continue # Skip category links if href in ("/video/", "/video/ban-thang-dep", "/video/highlight"): continue if not href.startswith("http"): href = BASE_BDP + href if href in seen: continue title = a.get_text(strip=True) # Clean time prefix like "09:30" or "01:00" title = re.sub(r'^\d{2}:\d{2}', '', title).strip() if not title or len(title) < 5: continue # Find image img_tag = a.find("img") if not img_tag: parent = a.parent if parent: img_tag = parent.find("img") img = None if img_tag: img = img_tag.get("data-src") or img_tag.get("src") or img_tag.get("data-original") articles.append({ "title": title, "link": href, "img": img, "summary": "", "time": "", "featured": len(articles) < 3, "source": "bdp", "group": "BongDaPlus Video", "is_video": True }) seen.add(href) return articles[:30] except Exception as e: return [{"title": f"โš ๏ธ {e}", "link": "#", "img": None, "summary": "", "time": "", "featured": False, "source": "bdp", "group": "", "is_video": False}] def scrape_bdp_article(url): try: soup = _get(url) h1=soup.select_one(".lead-title h1") or soup.select_one("h1") te=soup.select_one(".emobar .rgt"); se=soup.select_one("div.summary") og=soup.find("meta",property="og:image"); og_img=og.get("content","") if og else "" cd=soup.select_one("div.content#postContent") or soup.select_one("div.content") body=_extract_body(cd) if cd else [] # BDP video: check for iframe video-embed and fetch real MP4 is_video_page = "/video/" in url if is_video_page: vid_id = _extract_bdp_video_id(url) if vid_id: embed = fetch_bdp_embed_data(vid_id) if embed and embed.get("mp4"): body.insert(0, { "type": "video", "src": embed["mp4"], "poster": embed.get("poster", ""), "vtype": "mp4" }) # Use poster as og_image if default logo if not og_img or "logo" in og_img.lower(): og_img = embed.get("poster", og_img) else: # Non-video: check for iframe embeds for iframe in soup.select("iframe"): src = iframe.get("src", "") if "video-embed" in src: vid = _fetch_bdp_video(src) if vid: body.insert(0, vid) return {"title":h1.get_text(strip=True) if h1 else "","time":te.get_text(strip=True) if te else "", "summary":se.get_text(strip=True) if se else "","body":body,"related":_bdp_relates(soup), "source_url":url,"source":"bdp","og_image":og_img} except Exception as e: return {"title":"โš ๏ธ Lแป—i","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"bdp","og_image":""} def _fetch_bdp_video(embed_src): try: full = embed_src if embed_src.startswith("http") else BASE_BDP+embed_src html, soup = _get_raw(full) video = soup.select_one("video#videoPlayer") or soup.select_one("video") if video: source = video.find("source") if source: return {"type":"video","src":source.get("src",""), "poster":video.get("poster",""),"vtype":"mp4"} except: pass return None # โ”€โ”€ VNE Scrapers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def scrape_vne_list(url): try: soup=_get(url); articles,seen=[],set() for i,it in enumerate(soup.select("article.item-news")): a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a") or it.find("a",href=True,title=True) if not a: continue t=a.get("title","") or a.get_text(strip=True); lk=a.get("href","") if not t or len(t)<5 or lk in seen: continue im=it.find("img"); img=None if im: img=im.get("data-src") or im.get("src") if img and 'blank' in img: src=it.find("source") if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0] desc=it.select_one("p.description") articles.append({"title":t,"link":lk,"img":img,"summary":desc.get_text(strip=True)[:150] if desc else "", "time":"","featured":i==0,"source":"vne","group":"","is_video":False}) seen.add(lk) return articles except Exception as e: return [{"title":f"โš ๏ธ {e}","link":"#","img":None,"summary":"","time":"","featured":False,"source":"vne","group":"","is_video":False}] def scrape_vne_article(url): try: soup=_get(url) h1=soup.select_one("h1.title-detail"); desc=soup.select_one("p.description"); dt=soup.select_one("span.date") og=soup.find("meta",property="og:image"); og_img=og.get("content","") if og else "" cd=soup.select_one("article.fck_detail"); body=[] if cd: for ch in cd.children: if not hasattr(ch,'name') or not ch.name: continue if ch.name=="p": vid=ch.find("video") if vid: vsrc=vid.get("src","") if vsrc: # VNE uses HLS (m3u8) streams vtype="hls" if ("m3u8" in vsrc or "mpegURL" in (vid.get("type","") or "")) else "mp4" body.append({"type":"video","src":vsrc,"poster":vid.get("poster",""),"vtype":vtype}) continue im=ch.find("img") if im: s=im.get("data-src") or im.get("src") if s: body.append({"type":"img","src":s,"alt":im.get("alt","")}) t=ch.get_text(strip=True) if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))}) elif ch.name=="figure": vid=ch.find("video") if vid: vsrc=vid.get("src","") cap=ch.find("figcaption") if vsrc: vtype="hls" if ("m3u8" in vsrc or "mpegURL" in (vid.get("type","") or "")) else "mp4" body.append({"type":"video","src":vsrc,"poster":vid.get("poster",""),"vtype":vtype, "caption":cap.get_text(strip=True) if cap else ""}) continue im=ch.find("img"); cap=ch.find("figcaption") if im: s=im.get("data-src") or im.get("src") if s: body.append({"type":"img","src":s,"alt":cap.get_text(strip=True) if cap else ""}) elif ch.name in ("h2","h3","h4"): body.append({"type":"heading","text":ch.get_text(strip=True)}) return {"title":h1.get_text(strip=True) if h1 else "","time":dt.get_text(strip=True) if dt else "", "summary":desc.get_text(strip=True) if desc else "","body":body,"related":[], "source_url":url,"source":"vne","og_image":og_img} except Exception as e: return {"title":"โš ๏ธ Lแป—i","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"vne","og_image":""} def _extract_body(cd): body=[] for ch in cd.children: if not hasattr(ch,'name') or not ch.name: continue if ch.name=="p": im=ch.find("img") if im: s=im.get("src") or im.get("data-src") if s: body.append({"type":"img","src":s,"alt":im.get("alt","")}) t=ch.get_text(strip=True) if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))}) elif ch.name in ("h2","h3","h4"): body.append({"type":"heading","text":ch.get_text(strip=True)}) elif ch.name=="blockquote": body.append({"type":"quote","text":ch.get_text(strip=True)}) return body def _bdp_relates(soup): rels=[] rd=soup.select_one("div.relates") if rd: for a in rd.find_all("a",href=True): t=a.get_text(strip=True); h=a.get("href","") if t and len(t)>5: if not h.startswith("http"): h=BASE_BDP+h rels.append({"title":t,"link":h}) return rels[:5] # โ”€โ”€ Video aggregator for slide + video tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def fetch_latest_videos(): """Fetch latest BDP videos with poster images for the homepage slide.""" try: # Use any BDP video embed page to get the relatedCarousel (always has latest 20 videos) # First get a recent video ID from /video page soup = _get(f"{BASE_BDP}/video") first_link = None for a in soup.find_all("a", href=True): href = a.get("href", "") if "/video/" in href and re.search(r'-\d{6,}\.html', href): first_link = href if href.startswith("http") else BASE_BDP + href break if not first_link: return [] vid_id = _extract_bdp_video_id(first_link) if not vid_id: return [] embed = fetch_bdp_embed_data(vid_id) if not embed: return [] # Build video list from relatedCarousel (has title + poster for all) videos = [] for rel in embed.get("related", []): videos.append({ "title": rel["title"], "link": rel["link"], "img": rel["poster"], "source": "bdp" }) return videos[:15] except: return [] def fetch_video_list(): """Fetch video articles from BDP video page.""" return scrape_bdp_video_list() # โ”€โ”€ Dispatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def fetch_homepage(): all_articles=[] def _fetch(src,url,group): arts=scrape_bdp_list(url) if src=="bdp" else scrape_vne_list(url) for a in arts: a["group"]=group return arts with ThreadPoolExecutor(max_workers=5) as ex: futures={ex.submit(_fetch,s,u,g):g for s,u,g in HOMEPAGE_SOURCES} for f in as_completed(futures): try: all_articles.extend(f.result()) except: pass return all_articles def fetch_news_list(category): val=CATEGORIES.get(category,list(CATEGORIES.values())[0]) parts=val.split("::"); src,url_or_key,group=parts[0],parts[1],parts[2] if src=="mix" and url_or_key=="home": return render_homepage_html(fetch_homepage()) if src=="mix" and url_or_key=="video": return render_list_html(fetch_video_list(),"Video Tแป•ng Hแปฃp") articles=scrape_bdp_list(url_or_key) if src=="bdp" else scrape_vne_list(url_or_key) for a in articles: a["group"]=group return render_list_html(articles,group) def read_article(url): if not url or url=="#" or len(url)<10: return "

Khรดng tรฌm thแบฅy bร i viแบฟt.

" if "vnexpress.net" in url: return render_article_html(scrape_vne_article(url)) return render_article_html(scrape_bdp_article(url)) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # HTML RENDERERS # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• def render_video_slider_html(videos): """Render a horizontal video slider for the homepage.""" if not videos: return "" items = [] for i, v in enumerate(videos): img = safe_url(v.get("img", "")) link = v.get("link", "#") title = v.get("title", "") aid = make_id(link) sl = slug(title) if title else f"video-{i}" click_js = f"window.bdpOpen('{esc(link)}','{aid}','{sl}')" items.append(f'''
โ–ถ

{title}

''') return f'''
๐ŸŽฌ Video Mแป›i Nhแบฅt
{''.join(items)}
''' def render_homepage_html(articles): if not articles: return "

Khรดng tรฌm thแบฅy tin tแปฉc.

" now=datetime.now().strftime("%H:%M:%S %d/%m/%Y") # Fetch latest videos for slider (in parallel with rendering) try: videos = fetch_latest_videos() except: videos = [] slider_html = render_video_slider_html(videos) groups={} for a in articles: groups.setdefault(a.get("group","Khรกc"),[]).append(a) parts=[f'
{slider_html}
โฑ {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_list_html(articles,group_name=""): if not articles: return "

Khรดng tรฌm thแบฅy tin tแปฉc.

" now=datetime.now().strftime("%H:%M:%S %d/%m/%Y") feat=[a for a in articles if a.get("featured")] reg=[a for a in articles if not a.get("featured")] parts=[f'
โฑ {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): 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")=="bdp": badge=f'{grp or "BongDaPlus"}' sl=slug(art["title"]) share_js=f"event.stopPropagation();window.bdpShareOriginal('{esc(art['title'])}','{esc(link)}')" click_js=f"window.bdpOpen('{esc(link)}','{aid}','{sl}')" return f"""
{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.bdpShareOriginal('{esc(article['title'])}','{esc(src_url)}')" src_label="VnExpress" if article.get("source")=="vne" else "BongDaPlus" 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": # HLS stream - use data-hls-src, handled by global MutationObserver + HLS.js parts.append(f'
{cap_html}
') else: # Direct MP4 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}} .controls-row{padding:0 8px!important;background:#111!important} .controls-row>div{background:#111!important;border:none!important} .controls-row label{color:#aaa!important;font-size:12px!important} .controls-row .wrap{border-color:#333!important;background:#1a1a1a!important} .controls-row input,.controls-row select,.controls-row button{color:#eee!important} /* โ”€โ”€ Video Slider โ”€โ”€ */ .vslide-wrap{margin:8px;background:#1a1a1a;border-radius:12px;overflow:hidden;border:1px solid #2a2a2a} .vslide-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px} .vslide-label{color:#f0c040;font-size:15px;font-weight:700} .vslide-nav{display:flex;gap:6px} .vslide-btn{background:#333;color:#fff;border:none;width:30px;height:30px;border-radius:50%;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;transition:background .15s} .vslide-btn:hover{background:#555} .vslide-track{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;gap:10px;padding:6px 14px 14px;scrollbar-width:none} .vslide-track::-webkit-scrollbar{display:none} .vslide-item{flex:0 0 200px;scroll-snap-align:start;cursor:pointer;transition:transform .15s} .vslide-item:hover{transform:scale(1.03)} @media(min-width:768px){.vslide-item{flex:0 0 240px}} .vslide-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:8px;overflow:hidden;background:#222} .vslide-thumb img{width:100%;height:100%;object-fit:cover} .vslide-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:40px;height:40px;background:rgba(0,0,0,.6);border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:16px;pointer-events:none;transition:background .15s} .vslide-item:hover .vslide-play{background:rgba(220,50,50,.8)} .vslide-title{color:#ccc;font-size:12px;margin:6px 0 0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} /* โ”€โ”€ Main content โ”€โ”€ */ .bdp-wrap{padding:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif} .bdp-topbar{display:flex;justify-content:space-between;padding:4px 4px 8px;color:#666;font-size:11px} .bdp-empty{text-align:center;color:#666;padding:60px 20px} .bdp-section{margin-bottom:16px} .bdp-section-title{font-size:16px;font-weight:700;color:#5cb87a;margin:4px 0 8px;border-left:3px solid #5cb87a;padding-left:8px} @media(min-width:768px){.bdp-section-title{font-size:18px}} .bdp-grid{display:grid;grid-template-columns:1fr;gap:8px} .bdp-grid-featured,.bdp-grid-home{margin-bottom:4px} @media(min-width:420px){.bdp-grid{grid-template-columns:repeat(2,1fr)}} @media(min-width:768px){.bdp-grid{grid-template-columns:repeat(3,1fr);gap:10px}} @media(min-width:1100px){.bdp-grid{grid-template-columns:repeat(4,1fr)}} .bdp-card{background:#1a1a1a;border-radius:10px;overflow:hidden;cursor:pointer;transition:transform .15s,box-shadow .15s;border:1px solid #222} .bdp-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.5)} .bdp-card:active{transform:scale(.98)} .bdp-card-img{width:100%;height:130px;overflow:hidden;background:#222;position:relative} .bdp-card-img-big{height:170px} .bdp-card-img img{width:100%;height:100%;object-fit:cover} @media(min-width:768px){.bdp-card-img{height:150px}.bdp-card-img-big{height:190px}} .bdp-play-overlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:36px;height:36px;background:rgba(0,0,0,.55);border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:15px;pointer-events:none} .bdp-card-body{padding:8px 10px 6px} .bdp-card-title{font-size:13px;font-weight:600;color:#eee;margin:0;line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} .bdp-card-title-big{font-size:14.5px} @media(min-width:768px){.bdp-card-title{font-size:13.5px}.bdp-card-title-big{font-size:15px}} .bdp-card-summ{font-size:11.5px;color:#777;margin:4px 0 0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .bdp-card-footer{display:flex;justify-content:space-between;align-items:center;margin-top:6px} .bdp-card-time{color:#555;font-size:10.5px} .bdp-share-btn{background:none;border:none;cursor:pointer;font-size:15px;padding:3px 5px;border-radius:6px;transition:background .15s;color:#777} .bdp-share-btn:hover{background:#333} .bdp-badge{font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:4px} .bdp-badge-vne{background:#c0392b;color:#fff} .bdp-badge-bdp{background:#1a5c35;color:#fff} .bdp-article{padding:14px 12px 30px;max-width:720px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif} @media(min-width:768px){.bdp-article{padding:20px 16px 50px}} .bdp-article-title{font-size:21px;font-weight:800;color:#f0f0f0;line-height:1.3;margin:0 0 8px} @media(min-width:768px){.bdp-article-title{font-size:27px}} .bdp-article-meta{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;color:#666;font-size:12px;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #2a2a2a} .bdp-share-article-btn{background:linear-gradient(135deg,#1a5c35,#2d8659);color:#fff;border:none;padding:6px 14px;border-radius:18px;font-size:12px;cursor:pointer;font-weight:600} .bdp-share-article-btn:hover{opacity:.85} .bdp-article-summary{background:#1a2a1f;border-left:4px solid #2d8659;padding:12px 14px;margin-bottom:16px;border-radius:0 8px 8px 0;font-weight:600;color:#ccc;line-height:1.5;font-size:14.5px} .bdp-article-p{font-size:15.5px;line-height:1.75;color:#ccc;margin:0 0 12px} @media(min-width:768px){.bdp-article-p{font-size:16.5px}} .bdp-article-h2{font-size:19px;font-weight:700;color:#eee;margin:24px 0 10px} .bdp-quote{border-left:4px solid #b8960c;padding:10px 14px;margin:14px 0;background:#1a1a10;font-style:italic;color:#bbb;border-radius:0 6px 6px 0} .bdp-figure{margin:14px 0;text-align:center} .bdp-figure img{max-width:100%;height:auto;border-radius:8px} .bdp-figcap{color:#666;font-size:11.5px;margin-top:4px;font-style:italic} .bdp-video-wrap{margin:14px 0;border-radius:10px;overflow:hidden;background:#000} .bdp-video{width:100%;max-height:70vh;display:block;border-radius:10px} .bdp-related{margin-top:24px;padding-top:14px;border-top:1px solid #2a2a2a} .bdp-related h3{font-size:16px;color:#eee;margin:0 0 8px} .bdp-related-item{padding:8px 10px;margin-bottom:5px;border:1px solid #262626;border-radius:8px;cursor:pointer;transition:background .15s} .bdp-related-item:hover{background:#222} .bdp-related-item span{font-size:13.5px;color:#5cb87a;font-weight:500} .bdp-comments{margin-top:28px;padding-top:16px;border-top:1px solid #2a2a2a} .bdp-comments h3{font-size:16px;color:#eee;margin:0 0 10px} .bdp-cmt-item{background:#1a1a1a;border:1px solid #262626;border-radius:8px;padding:10px 12px;margin-bottom:8px} .bdp-cmt-author{font-weight:700;color:#5cb87a;font-size:13px} .bdp-cmt-date{color:#555;font-size:11px;margin-left:8px} .bdp-cmt-body{color:#ccc;font-size:14px;margin-top:4px;line-height:1.5} .bdp-cmt-form{margin-top:12px} .bdp-cmt-input,.bdp-cmt-textarea{width:100%;padding:8px 10px;background:#1a1a1a;border:1px solid #333;border-radius:6px;color:#eee;font-size:13px;box-sizing:border-box} .bdp-cmt-input{margin-bottom:6px} .bdp-cmt-textarea{resize:vertical} .bdp-cmt-submit{background:linear-gradient(135deg,#1a5c35,#2d8659);color:#fff;border:none;padding:8px 20px;border-radius:18px;font-size:13px;cursor:pointer;font-weight:600;margin-top:8px} .bdp-cmt-submit:hover{opacity:.85} .bdp-cmt-empty{color:#555;font-size:13px;font-style:italic;padding:8px 0} .bdp-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 22px;border-radius:22px;font-size:13px;z-index:9999;opacity:0;transition:opacity .3s;pointer-events:none;font-weight:500} .bdp-toast.show{opacity:1} .gr-group,.gr-box,.gr-panel{background:#111!important;border:none!important} .label-wrap{background:#111!important} """ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # JS โ€” ALL logic in head=, NO inline