bep40 commited on
Commit
3826fdf
·
verified ·
1 Parent(s): 8fc079e

feat: inline video player (VnExpress MP4 + BDP video-embed), OG image in share links, dynamic meta tags

Browse files
Files changed (1) hide show
  1. app.py +523 -1
app.py CHANGED
@@ -1 +1,523 @@
1
- PLACEHOLDER
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import requests
3
+ import re
4
+ import hashlib
5
+ from bs4 import BeautifulSoup
6
+ from datetime import datetime
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+
9
+ HEADERS = {
10
+ "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",
11
+ "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
12
+ }
13
+ BASE_BDP = "https://bongdaplus.vn"
14
+ REFRESH_SECONDS = 300
15
+
16
+ CATEGORIES = {
17
+ "🏠 Trang Chủ (Nổi Bật)": "mix::home::Trang Chủ",
18
+ "📰 Thời Sự": "vne::https://vnexpress.net/thoi-su::Thời Sự",
19
+ "🌍 Thế Giới": "vne::https://vnexpress.net/the-gioi::Thế Giới",
20
+ "💰 Kinh Doanh": "vne::https://vnexpress.net/kinh-doanh::Kinh Doanh",
21
+ "💻 Công Nghệ": "vne::https://vnexpress.net/so-hoa::Công Nghệ",
22
+ "🔬 Khoa Học": "vne::https://vnexpress.net/khoa-hoc::Khoa Học",
23
+ "🎬 Giải Trí": "vne::https://vnexpress.net/giai-tri::Giải Trí",
24
+ "🏥 Sức Khỏe": "vne::https://vnexpress.net/suc-khoe::Sức Khỏe",
25
+ "🎓 Giáo Dục": "vne::https://vnexpress.net/giao-duc::Giáo Dục",
26
+ "✈️ Du Lịch": "vne::https://vnexpress.net/du-lich::Du Lịch",
27
+ "⚽ Thể Thao": "vne::https://vnexpress.net/the-thao::Thể Thao",
28
+ "⚽ Bóng Đá QT": "vne::https://vnexpress.net/the-thao/bong-da::Bóng Đá",
29
+ "🏴󠁧󠁢󠁥󠁮󠁧󠁿 Ngoại Hạng Anh": "bdp::https://bongdaplus.vn/ngoai-hang-anh::Bóng Đá",
30
+ "🇪🇸 La Liga": "bdp::https://bongdaplus.vn/la-liga::Bóng Đá",
31
+ "🏆 Champions League": "bdp::https://bongdaplus.vn/champions-league-cup-c1::Bóng Đá",
32
+ "🇻🇳 Bóng Đá VN": "bdp::https://bongdaplus.vn/bong-da-viet-nam::Bóng Đá",
33
+ "🔄 Chuyển Nhượng": "bdp::https://bongdaplus.vn/tin-chuyen-nhuong::Bóng Đá",
34
+ }
35
+ HOMEPAGE_SOURCES = [
36
+ ("vne", "https://vnexpress.net/thoi-su", "Thời Sự"),
37
+ ("vne", "https://vnexpress.net/the-gioi", "Thế Giới"),
38
+ ("vne", "https://vnexpress.net/kinh-doanh", "Kinh Doanh"),
39
+ ("vne", "https://vnexpress.net/so-hoa", "Công Nghệ"),
40
+ ("vne", "https://vnexpress.net/the-thao", "Thể Thao"),
41
+ ("vne", "https://vnexpress.net/giai-tri", "Giải Trí"),
42
+ ("bdp", "https://bongdaplus.vn/tin-moi", "Bóng Đá"),
43
+ ]
44
+
45
+ def strip_links(html):
46
+ return re.sub(r'</a>', '', re.sub(r'<a\s[^>]*>', '', html))
47
+ def esc(text):
48
+ return text.replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"').replace("\n", " ")
49
+ def make_id(url):
50
+ return hashlib.md5(url.encode()).hexdigest()[:12]
51
+ def slug(text):
52
+ s = text.lower().strip()
53
+ for p, r in [('[àáạảãâầấậẩẫăằắặẳẵ]','a'),('[èéẹẻẽêềếệểễ]','e'),('[ìíịỉĩ]','i'),
54
+ ('[òóọỏõôồốộổỗơờớợởỡ]','o'),('[ùúụủũưừứựửữ]','u'),('[ỳýỵỷỹ]','y'),('[đ]','d')]:
55
+ s = re.sub(p, r, s)
56
+ return re.sub(r'[\s-]+', '-', re.sub(r'[^a-z0-9\s-]', '', s)).strip('-')[:60]
57
+
58
+ # ══════════════════════════════════════════════════════════════════════════════
59
+ # SCRAPERS
60
+ # ══════════════════════════════════════════════════════════════════════════════
61
+ def scrape_bdp_list(url):
62
+ try:
63
+ r = requests.get(url, headers=HEADERS, timeout=15); r.encoding = "utf-8"
64
+ soup = BeautifulSoup(r.text, "lxml"); articles, seen = [], set()
65
+ for sel, feat in [("div.news.fst", True), ("div.sld-itm.news", True), ("li.news", False)]:
66
+ for item in soup.select(sel):
67
+ tag = item.find("a", class_="title") or item.find("a", href=True)
68
+ if not tag: continue
69
+ title = tag.get_text(strip=True)
70
+ if not title or len(title) < 5: continue
71
+ link = tag.get("href", "")
72
+ if link and not link.startswith("http"): link = BASE_BDP + link
73
+ if link in seen: continue
74
+ img_tag = item.find("img")
75
+ img = (img_tag.get("data-src") or img_tag.get("src")) if img_tag else None
76
+ summ = item.find("p", class_="summ"); tt = item.find("div", class_="in-time")
77
+ articles.append({"title": title, "link": link, "img": img,
78
+ "summary": summ.get_text(strip=True) if summ else "",
79
+ "time": tt.get_text(strip=True) if tt else "",
80
+ "featured": feat, "source": "bdp", "group": ""})
81
+ seen.add(link)
82
+ return articles
83
+ except Exception as e:
84
+ return [{"title": f"⚠️ {e}", "link": "#", "img": None, "summary": "", "time": "", "featured": False, "source": "bdp", "group": ""}]
85
+
86
+ def scrape_bdp_article(url):
87
+ try:
88
+ r = requests.get(url, headers=HEADERS, timeout=15); r.encoding = "utf-8"
89
+ soup = BeautifulSoup(r.text, "lxml")
90
+ h1 = soup.select_one(".lead-title h1") or soup.select_one("h1")
91
+ te = soup.select_one(".emobar .rgt"); se = soup.select_one("div.summary")
92
+ og_img = ""; og = soup.find("meta", property="og:image")
93
+ if og: og_img = og.get("content", "")
94
+ cd = soup.select_one("div.content#postContent") or soup.select_one("div.content")
95
+ body = []
96
+ if cd:
97
+ body = _extract_body(cd)
98
+ # Extract videos from iframes (BDP uses /video-embed/)
99
+ for iframe in soup.select("iframe"):
100
+ src = iframe.get("src", "")
101
+ if "video-embed" in src:
102
+ vid = _fetch_bdp_video(src)
103
+ if vid:
104
+ body.insert(0, vid) # Put video at top
105
+ return {"title": h1.get_text(strip=True) if h1 else "", "time": te.get_text(strip=True) if te else "",
106
+ "summary": se.get_text(strip=True) if se else "", "body": body, "related": _bdp_relates(soup),
107
+ "source_url": url, "source": "bdp", "og_image": og_img}
108
+ except Exception as e:
109
+ return {"title": "⚠️ Lỗi", "time": "", "summary": str(e), "body": [], "related": [],
110
+ "source_url": url, "source": "bdp", "og_image": ""}
111
+
112
+ def _fetch_bdp_video(embed_src):
113
+ """Fetch BDP video-embed page and extract MP4 URL."""
114
+ try:
115
+ full = embed_src if embed_src.startswith("http") else BASE_BDP + embed_src
116
+ r = requests.get(full, headers=HEADERS, timeout=10); r.encoding = "utf-8"
117
+ soup = BeautifulSoup(r.text, "lxml")
118
+ source = soup.select_one("video source[src]")
119
+ video = soup.select_one("video")
120
+ if source:
121
+ return {"type": "video", "src": source.get("src", ""),
122
+ "poster": video.get("poster", "") if video else ""}
123
+ except Exception:
124
+ pass
125
+ return None
126
+
127
+ def scrape_vne_list(url):
128
+ try:
129
+ r = requests.get(url, headers=HEADERS, timeout=15); r.encoding = "utf-8"
130
+ soup = BeautifulSoup(r.text, "lxml"); articles, seen = [], set()
131
+ for i, item in enumerate(soup.select("article.item-news")):
132
+ a = item.select_one("h2.title-news a") or item.select_one("h3.title-news a") or item.find("a", href=True, title=True)
133
+ if not a: continue
134
+ title = a.get("title", "") or a.get_text(strip=True)
135
+ link = a.get("href", "")
136
+ if not title or len(title) < 5 or link in seen: continue
137
+ img_tag = item.find("img")
138
+ img = None
139
+ if img_tag:
140
+ img = img_tag.get("data-src") or img_tag.get("src")
141
+ if img and 'blank' in img:
142
+ src = item.find("source")
143
+ if src: img = src.get("srcset", "").split(",")[0].strip().split(" ")[0]
144
+ desc = item.select_one("p.description")
145
+ articles.append({"title": title, "link": link, "img": img,
146
+ "summary": desc.get_text(strip=True)[:150] if desc else "",
147
+ "time": "", "featured": i == 0, "source": "vne", "group": ""})
148
+ seen.add(link)
149
+ return articles
150
+ except Exception as e:
151
+ return [{"title": f"⚠️ {e}", "link": "#", "img": None, "summary": "", "time": "", "featured": False, "source": "vne", "group": ""}]
152
+
153
+ def scrape_vne_article(url):
154
+ try:
155
+ r = requests.get(url, headers=HEADERS, timeout=15); r.encoding = "utf-8"
156
+ soup = BeautifulSoup(r.text, "lxml")
157
+ h1 = soup.select_one("h1.title-detail"); desc = soup.select_one("p.description")
158
+ dt = soup.select_one("span.date")
159
+ og_img = ""; og = soup.find("meta", property="og:image")
160
+ if og: og_img = og.get("content", "")
161
+ cd = soup.select_one("article.fck_detail")
162
+ body = []
163
+ if cd:
164
+ for ch in cd.children:
165
+ if not hasattr(ch, 'name') or not ch.name: continue
166
+ if ch.name == "p":
167
+ # Check for video inside <p>
168
+ vid = ch.find("video")
169
+ if vid:
170
+ src = vid.get("src", "")
171
+ if src:
172
+ body.append({"type": "video", "src": src, "poster": vid.get("poster", "")})
173
+ continue
174
+ im = ch.find("img")
175
+ if im:
176
+ s = im.get("data-src") or im.get("src")
177
+ if s: body.append({"type": "img", "src": s, "alt": im.get("alt", "")})
178
+ t = ch.get_text(strip=True)
179
+ if t: body.append({"type": "p", "html": strip_links(''.join(str(c) for c in ch.children))})
180
+ elif ch.name == "figure":
181
+ # Check for video in figure
182
+ vid = ch.find("video")
183
+ if vid:
184
+ src = vid.get("src", "")
185
+ cap = ch.find("figcaption")
186
+ if src:
187
+ body.append({"type": "video", "src": src, "poster": vid.get("poster", ""),
188
+ "caption": cap.get_text(strip=True) if cap else ""})
189
+ continue
190
+ im = ch.find("img"); cap = ch.find("figcaption")
191
+ if im:
192
+ s = im.get("data-src") or im.get("src")
193
+ if s: body.append({"type": "img", "src": s, "alt": cap.get_text(strip=True) if cap else ""})
194
+ elif ch.name in ("h2", "h3", "h4"):
195
+ body.append({"type": "heading", "text": ch.get_text(strip=True)})
196
+ return {"title": h1.get_text(strip=True) if h1 else "", "time": dt.get_text(strip=True) if dt else "",
197
+ "summary": desc.get_text(strip=True) if desc else "", "body": body, "related": [],
198
+ "source_url": url, "source": "vne", "og_image": og_img}
199
+ except Exception as e:
200
+ return {"title": "⚠️ Lỗi", "time": "", "summary": str(e), "body": [], "related": [],
201
+ "source_url": url, "source": "vne", "og_image": ""}
202
+
203
+ def _extract_body(cd):
204
+ body = []
205
+ for ch in cd.children:
206
+ if not hasattr(ch, 'name') or not ch.name: continue
207
+ if ch.name == "p":
208
+ im = ch.find("img")
209
+ if im:
210
+ s = im.get("src") or im.get("data-src")
211
+ if s: body.append({"type": "img", "src": s, "alt": im.get("alt", "")})
212
+ t = ch.get_text(strip=True)
213
+ if t: body.append({"type": "p", "html": strip_links(''.join(str(c) for c in ch.children))})
214
+ elif ch.name in ("h2", "h3", "h4"):
215
+ body.append({"type": "heading", "text": ch.get_text(strip=True)})
216
+ elif ch.name == "blockquote":
217
+ body.append({"type": "quote", "text": ch.get_text(strip=True)})
218
+ return body
219
+
220
+ def _bdp_relates(soup):
221
+ rels = []
222
+ rd = soup.select_one("div.relates")
223
+ if rd:
224
+ for a in rd.find_all("a", href=True):
225
+ t = a.get_text(strip=True); h = a.get("href", "")
226
+ if t and len(t) > 5:
227
+ if not h.startswith("http"): h = BASE_BDP + h
228
+ rels.append({"title": t, "link": h})
229
+ return rels[:5]
230
+
231
+ def fetch_homepage():
232
+ all_articles = []
233
+ def _fetch(src, url, group):
234
+ arts = scrape_bdp_list(url) if src == "bdp" else scrape_vne_list(url)
235
+ for a in arts: a["group"] = group
236
+ return arts
237
+ with ThreadPoolExecutor(max_workers=5) as ex:
238
+ futures = {ex.submit(_fetch, s, u, g): g for s, u, g in HOMEPAGE_SOURCES}
239
+ for f in as_completed(futures):
240
+ try: all_articles.extend(f.result())
241
+ except: pass
242
+ return all_articles
243
+
244
+ def fetch_news_list(category):
245
+ val = CATEGORIES.get(category, list(CATEGORIES.values())[0])
246
+ parts = val.split("::"); src, url_or_key, group = parts[0], parts[1], parts[2]
247
+ if src == "mix": return render_homepage_html(fetch_homepage())
248
+ articles = scrape_bdp_list(url_or_key) if src == "bdp" else scrape_vne_list(url_or_key)
249
+ for a in articles: a["group"] = group
250
+ return render_list_html(articles, group)
251
+
252
+ def read_article(url):
253
+ if not url or url == "#" or len(url) < 10: return "<p>Không tìm thấy bài viết.</p>"
254
+ if "vnexpress.net" in url: return render_article_html(scrape_vne_article(url))
255
+ return render_article_html(scrape_bdp_article(url))
256
+
257
+ # ══════════════════════════════════════════════════════════════════════════════
258
+ # HTML RENDERERS
259
+ # ══════════════════════════════════════════════════════════════════════════════
260
+ def render_homepage_html(articles):
261
+ if not articles: return "<p class='bdp-empty'>Không tìm thấy tin tức.</p>"
262
+ now = datetime.now().strftime("%H:%M:%S %d/%m/%Y")
263
+ groups = {}
264
+ for a in articles: groups.setdefault(a.get("group", "Khác"), []).append(a)
265
+ parts = [f'<div class="bdp-wrap"><div class="bdp-topbar"><span>⏱ {now}</span><span>📰 Tin nổi bật từ nhiều nguồn</span></div>']
266
+ for gn in ["Thời Sự", "Thế Giới", "Kinh Doanh", "Công Nghệ", "Thể Thao", "Giải Trí", "Bóng Đá"]:
267
+ arts = groups.get(gn, [])
268
+ if not arts: continue
269
+ feat = [a for a in arts if a.get("featured")][:2]
270
+ reg = [a for a in arts if not a.get("featured")][:4]
271
+ display = feat + reg
272
+ if not display: continue
273
+ parts.append(f'<div class="bdp-section"><h2 class="bdp-section-title">{gn}</h2><div class="bdp-grid bdp-grid-home">')
274
+ for i, art in enumerate(display[:6]): parts.append(_list_card(art, i < len(feat)))
275
+ parts.append('</div></div>')
276
+ parts.append('</div>')
277
+ return '\n'.join(parts)
278
+
279
+ def render_list_html(articles, group_name=""):
280
+ if not articles: return "<p class='bdp-empty'>Không tìm thấy tin tức.</p>"
281
+ now = datetime.now().strftime("%H:%M:%S %d/%m/%Y")
282
+ featured = [a for a in articles if a.get("featured")]
283
+ regular = [a for a in articles if not a.get("featured")]
284
+ parts = [f'<div class="bdp-wrap"><div class="bdp-topbar"><span>⏱ {now}</span><span>📰 {len(articles)} tin · {group_name}</span></div>']
285
+ if featured:
286
+ parts.append('<div class="bdp-grid bdp-grid-featured">')
287
+ for art in featured[:6]: parts.append(_list_card(art, True))
288
+ parts.append('</div>')
289
+ if regular:
290
+ parts.append('<div class="bdp-grid">')
291
+ for art in regular[:40]: parts.append(_list_card(art, False))
292
+ parts.append('</div>')
293
+ parts.append('</div>')
294
+ return '\n'.join(parts)
295
+
296
+ def _list_card(art, big):
297
+ img_html = ""
298
+ if art.get("img"):
299
+ c = "bdp-card-img bdp-card-img-big" if big else "bdp-card-img"
300
+ img_html = f'<div class="{c}"><img src="{art["img"]}" alt="{esc(art["title"])}" onerror="this.parentElement.style.display=\'none\'"></div>'
301
+ time_html = f'<span class="bdp-card-time">🕐 {art["time"]}</span>' if art.get("time") else ""
302
+ summ_html = f'<p class="bdp-card-summ">{art["summary"][:120]}...</p>' if art.get("summary") and len(art["summary"]) > 10 else ""
303
+ link = art.get("link", "#"); aid = make_id(link)
304
+ tc = "bdp-card-title bdp-card-title-big" if big else "bdp-card-title"
305
+ grp = art.get("group", ""); badge = ""
306
+ if art.get("source") == "vne": badge = f'<span class="bdp-badge bdp-badge-vne">{grp or "VnExpress"}</span>'
307
+ elif art.get("source") == "bdp": badge = f'<span class="bdp-badge bdp-badge-bdp">{grp or "BongDaPlus"}</span>'
308
+ title_slug = slug(art["title"]); img_url = esc(art.get("img","") or "")
309
+ share_js = f"event.stopPropagation();window.bdpShare('{esc(art['title'])}','{aid}','{title_slug}','{img_url}')"
310
+ click_js = f"window.bdpOpen('{esc(link)}','{aid}','{title_slug}','{img_url}')"
311
+ return f"""<div class="bdp-card" onclick="{click_js}">
312
+ {img_html}<div class="bdp-card-body">{badge}<h3 class="{tc}">{art['title']}</h3>
313
+ {summ_html}<div class="bdp-card-footer">{time_html}
314
+ <button class="bdp-share-btn" onclick="{share_js}" title="Chia sẻ">📤</button></div></div></div>"""
315
+
316
+ def render_article_html(article):
317
+ aid = make_id(article["source_url"]); title_slug = slug(article["title"])
318
+ og_img = article.get("og_image", "")
319
+ share_js = f"window.bdpShare('{esc(article['title'])}','{aid}','{title_slug}','{esc(og_img)}')"
320
+ src_label = "VnExpress" if article.get("source") == "vne" else "BongDaPlus"
321
+ seo = f'<div style="display:none" itemscope itemtype="https://schema.org/NewsArticle"><meta itemprop="headline" content="{esc(article["title"])}"><meta itemprop="datePublished" content="{article.get("time","")}"><meta itemprop="publisher" content="{src_label}"><meta itemprop="image" content="{og_img}"><meta itemprop="description" content="{esc(article.get("summary","")[:160])}"></div>'
322
+ parts = [f"""{seo}<div class="bdp-article">
323
+ <h1 class="bdp-article-title">{article['title']}</h1>
324
+ <div class="bdp-article-meta"><span>🕐 {article['time']} · {src_label}</span>
325
+ <button class="bdp-share-article-btn" onclick="{share_js}">📤 Chia sẻ</button></div>"""]
326
+ if article.get("summary"): parts.append(f'<div class="bdp-article-summary">{article["summary"]}</div>')
327
+ for item in article.get("body", []):
328
+ if item["type"] == "video":
329
+ poster = item.get("poster", "")
330
+ poster_attr = f' poster="{poster}"' if poster else ""
331
+ caption = item.get("caption", "")
332
+ cap_html = f'<p class="bdp-figcap">{caption}</p>' if caption else ""
333
+ parts.append(f"""<div class="bdp-video-wrap">
334
+ <video controls playsinline preload="metadata"{poster_attr} class="bdp-video">
335
+ <source src="{item['src']}" type="video/mp4">Video không hỗ trợ trên trình duyệt này.</video>{cap_html}</div>""")
336
+ elif item["type"] == "img":
337
+ alt = item.get("alt", ""); cap = f'<figcaption class="bdp-figcap">{alt}</figcaption>' if alt else ""
338
+ parts.append(f'<figure class="bdp-figure"><img src="{item["src"]}" alt="{alt}" onerror="this.parentElement.style.display=\'none\'">{cap}</figure>')
339
+ elif item["type"] == "p": parts.append(f'<p class="bdp-article-p">{item["html"]}</p>')
340
+ elif item["type"] == "heading": parts.append(f'<h2 class="bdp-article-h2">{item["text"]}</h2>')
341
+ elif item["type"] == "quote": parts.append(f'<blockquote class="bdp-quote">{item["text"]}</blockquote>')
342
+ if article.get("related"):
343
+ parts.append('<div class="bdp-related"><h3>📰 Tin liên quan</h3>')
344
+ for rel in article["related"]:
345
+ rid = make_id(rel["link"]); rs = slug(rel["title"])
346
+ parts.append(f'<div class="bdp-related-item" onclick="window.bdpOpen(\'{esc(rel["link"])}\',\'{rid}\',\'{rs}\',\'\')"><span>▸ {rel["title"]}</span></div>')
347
+ parts.append('</div>')
348
+ parts.append(f"""<div class="bdp-comments" id="comments-{aid}"><h3>💬 Bình luận</h3>
349
+ <div id="cmt-list-{aid}"></div><div class="bdp-cmt-form">
350
+ <input id="cmt-name-{aid}" class="bdp-cmt-input" placeholder="Tên của bạn..." maxlength="50">
351
+ <textarea id="cmt-text-{aid}" class="bdp-cmt-textarea" placeholder="Viết bình luận..." rows="3" maxlength="500"></textarea>
352
+ <button class="bdp-cmt-submit" onclick="window.bdpAddCmt('{aid}')">Gửi bình luận</button></div></div>""")
353
+ parts.append('</div>')
354
+ return '\n'.join(parts)
355
+
356
+ # ══════════════════════════════════════════════════════════════════════════════
357
+ CSS = """
358
+ body,html{margin:0!important;padding:0!important;overflow-x:hidden;background:#111!important}
359
+ .gradio-container{max-width:100%!important;width:100%!important;margin:0!important;padding:0!important;border-radius:0!important;background:#111!important}
360
+ .main,.contain{max-width:100%!important;width:100%!important;padding:0!important;margin:0!important}
361
+ .gradio-container>.main>.contain{padding-top:0!important}
362
+ .gap{gap:0!important}
363
+ footer,.built-with{display:none!important}
364
+ #article-url-input,#btn-read-article{display:none!important;height:0!important;overflow:hidden!important}
365
+ .bdp-header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:14px 16px;text-align:center}
366
+ .bdp-header h1{color:#fff;font-size:20px;margin:0;font-weight:800;text-shadow:0 2px 6px rgba(0,0,0,.4)}
367
+ .bdp-header p{color:rgba(255,255,255,.6);font-size:11px;margin:2px 0 0}
368
+ @media(min-width:768px){.bdp-header h1{font-size:26px}.bdp-header{padding:20px}}
369
+ .controls-row{padding:0 8px!important;background:#111!important}
370
+ .controls-row>div{background:#111!important;border:none!important}
371
+ .controls-row label{color:#aaa!important;font-size:12px!important}
372
+ .controls-row .wrap{border-color:#333!important;background:#1a1a1a!important}
373
+ .controls-row input,.controls-row select,.controls-row button{color:#eee!important}
374
+ .bdp-wrap{padding:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
375
+ .bdp-topbar{display:flex;justify-content:space-between;padding:4px 4px 8px;color:#666;font-size:11px}
376
+ .bdp-empty{text-align:center;color:#666;padding:60px 20px}
377
+ .bdp-section{margin-bottom:16px}
378
+ .bdp-section-title{font-size:16px;font-weight:700;color:#5cb87a;margin:4px 0 8px;border-left:3px solid #5cb87a;padding-left:8px}
379
+ @media(min-width:768px){.bdp-section-title{font-size:18px}}
380
+ .bdp-grid{display:grid;grid-template-columns:1fr;gap:8px}
381
+ .bdp-grid-featured,.bdp-grid-home{margin-bottom:4px}
382
+ @media(min-width:420px){.bdp-grid{grid-template-columns:repeat(2,1fr)}}
383
+ @media(min-width:768px){.bdp-grid{grid-template-columns:repeat(3,1fr);gap:10px}}
384
+ @media(min-width:1100px){.bdp-grid{grid-template-columns:repeat(4,1fr)}}
385
+ .bdp-card{background:#1a1a1a;border-radius:10px;overflow:hidden;cursor:pointer;transition:transform .15s,box-shadow .15s;border:1px solid #222}
386
+ .bdp-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.5)}
387
+ .bdp-card:active{transform:scale(.98)}
388
+ .bdp-card-img{width:100%;height:130px;overflow:hidden;background:#222}
389
+ .bdp-card-img-big{height:170px}
390
+ .bdp-card-img img{width:100%;height:100%;object-fit:cover}
391
+ @media(min-width:768px){.bdp-card-img{height:150px}.bdp-card-img-big{height:190px}}
392
+ .bdp-card-body{padding:8px 10px 6px}
393
+ .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}
394
+ .bdp-card-title-big{font-size:14.5px}
395
+ @media(min-width:768px){.bdp-card-title{font-size:13.5px}.bdp-card-title-big{font-size:15px}}
396
+ .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}
397
+ .bdp-card-footer{display:flex;justify-content:space-between;align-items:center;margin-top:6px}
398
+ .bdp-card-time{color:#555;font-size:10.5px}
399
+ .bdp-share-btn{background:none;border:none;cursor:pointer;font-size:15px;padding:3px 5px;border-radius:6px;transition:background .15s;color:#777}
400
+ .bdp-share-btn:hover{background:#333}
401
+ .bdp-badge{font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:4px}
402
+ .bdp-badge-vne{background:#c0392b;color:#fff}
403
+ .bdp-badge-bdp{background:#1a5c35;color:#fff}
404
+ .bdp-article{padding:14px 12px 30px;max-width:720px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
405
+ @media(min-width:768px){.bdp-article{padding:20px 16px 50px}}
406
+ .bdp-article-title{font-size:21px;font-weight:800;color:#f0f0f0;line-height:1.3;margin:0 0 8px}
407
+ @media(min-width:768px){.bdp-article-title{font-size:27px}}
408
+ .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}
409
+ .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}
410
+ .bdp-share-article-btn:hover{opacity:.85}
411
+ .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}
412
+ .bdp-article-p{font-size:15.5px;line-height:1.75;color:#ccc;margin:0 0 12px}
413
+ @media(min-width:768px){.bdp-article-p{font-size:16.5px}}
414
+ .bdp-article-h2{font-size:19px;font-weight:700;color:#eee;margin:24px 0 10px}
415
+ .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}
416
+ .bdp-figure{margin:14px 0;text-align:center}
417
+ .bdp-figure img{max-width:100%;height:auto;border-radius:8px}
418
+ .bdp-figcap{color:#666;font-size:11.5px;margin-top:4px;font-style:italic}
419
+ .bdp-video-wrap{margin:14px 0;border-radius:10px;overflow:hidden;background:#000}
420
+ .bdp-video{width:100%;max-height:420px;display:block;border-radius:10px}
421
+ @media(min-width:768px){.bdp-video{max-height:500px}}
422
+ .bdp-related{margin-top:24px;padding-top:14px;border-top:1px solid #2a2a2a}
423
+ .bdp-related h3{font-size:16px;color:#eee;margin:0 0 8px}
424
+ .bdp-related-item{padding:8px 10px;margin-bottom:5px;border:1px solid #262626;border-radius:8px;cursor:pointer;transition:background .15s}
425
+ .bdp-related-item:hover{background:#222}
426
+ .bdp-related-item span{font-size:13.5px;color:#5cb87a;font-weight:500}
427
+ .bdp-comments{margin-top:28px;padding-top:16px;border-top:1px solid #2a2a2a}
428
+ .bdp-comments h3{font-size:16px;color:#eee;margin:0 0 10px}
429
+ .bdp-cmt-item{background:#1a1a1a;border:1px solid #262626;border-radius:8px;padding:10px 12px;margin-bottom:8px}
430
+ .bdp-cmt-author{font-weight:700;color:#5cb87a;font-size:13px}
431
+ .bdp-cmt-date{color:#555;font-size:11px;margin-left:8px}
432
+ .bdp-cmt-body{color:#ccc;font-size:14px;margin-top:4px;line-height:1.5}
433
+ .bdp-cmt-form{margin-top:12px}
434
+ .bdp-cmt-input{width:100%;padding:8px 10px;background:#1a1a1a;border:1px solid #333;border-radius:6px;color:#eee;font-size:13px;margin-bottom:6px;box-sizing:border-box}
435
+ .bdp-cmt-textarea{width:100%;padding:8px 10px;background:#1a1a1a;border:1px solid #333;border-radius:6px;color:#eee;font-size:13px;resize:vertical;box-sizing:border-box}
436
+ .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}
437
+ .bdp-cmt-submit:hover{opacity:.85}
438
+ .bdp-cmt-empty{color:#555;font-size:13px;font-style:italic;padding:8px 0}
439
+ .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}
440
+ .bdp-toast.show{opacity:1}
441
+ .gr-group,.gr-box,.gr-panel{background:#111!important;border:none!important}
442
+ .label-wrap{background:#111!important}
443
+ """
444
+
445
+ JS_HEAD = """
446
+ <meta name="description" content="Tin tức tổng hợp nhanh nhất từ VnExpress, BongDaPlus - Thời sự, Thế giới, Kinh doanh, Công nghệ, Thể thao, Giải trí">
447
+ <meta property="og:title" content="Tin Tức Việt Nam - Tổng Hợp Nhanh Nhất" id="og-title">
448
+ <meta property="og:description" content="Đọc tin tức từ VnExpress, BongDaPlus. Cập nhật tự động." id="og-desc">
449
+ <meta property="og:type" content="website">
450
+ <meta property="og:image" content="https://bongdaplus.vn/Assets/img/logo.png" id="og-image">
451
+ <meta property="og:url" content="" id="og-url">
452
+ <meta name="twitter:card" content="summary_large_image">
453
+ <meta name="twitter:image" content="" id="tw-image">
454
+ <link rel="canonical" href="https://bep40-bongdaplus-news.hf.space">
455
+ <script>
456
+ (function(){
457
+ window.bdpOpen=function(url,aid,sl,img){
458
+ window.location.hash='#/'+sl+'/'+aid;
459
+ try{localStorage.setItem('bdp_url_'+aid,url);if(img)localStorage.setItem('bdp_img_'+aid,img);}catch(e){}
460
+ /* Update OG meta tags dynamically */
461
+ var oi=document.getElementById('og-image');if(oi&&img)oi.content=img;
462
+ var ti=document.getElementById('tw-image');if(ti&&img)ti.content=img;
463
+ var ou=document.getElementById('og-url');if(ou)ou.content=window.location.href;
464
+ var el=document.getElementById('article-url-input');
465
+ if(el){var ta=el.querySelector('textarea');if(ta){ta.value=url;ta.dispatchEvent(new Event('input',{bubbles:true}));}}
466
+ document.getElementById('btn-read-article').click();
467
+ window.scrollTo({top:0,behavior:'smooth'});
468
+ };
469
+ window.bdpShare=async function(title,aid,sl,img){
470
+ var u=window.location.origin+window.location.pathname+'#/'+sl+'/'+aid;
471
+ /* Store image for when someone opens the shared link */
472
+ try{if(img)localStorage.setItem('bdp_img_'+aid,img);}catch(e){}
473
+ /* Update OG tags before share so crawlers pick them up */
474
+ var oi=document.getElementById('og-image');if(oi&&img)oi.content=img;
475
+ var ot=document.getElementById('og-title');if(ot)ot.content=title;
476
+ var ou=document.getElementById('og-url');if(ou)ou.content=u;
477
+ var ti=document.getElementById('tw-image');if(ti&&img)ti.content=img;
478
+ var sd={title:title,url:u,text:title+' - Tin tức tổng hợp'};
479
+ if(navigator.share){try{await navigator.share(sd);return;}catch(e){if(e.name==='AbortError')return;}}
480
+ if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(u);window.bdpToast('✅ Đã sao chép liên kết!');return;}catch(e){}}
481
+ var ta=document.createElement('textarea');ta.value=u;ta.style.cssText='position:fixed;opacity:0';
482
+ document.body.appendChild(ta);ta.focus();ta.select();
483
+ try{document.execCommand('copy');window.bdpToast('✅ Đã sao chép liên kết!');}catch(e){window.prompt('Sao chép:',u);}
484
+ document.body.removeChild(ta);
485
+ };
486
+ 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);};
487
+ function gc(a){try{return JSON.parse(localStorage.getItem('bdp_cmt_'+a))||[];}catch(e){return[];}}
488
+ function sc(a,c){try{localStorage.setItem('bdp_cmt_'+a,JSON.stringify(c));}catch(e){}}
489
+ window.bdpRenderCmt=function(a){var l=document.getElementById('cmt-list-'+a);if(!l)return;var c=gc(a);if(!c.length){l.innerHTML='<div class="bdp-cmt-empty">Chưa có bình luận. Hãy là người đầu tiên!</div>';return;}var h='';for(var i=c.length-1;i>=0;i--){var x=c[i];h+='<div class="bdp-cmt-item"><span class="bdp-cmt-author">'+x.name+'</span><span class="bdp-cmt-date">'+x.date+'</span><div class="bdp-cmt-body">'+x.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</div></div>';}l.innerHTML=h;};
490
+ 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!');};
491
+ new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){if(n.querySelectorAll){n.querySelectorAll('[id^="cmt-list-"]').forEach(function(d){window.bdpRenderCmt(d.id.replace('cmt-list-',''));});}});});}).observe(document.body,{childList:true,subtree:true});
492
+ window.addEventListener('load',function(){var h=window.location.hash;if(h&&h.startsWith('#/')){var ps=h.slice(2).split('/');if(ps.length>=2){var aid=ps[ps.length-1];try{var url=localStorage.getItem('bdp_url_'+aid);var img=localStorage.getItem('bdp_img_'+aid)||'';if(url)setTimeout(function(){window.bdpOpen(url,aid,ps.slice(0,-1).join('/'),img);},1200);}catch(e){}}}});
493
+ })();
494
+ </script>
495
+ """
496
+
497
+ with gr.Blocks(title="Tin Tức Việt Nam - VnExpress, BongDaPlus", css=CSS, head=JS_HEAD, theme=gr.themes.Base(), fill_width=True) as demo:
498
+ gr.HTML("""<div class="bdp-header"><h1>📰 Tin Tức Việt Nam</h1>
499
+ <p>VnExpress · BongDaPlus · Thời sự · Thế giới · Kinh doanh · Công nghệ · Thể thao · Giải trí</p></div>""")
500
+ article_url = gr.Textbox(value="", visible=False, elem_id="article-url-input")
501
+ with gr.Row(elem_classes=["controls-row"]):
502
+ cat = gr.Dropdown(choices=list(CATEGORIES.keys()), value="🏠 Trang Chủ (Nổi Bật)", label="Chuyên mục", scale=3, interactive=True)
503
+ ref_btn = gr.Button("🔄 Làm mới", variant="primary", scale=1)
504
+ back_btn = gr.Button("← Quay lại", variant="secondary", scale=1, visible=False)
505
+ news_list = gr.HTML()
506
+ article_view = gr.HTML(visible=False)
507
+ read_btn = gr.Button("Đọc", visible=False, elem_id="btn-read-article")
508
+ def show_article(url):
509
+ if not url or url == "#" or len(url) < 10:
510
+ return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), ""
511
+ return (gr.update(visible=False), gr.update(value=read_article(url), visible=True), gr.update(visible=False), gr.update(visible=True), "")
512
+ def show_list(c):
513
+ return (gr.update(value=fetch_news_list(c), visible=True), gr.update(visible=False), gr.update(visible=True), gr.update(visible=False))
514
+ read_btn.click(fn=show_article, inputs=[article_url], outputs=[news_list, article_view, ref_btn, back_btn, article_url])
515
+ back_btn.click(fn=show_list, inputs=[cat], outputs=[news_list, article_view, ref_btn, back_btn])
516
+ ref_btn.click(fn=show_list, inputs=[cat], outputs=[news_list, article_view, ref_btn, back_btn])
517
+ cat.change(fn=show_list, inputs=[cat], outputs=[news_list, article_view, ref_btn, back_btn])
518
+ timer = gr.Timer(value=REFRESH_SECONDS, active=True)
519
+ timer.tick(fn=fetch_news_list, inputs=cat, outputs=news_list)
520
+ demo.load(fn=fetch_news_list, inputs=cat, outputs=news_list)
521
+
522
+ if __name__ == "__main__":
523
+ demo.launch()