bep40 commited on
Commit
95e415a
·
verified ·
1 Parent(s): d84565a

feat: app.py as patcher entry point, patches and runs main app with 24h shorts

Browse files
Files changed (1) hide show
  1. app.py +85 -1195
app.py CHANGED
@@ -1,502 +1,57 @@
1
- import gradio as gr
2
- import requests
3
- import re
4
- import hashlib
5
- import json
6
- from urllib.parse import quote as urlquote
7
- from bs4 import BeautifulSoup
8
- from datetime import datetime, timezone, timedelta
9
- from concurrent.futures import ThreadPoolExecutor, as_completed
10
-
11
- HEADERS = {
12
- "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",
13
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
14
- }
15
- BASE_BDP = "https://bongdaplus.vn"
16
- BASE_24H = "https://www.24h.com.vn"
17
- REFRESH_SECONDS = 300
18
- SPACE_URL = "https://bep40-bongdaplus-news.hf.space"
19
-
20
- CATEGORIES = {
21
- "🏠 Trang Chủ (Nổi Bật)": "mix::home::Trang Chủ",
22
- "🎬 Video Tổng Hợp": "mix::video::Video",
23
- "📰 Thời Sự": "vne::https://vnexpress.net/thoi-su::Thời Sự",
24
- "🌍 Thế Giới": "vne::https://vnexpress.net/the-gioi::Thế Giới",
25
- "💰 Kinh Doanh": "vne::https://vnexpress.net/kinh-doanh::Kinh Doanh",
26
- "💻 Công Nghệ": "vne::https://vnexpress.net/so-hoa::Công Nghệ",
27
- "🔬 Khoa Học": "vne::https://vnexpress.net/khoa-hoc::Khoa Học",
28
- "🎬 Giải Trí": "vne::https://vnexpress.net/giai-tri::Giải Trí",
29
- "🏥 Sức Khỏe": "vne::https://vnexpress.net/suc-khoe::Sức Khỏe",
30
- "🎓 Giáo Dục": "vne::https://vnexpress.net/giao-duc::Giáo Dục",
31
- "✈️ Du Lịch": "vne::https://vnexpress.net/du-lich::Du Lịch",
32
- "⚽ Thể Thao": "vne::https://vnexpress.net/the-thao::Thể Thao",
33
- " Bóng Đá QT": "vne::https://vnexpress.net/the-thao/bong-da::Bóng Đá",
34
- "🏴󠁧󠁢󠁥󠁮󠁧󠁿 Ngoại Hạng Anh": "bdp::https://bongdaplus.vn/ngoai-hang-anh::Bóng Đá",
35
- "🇪🇸 La Liga": "bdp::https://bongdaplus.vn/la-liga::Bóng Đá",
36
- "🏆 Champions League": "bdp::https://bongdaplus.vn/champions-league-cup-c1::Bóng Đá",
37
- "🇻🇳 Bóng Đá VN": "bdp::https://bongdaplus.vn/bong-da-viet-nam::Bóng Đá",
38
- "🔄 Chuyển Nhượng": "bdp::https://bongdaplus.vn/tin-chuyen-nhuong::Bóng Đá",
39
- }
40
-
41
- # Mapping for icon grid: (icon, short_label, hash_slug)
42
- CAT_ICONS = [
43
- ("🏠","Trang Chủ","trang-chu"),
44
- ("🎬","Video","video"),
45
- ("📰","Thời Sự","thoi-su"),
46
- ("🌍","Thế Giới","the-gioi"),
47
- ("💰","Kinh Doanh","kinh-doanh"),
48
- ("💻","Công Nghệ","cong-nghe"),
49
- ("🔬","Khoa Học","khoa-hoc"),
50
- ("🎬","Giải Trí","giai-tri"),
51
- ("🏥","Sức Khỏe","suc-khoe"),
52
- ("🎓","Giáo Dục","giao-duc"),
53
- ("✈️","Du Lịch","du-lich"),
54
- ("","Thể Thao","the-thao"),
55
- ("⚽","Bóng Đá QT","bong-da-qt"),
56
- ("⚽","Ngoại Hạng Anh","ngoai-hang-anh"),
57
- ("⚽","La Liga","la-liga"),
58
- ("🏆","Champions League","champions-league"),
59
- ("⚽","Bóng Đá VN","bong-da-vn"),
60
- ("🔄","Chuyển Nhượng","chuyen-nhuong"),
61
- ]
62
- CAT_KEYS = list(CATEGORIES.keys())
63
- CAT_HASH_TO_KEY = {ci[2]: CAT_KEYS[i] for i, ci in enumerate(CAT_ICONS)}
64
- HOMEPAGE_SOURCES = [
65
- ("vne","https://vnexpress.net/thoi-su","Thời Sự"),
66
- ("vne","https://vnexpress.net/the-gioi","Thế Giới"),
67
- ("vne","https://vnexpress.net/kinh-doanh","Kinh Doanh"),
68
- ("vne","https://vnexpress.net/so-hoa","Công Nghệ"),
69
- ("vne","https://vnexpress.net/the-thao","Thể Thao"),
70
- ("vne","https://vnexpress.net/giai-tri","Giải Trí"),
71
- ("bdp","https://bongdaplus.vn/tin-moi","Bóng Đá"),
72
- ]
73
-
74
- # ── Helpers ────────────────────────────────────────────────────────────────────
75
- def strip_links(html):
76
- return re.sub(r'</a>', '', re.sub(r'<a\s[^>]*>', '', html))
77
-
78
- def esc(text):
79
- return text.replace("\\","\\\\").replace("'","\\'").replace('"','\\"').replace("\n"," ")
80
-
81
- def safe_url(url):
82
- if not url: return ""
83
- try:
84
- return urlquote(url, safe=':/?#[]@!$&()*+,;=-._~%')
85
- except Exception:
86
- return url.replace(" ","%20").replace("'","%27").replace('"',"%22")
87
-
88
- def make_id(url):
89
- return hashlib.md5(url.encode()).hexdigest()[:12]
90
-
91
- def slug(text):
92
- s = text.lower().strip()
93
- for p,r in [('[àáạảãâầấậẩẫăằắặẳẵ]','a'),('[èéẹẻẽêềếệểễ]','e'),('[ìíịỉĩ]','i'),
94
- ('[òóọỏõôồốộổỗơờớợởỡ]','o'),('[ùúụủũưừứựửữ]','u'),('[ỳýỵỷỹ]','y'),('[đ]','d')]:
95
- s = re.sub(p,r,s)
96
- return re.sub(r'[\s-]+','-',re.sub(r'[^a-z0-9\s-]','',s)).strip('-')[:60]
97
-
98
- def _extract_bdp_video_id(url):
99
- m = re.search(r'-(\d{6,})\.html', url)
100
- return m.group(1) if m else None
101
-
102
- # ══════════════════════════════════════════════════════════��═══════════════════
103
- # SCRAPERS
104
- # ══════════════════════════════════════════════════════════════════════════════
105
- def _get(url):
106
- r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
107
- return BeautifulSoup(r.text, "lxml")
108
-
109
- def _get_raw(url):
110
- r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
111
- return r.text, BeautifulSoup(r.text, "lxml")
112
-
113
- # ── BDP Video Embed Fetcher ────────────────────────────────────────────────────
114
- def fetch_bdp_embed_data(video_id):
115
- try:
116
- embed_url = f"{BASE_BDP}/video-embed/{video_id}.html"
117
- html, soup = _get_raw(embed_url)
118
- video = soup.select_one("video#videoPlayer")
119
- if not video: return None
120
- source = video.find("source")
121
- result = {"mp4": source.get("src","") if source else "", "poster": video.get("poster","")}
122
- carousel_match = re.findall(r'vdo\.relatedCarousel\((\[.*?\])\)', html, re.DOTALL)
123
- if carousel_match:
124
- posters = re.findall(r"poster:\s*'([^']+)'", carousel_match[0])
125
- vdorefs = re.findall(r"vdoref:\s*'([^']+)'", carousel_match[0])
126
- titles = re.findall(r"title:\s*'([^']*)'", carousel_match[0])
127
- result["related"] = [
128
- {"poster": p, "link": BASE_BDP + v if not v.startswith("http") else v, "title": t}
129
- for p, v, t in zip(posters, vdorefs, titles)
130
- ]
131
- return result
132
- except: return None
133
-
134
- # ── BDP Scrapers ───────────────────────────────────────────────────────────────
135
- def scrape_bdp_list(url):
136
- try:
137
- soup = _get(url); articles,seen=[],set()
138
- for sel,feat in [("div.news.fst",True),("div.sld-itm.news",True),("li.news",False)]:
139
- for it in soup.select(sel):
140
- tag = it.find("a",class_="title") or it.find("a",href=True)
141
- if not tag: continue
142
- t=tag.get_text(strip=True); lk=tag.get("href","")
143
- if not t or len(t)<5: continue
144
- if lk and not lk.startswith("http"): lk=BASE_BDP+lk
145
- if lk in seen: continue
146
- im=it.find("img"); img=(im.get("data-src") or im.get("src")) if im else None
147
- sm=it.find("p",class_="summ"); tt=it.find("div",class_="in-time")
148
- is_video = "/video/" in lk
149
- articles.append({"title":t,"link":lk,"img":img,"summary":sm.get_text(strip=True) if sm else "",
150
- "time":tt.get_text(strip=True) if tt else "","featured":feat,"source":"bdp","group":"","is_video":is_video})
151
- seen.add(lk)
152
- return articles
153
- except Exception as e:
154
- return [{"title":f"⚠️ {e}","link":"#","img":None,"summary":"","time":"","featured":False,"source":"bdp","group":"","is_video":False}]
155
-
156
- def scrape_bdp_video_list():
157
- try:
158
- soup = _get(f"{BASE_BDP}/video")
159
- articles, seen = [], set()
160
- for a in soup.find_all("a", href=True):
161
- href = a.get("href", "")
162
- if "/video/" not in href: continue
163
- if href in ("/video/", "/video/ban-thang-dep", "/video/highlight"): continue
164
- if not href.startswith("http"): href = BASE_BDP + href
165
- if href in seen: continue
166
- title = a.get_text(strip=True)
167
- title = re.sub(r'^\d{2}:\d{2}', '', title).strip()
168
- if not title or len(title) < 5: continue
169
- img_tag = a.find("img")
170
- if not img_tag:
171
- parent = a.parent
172
- if parent: img_tag = parent.find("img")
173
- img = None
174
- if img_tag: img = img_tag.get("data-src") or img_tag.get("src") or img_tag.get("data-original")
175
- articles.append({"title": title, "link": href, "img": img, "summary": "", "time": "",
176
- "featured": len(articles) < 3, "source": "bdp", "group": "BongDaPlus Video", "is_video": True})
177
- seen.add(href)
178
- return articles[:30]
179
- except Exception as e:
180
- return [{"title": f"⚠️ {e}", "link": "#", "img": None, "summary": "", "time": "",
181
- "featured": False, "source": "bdp", "group": "", "is_video": False}]
182
-
183
- def scrape_bdp_article(url):
184
- try:
185
- soup = _get(url)
186
- h1=soup.select_one(".lead-title h1") or soup.select_one("h1")
187
- te=soup.select_one(".emobar .rgt"); se=soup.select_one("div.summary")
188
- og=soup.find("meta",property="og:image"); og_img=og.get("content","") if og else ""
189
- cd=soup.select_one("div.content#postContent") or soup.select_one("div.content")
190
- body=_extract_body(cd) if cd else []
191
- is_video_page = "/video/" in url
192
- if is_video_page:
193
- vid_id = _extract_bdp_video_id(url)
194
- if vid_id:
195
- embed = fetch_bdp_embed_data(vid_id)
196
- if embed and embed.get("mp4"):
197
- body.insert(0, {"type": "video", "src": embed["mp4"], "poster": embed.get("poster", ""), "vtype": "mp4"})
198
- if not og_img or "logo" in og_img.lower(): og_img = embed.get("poster", og_img)
199
- else:
200
- for iframe in soup.select("iframe"):
201
- src = iframe.get("src", "")
202
- if "video-embed" in src:
203
- vid = _fetch_bdp_video(src)
204
- if vid: body.insert(0, vid)
205
- return {"title":h1.get_text(strip=True) if h1 else "","time":te.get_text(strip=True) if te else "",
206
- "summary":se.get_text(strip=True) if se else "","body":body,"related":_bdp_relates(soup),
207
- "source_url":url,"source":"bdp","og_image":og_img}
208
- except Exception as e:
209
- return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"bdp","og_image":""}
210
-
211
- def _fetch_bdp_video(embed_src):
212
- try:
213
- full = embed_src if embed_src.startswith("http") else BASE_BDP+embed_src
214
- html, soup = _get_raw(full)
215
- video = soup.select_one("video#videoPlayer") or soup.select_one("video")
216
- if video:
217
- source = video.find("source")
218
- if source:
219
- return {"type":"video","src":source.get("src",""),"poster":video.get("poster",""),"vtype":"mp4"}
220
- except: pass
221
- return None
222
-
223
- # ── 24h.com.vn Scrapers ───────────────────────────────────────────────────────
224
- def scrape_24h_video_list():
225
- """Scrape 24h.com.vn video-highlight page using <article> tags."""
226
- try:
227
- url = f"{BASE_24H}/video-highlight-c953.html"
228
- r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
229
- soup = BeautifulSoup(r.text, "lxml")
230
- articles, seen = [], set()
231
- for art in soup.find_all("article"):
232
- a = art.find("a", href=True)
233
- if not a: continue
234
- href = a.get("href","")
235
- if not href.startswith("http"): href = BASE_24H + href
236
- if href in seen: continue
237
- img_tag = art.find("img")
238
- title = ""
239
- if img_tag: title = img_tag.get("alt","")
240
- if not title: title = a.get("title","") or a.get_text(strip=True)
241
- if not title or len(title) < 10: continue
242
- img_src = None
243
- if img_tag:
244
- for attr in ["data-original","data-src","src"]:
245
- v = img_tag.get(attr,"")
246
- if v and "base64" not in v and len(v) > 20:
247
- img_src = v; break
248
- seen.add(href)
249
- articles.append({"title": title, "link": href, "img": img_src, "summary": "", "time": "",
250
- "featured": len(articles) < 3, "source": "24h", "group": "24h Video", "is_video": True})
251
- return articles[:30]
252
- except:
253
- return []
254
-
255
- def scrape_24h_article(url):
256
- """Scrape a 24h.com.vn article - extract m3u8 video URL."""
257
- try:
258
- r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
259
- soup = BeautifulSoup(r.text, "lxml")
260
- h1 = soup.select_one("h1")
261
- title = h1.get_text(strip=True) if h1 else ""
262
- og = soup.find("meta", property="og:image")
263
- og_img = og.get("content","") if og else ""
264
- desc_meta = soup.find("meta", property="og:description")
265
- summary = desc_meta.get("content","") if desc_meta else ""
266
- # Extract m3u8 video URLs
267
- m3u8s = re.findall(r'(https?://cdn\.24h\.com\.vn/[^\s"\'\\]+\.m3u8)', r.text)
268
- videos = [u for u in m3u8s if '_720p' not in u]
269
- if not videos: videos = m3u8s
270
- body = []
271
- for vsrc in videos[:3]:
272
- body.append({"type": "video", "src": vsrc, "poster": og_img, "vtype": "hls"})
273
- # Extract text content
274
- content_selectors = ["div.the-article-body", "div.nws-mainContent", "div.nwsCt",
275
- "div#ctl00_mainContent_ctl00_divNewsContent", "div.detail-content"]
276
- cd = None
277
- for sel in content_selectors:
278
- cd = soup.select_one(sel)
279
- if cd: break
280
- if cd:
281
- for ch in cd.children:
282
- if not hasattr(ch,'name') or not ch.name: continue
283
- if ch.name == "p":
284
- t = ch.get_text(strip=True)
285
- if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))})
286
- elif ch.name in ("h2","h3","h4"):
287
- body.append({"type":"heading","text":ch.get_text(strip=True)})
288
- return {"title": title, "time": "", "summary": summary[:200], "body": body,
289
- "related": [], "source_url": url, "source": "24h", "og_image": og_img}
290
- except Exception as e:
291
- return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"24h","og_image":""}
292
-
293
- def _extract_24h_video_urls(article_url):
294
- """Extract ALL m3u8 URLs (multiple halves/parts) + poster from a 24h article.
295
- Pattern: ...name1.m3u8 → ...name2.m3u8, ...name3.m3u8, etc.
296
- Returns list of {src, poster, vtype} or empty list."""
297
- try:
298
- r = requests.get(article_url, headers=HEADERS, timeout=10); r.encoding="utf-8"
299
- m3u8s = re.findall(r'(https?://cdn\.24h\.com\.vn/[^\s"\'\\<>]+\.m3u8)', r.text)
300
- masters = list(dict.fromkeys(u for u in m3u8s if '_720p' not in u))
301
- if not masters: return []
302
- soup = BeautifulSoup(r.text, "lxml")
303
- og = soup.find("meta", property="og:image")
304
- poster = og.get("content","") if og else ""
305
- results = [{"src": masters[0], "poster": poster, "vtype": "hls"}]
306
- # Probe numbered parts: ...name1.m3u8 → ...name2.m3u8, etc.
307
- base_match = re.match(r'(.+?)(\d+)(\.m3u8)$', masters[0])
308
- if base_match:
309
- base, start_num, ext = base_match.group(1), int(base_match.group(2)), base_match.group(3)
310
- for i in range(start_num + 1, start_num + 10):
311
- part_url = f"{base}{i}{ext}"
312
- if part_url in masters:
313
- results.append({"src": part_url, "poster": poster, "vtype": "hls"})
314
- continue
315
- try:
316
- tr = requests.head(part_url, headers=HEADERS, timeout=3, allow_redirects=True)
317
- if tr.status_code == 200:
318
- results.append({"src": part_url, "poster": poster, "vtype": "hls"})
319
- else: break
320
- except: break
321
- return results
322
- except: return []
323
-
324
- # ── VNE Scrapers ───────────────────────────────────────────────────────────────
325
- def scrape_vne_list(url):
326
- try:
327
- soup=_get(url); articles,seen=[],set()
328
- for i,it in enumerate(soup.select("article.item-news")):
329
- a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a") or it.find("a",href=True,title=True)
330
- if not a: continue
331
- t=a.get("title","") or a.get_text(strip=True); lk=a.get("href","")
332
- if not t or len(t)<5 or lk in seen: continue
333
- im=it.find("img"); img=None
334
- if im:
335
- img=im.get("data-src") or im.get("src")
336
- if img and 'blank' in img:
337
- src=it.find("source")
338
- if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
339
- desc=it.select_one("p.description")
340
- articles.append({"title":t,"link":lk,"img":img,"summary":desc.get_text(strip=True)[:150] if desc else "",
341
- "time":"","featured":i==0,"source":"vne","group":"","is_video":False})
342
- seen.add(lk)
343
- return articles
344
- except Exception as e:
345
- return [{"title":f"⚠️ {e}","link":"#","img":None,"summary":"","time":"","featured":False,"source":"vne","group":"","is_video":False}]
346
-
347
- def scrape_vne_article(url):
348
- try:
349
- soup=_get(url)
350
- h1=soup.select_one("h1.title-detail"); desc=soup.select_one("p.description"); dt=soup.select_one("span.date")
351
- og=soup.find("meta",property="og:image"); og_img=og.get("content","") if og else ""
352
- cd=soup.select_one("article.fck_detail"); body=[]
353
- if cd:
354
- for ch in cd.children:
355
- if not hasattr(ch,'name') or not ch.name: continue
356
- if ch.name=="p":
357
- vid=ch.find("video")
358
- if vid:
359
- vsrc=vid.get("src","")
360
- if vsrc:
361
- vtype="hls" if ("m3u8" in vsrc or "mpegURL" in (vid.get("type","") or "")) else "mp4"
362
- body.append({"type":"video","src":vsrc,"poster":vid.get("poster",""),"vtype":vtype})
363
- continue
364
- im=ch.find("img")
365
- if im:
366
- s=im.get("data-src") or im.get("src")
367
- if s: body.append({"type":"img","src":s,"alt":im.get("alt","")})
368
- t=ch.get_text(strip=True)
369
- if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))})
370
- elif ch.name=="figure":
371
- vid=ch.find("video")
372
- if vid:
373
- vsrc=vid.get("src","")
374
- cap=ch.find("figcaption")
375
- if vsrc:
376
- vtype="hls" if ("m3u8" in vsrc or "mpegURL" in (vid.get("type","") or "")) else "mp4"
377
- body.append({"type":"video","src":vsrc,"poster":vid.get("poster",""),"vtype":vtype,
378
- "caption":cap.get_text(strip=True) if cap else ""})
379
- continue
380
- im=ch.find("img"); cap=ch.find("figcaption")
381
- if im:
382
- s=im.get("data-src") or im.get("src")
383
- if s: body.append({"type":"img","src":s,"alt":cap.get_text(strip=True) if cap else ""})
384
- elif ch.name in ("h2","h3","h4"):
385
- body.append({"type":"heading","text":ch.get_text(strip=True)})
386
- return {"title":h1.get_text(strip=True) if h1 else "","time":dt.get_text(strip=True) if dt else "",
387
- "summary":desc.get_text(strip=True) if desc else "","body":body,"related":[],
388
- "source_url":url,"source":"vne","og_image":og_img}
389
- except Exception as e:
390
- return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"vne","og_image":""}
391
-
392
- def _extract_body(cd):
393
- body=[]
394
- for ch in cd.children:
395
- if not hasattr(ch,'name') or not ch.name: continue
396
- if ch.name=="p":
397
- im=ch.find("img")
398
- if im:
399
- s=im.get("src") or im.get("data-src")
400
- if s: body.append({"type":"img","src":s,"alt":im.get("alt","")})
401
- t=ch.get_text(strip=True)
402
- if t: body.append({"type":"p","html":strip_links(''.join(str(c) for c in ch.children))})
403
- elif ch.name in ("h2","h3","h4"):
404
- body.append({"type":"heading","text":ch.get_text(strip=True)})
405
- elif ch.name=="blockquote":
406
- body.append({"type":"quote","text":ch.get_text(strip=True)})
407
- return body
408
-
409
- def _bdp_relates(soup):
410
- rels=[]
411
- rd=soup.select_one("div.relates")
412
- if rd:
413
- for a in rd.find_all("a",href=True):
414
- t=a.get_text(strip=True); h=a.get("href","")
415
- if t and len(t)>5:
416
- if not h.startswith("http"): h=BASE_BDP+h
417
- rels.append({"title":t,"link":h})
418
- return rels[:5]
419
-
420
- def fetch_video_list():
421
- """Fetch videos from 24h only."""
422
- try:
423
- return scrape_24h_video_list()
424
- except:
425
- return []
426
-
427
- def fetch_tiktok_feed_videos():
428
- """Pre-fetch video URLs for TikTok fullscreen feed. 24h only."""
429
- results = []
430
-
431
- def _fetch_24h_vid(art):
432
- vids = _extract_24h_video_urls(art["link"])
433
- if not vids: return []
434
- out = []
435
- for pi, v in enumerate(vids):
436
- label = f" (Phần {pi+1})" if len(vids)>1 else ""
437
- out.append({"title": art["title"]+label, "src": v["src"], "poster": v["poster"],
438
- "vtype": v["vtype"], "source": "24h", "link": art["link"]})
439
- return out
440
-
441
- h24_list = scrape_24h_video_list()[:15]
442
-
443
- with ThreadPoolExecutor(max_workers=8) as ex:
444
- h24_futures = {ex.submit(_fetch_24h_vid, a): a for a in h24_list}
445
- for f in as_completed(h24_futures):
446
- try:
447
- r = f.result()
448
- if r:
449
- if isinstance(r, list): results.extend(r)
450
- else: results.append(r)
451
- except: pass
452
-
453
- return results[:25]
454
-
455
- # ── Dispatch ───────────────────────────────────────────────────────────────────
456
- def fetch_homepage():
457
- all_articles=[]
458
- h24_videos=[]
459
- def _fetch(src,url,group):
460
- arts=scrape_bdp_list(url) if src=="bdp" else scrape_vne_list(url)
461
- for a in arts: a["group"]=group
462
- return arts
463
- def _fetch_24h():
464
- nonlocal h24_videos
465
- try: h24_videos=scrape_24h_video_list()[:15]
466
- except: pass
467
- with ThreadPoolExecutor(max_workers=6) as ex:
468
- ex.submit(_fetch_24h)
469
- futures={ex.submit(_fetch,s,u,g):g for s,u,g in HOMEPAGE_SOURCES}
470
- for f in as_completed(futures):
471
- try: all_articles.extend(f.result())
472
- except: pass
473
- return all_articles, h24_videos
474
-
475
- def fetch_news_list(category):
476
- val=CATEGORIES.get(category,list(CATEGORIES.values())[0])
477
- parts=val.split("::"); src,url_or_key,group=parts[0],parts[1],parts[2]
478
- if src=="mix" and url_or_key=="home":
479
- articles, h24_videos = fetch_homepage()
480
- return render_homepage_html(articles, h24_videos)
481
- if src=="mix" and url_or_key=="video":
482
- return render_video_page_html()
483
- articles=scrape_bdp_list(url_or_key) if src=="bdp" else scrape_vne_list(url_or_key)
484
- for a in articles: a["group"]=group
485
- return render_list_html(articles,group)
486
-
487
- def read_article(url):
488
- if not url or url=="#" or len(url)<10: return "<p>Không tìm thấy bài viết.</p>"
489
- if "vnexpress.net" in url: return render_article_html(scrape_vne_article(url))
490
- if "24h.com.vn" in url: return render_article_html(scrape_24h_article(url))
491
- return render_article_html(scrape_bdp_article(url))
492
-
493
- # ══════════════════════════════════════════════════════���═══════════════════════
494
- # HTML RENDERERS
495
- # ══════════════════════════════════════════════════════════════════════════════
496
-
497
- def render_video_carousel_html(videos):
498
- """Carousel video 24h highlights, dùng dữ liệu list page (không fetch từng bài)."""
499
- vids_with_img = [v for v in videos if v.get("img")]
500
  if not vids_with_img: return ""
501
  items = []
502
  for i, v in enumerate(vids_with_img[:15]):
@@ -504,700 +59,35 @@ def render_video_carousel_html(videos):
504
  link = v.get("link","#")
505
  title = v.get("title","")
506
  aid = make_id(link); sl = slug(title)
507
- click_js = f"window.bdpOpenTikTok('{esc(link)}','{aid}')"
508
- items.append(f'''<div class="vslide-item" onclick="{click_js}">
509
- <div class="vslide-thumb"><img src="{img}" alt="" class="bdp-lazy-img">
510
- <div class="vslide-play">▶</div><span class="vslide-badge vslide-badge-24h">24h</span></div>
511
- <p class="vslide-title">{title}</p></div>''')
512
- return f'''<div class="vslide-wrap">
513
- <div class="vslide-header"><span class="vslide-label">🎬 Video Highlight</span>
514
- <div class="vslide-nav"><button class="vslide-btn" onclick="window.bdpSlideScroll(-1,'vslide-video')">◀</button>
515
- <button class="vslide-btn" onclick="window.bdpSlideScroll(1,'vslide-video')">▶</button></div></div>
516
- <div class="vslide-track" id="vslide-video">{''.join(items)}</div></div>'''
517
-
518
- def render_featured_carousel_html(articles):
519
- """Carousel tin nổi bật + mới nhất, lấy từ articles đã fetch sẵn (không fetch thêm)."""
520
- # Lấy bài featured có ảnh, ưu tiên bài đầu mỗi nhóm
521
- top = []
522
- seen = set()
523
- for a in articles:
524
- if a.get("img") and a.get("link","#") != "#" and a["link"] not in seen:
525
- top.append(a)
526
- seen.add(a["link"])
527
- if len(top) >= 15: break
528
- if not top: return ""
529
- items = []
530
- for i, a in enumerate(top):
531
- img = safe_url(a.get("img",""))
532
- link = a.get("link","#")
533
- title = a.get("title","")
534
- aid = make_id(link); sl = slug(title)
535
- grp = a.get("group","")
536
- src = a.get("source","")
537
- badge_cls = "vslide-badge-vne" if src=="vne" else "vslide-badge-bdp" if src=="bdp" else "vslide-badge-24h"
538
- badge_txt = grp or ("VnExpress" if src=="vne" else "BDP" if src=="bdp" else "24h")
539
- click_js = f"window.bdpOpen('{esc(link)}','{aid}','{sl}')"
540
- items.append(f'''<div class="vslide-item" onclick="{click_js}">
541
  <div class="vslide-thumb"><img src="{img}" alt="" class="bdp-lazy-img">
542
- <span class="vslide-badge {badge_cls}">{badge_txt}</span></div>
543
- <p class="vslide-title">{title}</p></div>''')
544
- return f'''<div class="vslide-wrap">
545
- <div class="vslide-header"><span class="vslide-label">🔥 Tin Nổi Bật</span>
546
- <div class="vslide-nav"><button class="vslide-btn" onclick="window.bdpSlideScroll(-1,'vslide-news')"></button>
547
- <button class="vslide-btn" onclick="window.bdpSlideScroll(1,'vslide-news')"></button></div></div>
548
- <div class="vslide-track" id="vslide-news">{''.join(items)}</div></div>'''
549
-
550
- def render_homepage_html(articles, h24_videos=None):
551
- if not articles: return "<p class='bdp-empty'>Không tìm thấy tin tức.</p>"
552
- now=datetime.now(timezone(timedelta(hours=7))).strftime("%H:%M:%S %d/%m/%Y")
553
- video_carousel = render_video_carousel_html(h24_videos or [])
554
- news_carousel = render_featured_carousel_html(articles)
555
- groups={}
556
- for a in articles: groups.setdefault(a.get("group","Khác"),[]).append(a)
557
- 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>']
558
- for gn in ["Thời Sự","Thế Giới","Kinh Doanh","Công Nghệ","Thể Thao","Giải Trí","Bóng Đá"]:
559
- arts=groups.get(gn,[])
560
- if not arts: continue
561
- feat=[a for a in arts if a.get("featured")][:2]
562
- reg=[a for a in arts if not a.get("featured")][:4]
563
- display=feat+reg
564
- if not display: continue
565
- parts.append(f'<div class="bdp-section"><h2 class="bdp-section-title">{gn}</h2><div class="bdp-grid bdp-grid-home">')
566
- for i,art in enumerate(display[:6]): parts.append(_list_card(art,i<len(feat)))
567
- parts.append('</div></div>')
568
- parts.append('</div>')
569
- return '\n'.join(parts)
570
-
571
- def render_video_page_html():
572
- """Render video page with fullscreen TikTok feed + grid list."""
573
- articles = fetch_video_list()
574
- tiktok_videos = fetch_tiktok_feed_videos()
575
- now = datetime.now(timezone(timedelta(hours=7))).strftime("%H:%M:%S %d/%m/%Y")
576
-
577
- # Build TikTok fullscreen feed HTML
578
- tiktok_html = ""
579
- if tiktok_videos:
580
- slides = []
581
- for vi, v in enumerate(tiktok_videos):
582
- poster = safe_url(v.get("poster",""))
583
- poster_attr = f' poster="{poster}"' if poster else ""
584
- vsrc = v["src"]
585
- vtype = v.get("vtype","mp4")
586
- title_esc = esc(v.get("title",""))
587
- src_label = "24h" if v.get("source")=="24h" else "BDP"
588
- badge_cls = "bdp-badge-24h" if v.get("source")=="24h" else "bdp-badge-bdp"
589
- aid = make_id(v.get("link",""))
590
- vid = make_id(vsrc)
591
- sl = slug(v.get("title",""))
592
- open_js = f"event.stopPropagation();window.bdpOpen('{esc(v.get('link',''))}','{aid}','{sl}')"
593
- share_js = f"event.stopPropagation();window.bdpShareTikTok('{title_esc}','{sl}','{vid}')"
594
-
595
- if vtype == "hls":
596
- video_tag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} data-hls-src="{vsrc}" muted loop></video>'
597
- else:
598
- video_tag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} muted loop><source src="{safe_url(vsrc)}" type="video/mp4"></video>'
599
-
600
- slides.append(f'''<div class="tiktok-slide" data-index="{vi}" data-aid="{aid}" data-vid="{vid}">
601
- {video_tag}
602
- <div class="tiktok-pause-icon">▶</div>
603
- <div class="tiktok-seek-controls">
604
- <button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,-10)">⏪ 10s</button>
605
- <button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,10)">10s ⏩</button>
606
- </div>
607
- <div class="tiktok-bottom">
608
- <span class="bdp-badge {badge_cls}">{src_label}</span>
609
- <p class="tiktok-title">{v.get("title","")}</p>
610
- <div class="tiktok-actions">
611
- <button class="tiktok-action-btn" onclick="{open_js}">📰 Đọc</button>
612
- <button class="tiktok-action-btn" onclick="{share_js}">📤 Chia sẻ</button>
613
- </div>
614
- </div>
615
- <div class="tiktok-unmute-hint" onclick="window.bdpTikTokUnmute(this)">🔇 Nhấn để bật tiếng</div>
616
- <span class="tiktok-counter">{vi+1}/{len(tiktok_videos)}</span>
617
- </div>''')
618
-
619
- tiktok_html = f'''<div class="tiktok-fullscreen-container" id="tiktok-fullscreen-feed">
620
- <div class="tiktok-fullscreen-feed">{''.join(slides)}</div>
621
- </div>'''
622
-
623
- # Also render article grid below
624
- grid_html = ""
625
- if articles:
626
- feat=[a for a in articles if a.get("featured")]
627
- reg=[a for a in articles if not a.get("featured")]
628
- grid_parts = ['<div class="bdp-wrap">']
629
- grid_parts.append(f'<div class="bdp-topbar"><span>⏱ {now}</span><span>📰 {len(articles)} video · BDP + 24h</span></div>')
630
- if feat:
631
- grid_parts.append('<div class="bdp-grid bdp-grid-featured">')
632
- for a in feat[:6]: grid_parts.append(_list_card(a,True,tiktok=True))
633
- grid_parts.append('</div>')
634
- if reg:
635
- grid_parts.append('<div class="bdp-grid">')
636
- for a in reg[:40]: grid_parts.append(_list_card(a,False,tiktok=True))
637
- grid_parts.append('</div>')
638
- grid_parts.append('</div>')
639
- grid_html = '\n'.join(grid_parts)
640
-
641
- return tiktok_html + grid_html
642
-
643
- def render_list_html(articles,group_name=""):
644
- if not articles: return "<p class='bdp-empty'>Không tìm thấy tin tức.</p>"
645
- now=datetime.now(timezone(timedelta(hours=7))).strftime("%H:%M:%S %d/%m/%Y")
646
- feat=[a for a in articles if a.get("featured")]
647
- reg=[a for a in articles if not a.get("featured")]
648
- parts=[f'<div class="bdp-wrap"><div class="bdp-topbar"><span>⏱ {now}</span><span>📰 {len(articles)} tin · {group_name}</span></div>']
649
- if feat:
650
- parts.append('<div class="bdp-grid bdp-grid-featured">')
651
- for a in feat[:6]: parts.append(_list_card(a,True))
652
- parts.append('</div>')
653
- if reg:
654
- parts.append('<div class="bdp-grid">')
655
- for a in reg[:40]: parts.append(_list_card(a,False))
656
- parts.append('</div>')
657
- parts.append('</div>')
658
- return '\n'.join(parts)
659
-
660
- def _list_card(art,big,tiktok=False):
661
- img_html=""
662
- if art.get("img"):
663
- c="bdp-card-img bdp-card-img-big" if big else "bdp-card-img"
664
- is_video = art.get("is_video", False) or "/video/" in art.get("link","")
665
- play_overlay = '<div class="bdp-play-overlay">▶</div>' if is_video else ""
666
- img_html=f'<div class="{c}"><img src="{safe_url(art["img"])}" alt="" class="bdp-lazy-img">{play_overlay}</div>'
667
- time_html=f'<span class="bdp-card-time">🕐 {art["time"]}</span>' if art.get("time") else ""
668
- summ_html=f'<p class="bdp-card-summ">{art["summary"][:120]}...</p>' if art.get("summary") and len(art["summary"])>10 else ""
669
- link=art.get("link","#"); aid=make_id(link)
670
- tc="bdp-card-title bdp-card-title-big" if big else "bdp-card-title"
671
- grp=art.get("group",""); badge=""
672
- if art.get("source")=="vne": badge=f'<span class="bdp-badge bdp-badge-vne">{grp or "VnExpress"}</span>'
673
- elif art.get("source")=="24h": badge=f'<span class="bdp-badge bdp-badge-24h">{grp or "24h"}</span>'
674
- elif art.get("source")=="bdp": badge=f'<span class="bdp-badge bdp-badge-bdp">{grp or "BongDaPlus"}</span>'
675
- sl=slug(art["title"])
676
- share_js=f"event.stopPropagation();window.bdpShareHash('{esc(art['title'])}','{sl}','{aid}')"
677
- if tiktok:
678
- click_js=f"window.bdpOpenTikTok('{esc(link)}','{aid}')"
679
- else:
680
- click_js=f"window.bdpOpen('{esc(link)}','{aid}','{sl}')"
681
- return f"""<div class="bdp-card" onclick="{click_js}">
682
- {img_html}<div class="bdp-card-body">{badge}<h3 class="{tc}">{art['title']}</h3>
683
- {summ_html}<div class="bdp-card-footer">{time_html}
684
- <button class="bdp-share-btn" onclick="{share_js}" title="Chia sẻ">📤</button></div></div></div>"""
685
-
686
- def render_article_html(article):
687
- aid=make_id(article["source_url"]); sl=slug(article["title"])
688
- src_url=article.get("source_url","")
689
- og_img=safe_url(article.get("og_image",""))
690
- share_js=f"window.bdpShareHash('{esc(article['title'])}','{sl}','{aid}')"
691
- src_map={"vne":"VnExpress","bdp":"BongDaPlus","24h":"24h.com.vn"}
692
- src_label=src_map.get(article.get("source",""),"")
693
- 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>'
694
-
695
- parts=[f"""{seo}<div class="bdp-article">
696
- <h1 class="bdp-article-title">{article['title']}</h1>
697
- <div class="bdp-article-meta"><span>🕐 {article['time']} · {src_label}</span>
698
- <button class="bdp-share-article-btn" onclick="{share_js}">📤 Chia sẻ</button></div>"""]
699
- if article.get("summary"):
700
- parts.append(f'<div class="bdp-article-summary">{article["summary"]}</div>')
701
-
702
- for item in article.get("body",[]):
703
- if item["type"]=="video":
704
- poster=safe_url(item.get("poster",""))
705
- poster_attr=f' poster="{poster}"' if poster else ""
706
- caption=item.get("caption","")
707
- cap_html=f'<p class="bdp-figcap">{caption}</p>' if caption else ""
708
- vtype=item.get("vtype","mp4")
709
- vsrc=item["src"]
710
- if vtype=="hls":
711
- 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>')
712
- else:
713
- 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>')
714
- elif item["type"]=="img":
715
- alt=item.get("alt",""); cap=f'<figcaption class="bdp-figcap">{alt}</figcaption>' if alt else ""
716
- parts.append(f'<figure class="bdp-figure"><img src="{safe_url(item["src"])}" alt="{alt}" class="bdp-lazy-img">{cap}</figure>')
717
- elif item["type"]=="p":
718
- parts.append(f'<p class="bdp-article-p">{item["html"]}</p>')
719
- elif item["type"]=="heading":
720
- parts.append(f'<h2 class="bdp-article-h2">{item["text"]}</h2>')
721
- elif item["type"]=="quote":
722
- parts.append(f'<blockquote class="bdp-quote">{item["text"]}</blockquote>')
723
- if article.get("related"):
724
- parts.append('<div class="bdp-related"><h3>📰 Tin liên quan</h3>')
725
- for rel in article["related"]:
726
- rid=make_id(rel["link"]); rs=slug(rel["title"])
727
- parts.append(f'<div class="bdp-related-item" onclick="window.bdpOpen(\'{esc(rel["link"])}\',\'{rid}\',\'{rs}\')"><span>▸ {rel["title"]}</span></div>')
728
- parts.append('</div>')
729
- parts.append(f"""<div class="bdp-comments" id="comments-{aid}"><h3>💬 Bình luận</h3>
730
- <div id="cmt-list-{aid}"></div><div class="bdp-cmt-form">
731
- <input id="cmt-name-{aid}" class="bdp-cmt-input" placeholder="Tên của bạn..." maxlength="50">
732
- <textarea id="cmt-text-{aid}" class="bdp-cmt-textarea" placeholder="Viết bình luận..." rows="3" maxlength="500"></textarea>
733
- <button class="bdp-cmt-submit" onclick="window.bdpAddCmt('{aid}')">Gửi bình luận</button></div></div>""")
734
- parts.append('</div>')
735
- return '\n'.join(parts)
736
-
737
- # ══════════════════════════════════════════════════════════════════════════════
738
- CSS = """
739
- body,html{margin:0!important;padding:0!important;overflow-x:hidden;background:#111!important}
740
- .gradio-container{max-width:100%!important;width:100%!important;margin:0!important;padding:0!important;border-radius:0!important;background:#111!important}
741
- .main,.contain{max-width:100%!important;width:100%!important;padding:0!important;margin:0!important}
742
- .gradio-container>.main>.contain{padding-top:0!important}
743
- .gap{gap:0!important}
744
- footer,.built-with{display:none!important}
745
- #article-url-input,#btn-read-article{display:none!important;height:0!important;overflow:hidden!important}
746
- .bdp-header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:14px 16px;text-align:center}
747
- .bdp-header h1{color:#fff;font-size:20px;margin:0;font-weight:800;text-shadow:0 2px 6px rgba(0,0,0,.4)}
748
- .bdp-header p{color:rgba(255,255,255,.6);font-size:11px;margin:2px 0 0}
749
- @media(min-width:768px){.bdp-header h1{font-size:26px}.bdp-header{padding:20px}}
750
- #cat-input,#btn-switch-cat{display:none!important;height:0!important;overflow:hidden!important}
751
- .vslide-wrap{margin:8px;background:#1a1a1a;border-radius:12px;overflow:hidden;border:1px solid #2a2a2a}
752
- .vslide-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px}
753
- .vslide-label{color:#f0c040;font-size:15px;font-weight:700}
754
- .vslide-nav{display:flex;gap:6px}
755
- .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}
756
- .vslide-btn:hover{background:#555}
757
- .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}
758
- .vslide-track::-webkit-scrollbar{display:none}
759
- .vslide-item{flex:0 0 200px;scroll-snap-align:start;cursor:pointer;transition:transform .15s}
760
- .vslide-item:hover{transform:scale(1.03)}
761
- @media(min-width:768px){.vslide-item{flex:0 0 240px}}
762
- .vslide-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:8px;overflow:hidden;background:#222}
763
- .vslide-thumb img{width:100%;height:100%;object-fit:cover}
764
- .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}
765
- .vslide-item:hover .vslide-play{background:rgba(220,50,50,.8)}
766
- .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}
767
- .bdp-wrap{padding:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
768
- .bdp-topbar{display:flex;justify-content:space-between;padding:4px 4px 8px;color:#666;font-size:11px}
769
- .bdp-empty{text-align:center;color:#666;padding:60px 20px}
770
- .bdp-section{margin-bottom:16px}
771
- .bdp-section-title{font-size:16px;font-weight:700;color:#5cb87a;margin:4px 0 8px;border-left:3px solid #5cb87a;padding-left:8px}
772
- @media(min-width:768px){.bdp-section-title{font-size:18px}}
773
- .bdp-grid{display:grid;grid-template-columns:1fr;gap:8px}
774
- .bdp-grid-featured,.bdp-grid-home{margin-bottom:4px}
775
- @media(min-width:420px){.bdp-grid{grid-template-columns:repeat(2,1fr)}}
776
- @media(min-width:768px){.bdp-grid{grid-template-columns:repeat(3,1fr);gap:10px}}
777
- @media(min-width:1100px){.bdp-grid{grid-template-columns:repeat(4,1fr)}}
778
- .bdp-card{background:#1a1a1a;border-radius:10px;overflow:hidden;cursor:pointer;transition:transform .15s,box-shadow .15s;border:1px solid #222}
779
- .bdp-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.5)}
780
- .bdp-card:active{transform:scale(.98)}
781
- .bdp-card-img{width:100%;height:130px;overflow:hidden;background:#222;position:relative}
782
- .bdp-card-img-big{height:170px}
783
- .bdp-card-img img{width:100%;height:100%;object-fit:cover}
784
- @media(min-width:768px){.bdp-card-img{height:150px}.bdp-card-img-big{height:190px}}
785
- .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}
786
- .bdp-card-body{padding:8px 10px 6px}
787
- .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}
788
- .bdp-card-title-big{font-size:14.5px}
789
- @media(min-width:768px){.bdp-card-title{font-size:13.5px}.bdp-card-title-big{font-size:15px}}
790
- .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}
791
- .bdp-card-footer{display:flex;justify-content:space-between;align-items:center;margin-top:6px}
792
- .bdp-card-time{color:#555;font-size:10.5px}
793
- .bdp-share-btn{background:none;border:none;cursor:pointer;font-size:15px;padding:3px 5px;border-radius:6px;transition:background .15s;color:#777}
794
- .bdp-share-btn:hover{background:#333}
795
- .bdp-badge{font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:4px}
796
- .bdp-badge-vne{background:#c0392b;color:#fff}
797
- .bdp-badge-bdp{background:#1a5c35;color:#fff}
798
- .bdp-badge-24h{background:#e67e22;color:#fff}
799
- .bdp-article{padding:14px 12px 30px;max-width:720px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
800
- @media(min-width:768px){.bdp-article{padding:20px 16px 50px}}
801
- .bdp-article-title{font-size:21px;font-weight:800;color:#f0f0f0;line-height:1.3;margin:0 0 8px}
802
- @media(min-width:768px){.bdp-article-title{font-size:27px}}
803
- .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}
804
- .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}
805
- .bdp-share-article-btn:hover{opacity:.85}
806
- .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}
807
- .bdp-article-p{font-size:15.5px;line-height:1.75;color:#ccc;margin:0 0 12px}
808
- @media(min-width:768px){.bdp-article-p{font-size:16.5px}}
809
- .bdp-article-h2{font-size:19px;font-weight:700;color:#eee;margin:24px 0 10px}
810
- .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}
811
- .bdp-figure{margin:14px 0;text-align:center}
812
- .bdp-figure img{max-width:100%;height:auto;border-radius:8px}
813
- .bdp-figcap{color:#666;font-size:11.5px;margin-top:4px;font-style:italic}
814
- .bdp-video-wrap{margin:14px 0;border-radius:10px;overflow:hidden;background:#000}
815
- .bdp-video{width:100%;max-height:70vh;display:block;border-radius:10px}
816
- .bdp-related{margin-top:24px;padding-top:14px;border-top:1px solid #2a2a2a}
817
- .bdp-related h3{font-size:16px;color:#eee;margin:0 0 8px}
818
- .bdp-related-item{padding:8px 10px;margin-bottom:5px;border:1px solid #262626;border-radius:8px;cursor:pointer;transition:background .15s}
819
- .bdp-related-item:hover{background:#222}
820
- .bdp-related-item span{font-size:13.5px;color:#5cb87a;font-weight:500}
821
- .bdp-comments{margin-top:28px;padding-top:16px;border-top:1px solid #2a2a2a}
822
- .bdp-comments h3{font-size:16px;color:#eee;margin:0 0 10px}
823
- .bdp-cmt-item{background:#1a1a1a;border:1px solid #262626;border-radius:8px;padding:10px 12px;margin-bottom:8px}
824
- .bdp-cmt-author{font-weight:700;color:#5cb87a;font-size:13px}
825
- .bdp-cmt-date{color:#555;font-size:11px;margin-left:8px}
826
- .bdp-cmt-body{color:#ccc;font-size:14px;margin-top:4px;line-height:1.5}
827
- .bdp-cmt-form{margin-top:12px}
828
- .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}
829
- .bdp-cmt-input{margin-bottom:6px}
830
- .bdp-cmt-textarea{resize:vertical}
831
- .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}
832
- .bdp-cmt-submit:hover{opacity:.85}
833
- .bdp-cmt-empty{color:#555;font-size:13px;font-style:italic;padding:8px 0}
834
- .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}
835
- .bdp-toast.show{opacity:1}
836
- .gr-group,.gr-box,.gr-panel{background:#111!important;border:none!important}
837
- .label-wrap{background:#111!important}
838
-
839
- /* ══ TikTok Fullscreen Video Feed ══ */
840
- .tiktok-fullscreen-container{width:100%;background:#000;position:relative;height:calc(100vh - 60px);max-height:900px;min-height:500px;border-radius:0}
841
- .tiktok-fullscreen-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none}
842
- .tiktok-fullscreen-feed::-webkit-scrollbar{display:none}
843
- .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}
844
- .tiktok-video{width:100%;height:100%;object-fit:contain;display:block}
845
- .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}
846
- .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}
847
- .tiktok-actions{display:flex;gap:8px}
848
- .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}
849
- .tiktok-action-btn:hover{background:rgba(255,255,255,.3)}
850
- .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)}
851
- .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}
852
- .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}
853
- .tiktok-slide.paused .tiktok-pause-icon{display:block}
854
- .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}
855
- .tiktok-slide.show-controls .tiktok-seek-controls{opacity:1;pointer-events:auto}
856
- .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}
857
- .tiktok-seek-btn:hover{opacity:1}
858
- .tiktok-seek-btn:active{transform:scale(.9)}
859
- .vslide-badge{position:absolute;top:6px;left:6px;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;z-index:2}
860
- .vslide-badge-24h{background:#e67e22;color:#fff}
861
-
862
- /* ══ Category Icon Grid ══ */
863
- .cat-grid-wrap{padding:6px 8px 2px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;background:#111}
864
- .cat-grid-wrap::-webkit-scrollbar{display:none}
865
- .cat-grid{display:flex;gap:6px;min-width:max-content}
866
- .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}
867
- .cat-icon-btn:hover{background:#252525;transform:scale(1.04)}
868
- .cat-icon-btn:active{transform:scale(.95)}
869
- .cat-icon-btn.active{background:#1a3a2a;border-color:#5cb87a}
870
- .cat-icon-emoji{font-size:22px;line-height:1.2}
871
- .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}
872
- .cat-icon-btn.active .cat-icon-label{color:#5cb87a;font-weight:700}
873
- @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}}
874
- """
875
- HEAD_META = """
876
- <meta name="description" content="Tin tức tổng hợp nhanh nhất - VnExpress, BongDaPlus, 24h">
877
- <meta property="og:title" content="Tin Tức Việt Nam - Tổng Hợp">
878
- <meta property="og:description" content="Thời sự, thế giới, kinh doanh, công nghệ, thể thao, giải trí.">
879
- <meta property="og:type" content="website">
880
- <meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
881
- <meta name="twitter:card" content="summary_large_image">
882
- <link rel="canonical" href="https://bep40-bongdaplus-news.hf.space">
883
- """
884
-
885
- JS_FUNC = """
886
- function() {
887
- var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js';document.head.appendChild(s);
888
- var SPACE_BASE='""" + SPACE_URL + """';
889
-
890
- window.bdpOpen=function(url,aid,sl){
891
- window.location.hash='#/'+sl+'/'+aid;
892
- try{localStorage.setItem('bdp_url_'+aid,url);}catch(e){}
893
- var el=document.getElementById('article-url-input');
894
- if(el){var ta=el.querySelector('textarea');if(ta){ta.value=url;ta.dispatchEvent(new Event('input',{bubbles:true}));}}
895
- var btn=document.getElementById('btn-read-article');
896
- if(btn){var b=btn.querySelector('button');if(b)b.click();else btn.click();}
897
- window.scrollTo({top:0,behavior:'smooth'});
898
- };
899
-
900
- window.bdpShareHash=async function(title,sl,aid){
901
- var hashUrl=SPACE_BASE+'/#/'+sl+'/'+aid;
902
- var sd={title:title,url:hashUrl,text:title};
903
- if(navigator.share){try{await navigator.share(sd);return;}catch(e){if(e.name==='AbortError')return;}}
904
- if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(hashUrl);window.bdpToast('Đã sao chép liên kết!');return;}catch(e){}}
905
- var ta=document.createElement('textarea');ta.value=hashUrl;ta.style.cssText='position:fixed;opacity:0';
906
- document.body.appendChild(ta);ta.focus();ta.select();
907
- try{document.execCommand('copy');window.bdpToast('Đã sao chép liên kết!');}catch(e){window.prompt('Sao chép:',hashUrl);}
908
- document.body.removeChild(ta);
909
- };
910
-
911
- window.bdpShareTikTok=async function(title,sl,vid){
912
- var hashUrl=SPACE_BASE+'/#/v/'+sl+'/'+vid;
913
- var sd={title:title,url:hashUrl,text:title};
914
- if(navigator.share){try{await navigator.share(sd);return;}catch(e){if(e.name==='AbortError')return;}}
915
- if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(hashUrl);window.bdpToast('Đã sao chép liên kết!');return;}catch(e){}}
916
- var ta=document.createElement('textarea');ta.value=hashUrl;ta.style.cssText='position:fixed;opacity:0';
917
- document.body.appendChild(ta);ta.focus();ta.select();
918
- try{document.execCommand('copy');window.bdpToast('Đã sao chép liên kết!');}catch(e){window.prompt('Sao chép:',hashUrl);}
919
- document.body.removeChild(ta);
920
- };
921
-
922
- 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);};
923
-
924
- window.bdpSlideScroll=function(dir,trackId){
925
- var track=document.getElementById(trackId||'vslide-track');
926
- if(track){track.scrollBy({left:dir*260,behavior:'smooth'});}
927
- };
928
-
929
- /* ══ Category Icon Grid ══ */
930
- window.bdpSelectCat=function(catKey,hashSlug){
931
- window.location.hash='#cat/'+hashSlug;
932
- /* Update active state visually */
933
- document.querySelectorAll('.cat-icon-btn').forEach(function(b){
934
- b.classList.toggle('active',b.getAttribute('data-cat')===catKey);
935
- });
936
- /* Trigger server-side category switch via hidden textbox+button */
937
- window._bdpSetCat(catKey);
938
- };
939
-
940
- window._bdpSetCat=function(catKey){
941
- var el=document.getElementById('cat-input');
942
- if(el){var ta=el.querySelector('textarea')||el.querySelector('input');if(ta){ta.value=catKey;ta.dispatchEvent(new Event('input',{bubbles:true}));}}
943
- var btn=document.getElementById('btn-switch-cat');
944
- if(btn){var b=btn.querySelector('button');if(b)b.click();else btn.click();}
945
- window.scrollTo({top:0,behavior:'smooth'});
946
- };
947
-
948
- function gc(a){try{return JSON.parse(localStorage.getItem('bdp_cmt_'+a))||[];}catch(e){return[];}}
949
- function sc(a,c){try{localStorage.setItem('bdp_cmt_'+a,JSON.stringify(c));}catch(e){}}
950
- 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;};
951
- 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!');};
952
-
953
- function initHlsVideo(v){
954
- if(v._hlsInit) return; v._hlsInit=true;
955
- var src=v.getAttribute('data-hls-src'); if(!src) return;
956
- if(v.canPlayType('application/vnd.apple.mpegURL')){v.src=src;}
957
- 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;}});}
958
- else{v.src=src;}
959
- }
960
-
961
- /* ══ TikTok Fullscreen Feed ══ */
962
- window.bdpSeek=function(btn,sec){
963
- var slide=btn.closest('.tiktok-slide');
964
- if(!slide) return;
965
- var v=slide.querySelector('.tiktok-video');
966
- if(!v) return;
967
- v.currentTime=Math.max(0,Math.min(v.duration||9999,v.currentTime+sec));
968
- };
969
-
970
- window.bdpOpenTikTok=function(url,aid){
971
- /* Switch to Video tab and scroll TikTok feed to matching video */
972
- try{localStorage.setItem('bdp_url_'+aid,url);}catch(e){}
973
- window.location.hash='#cat/video';
974
- /* Update icon grid active state */
975
- document.querySelectorAll('.cat-icon-btn').forEach(function(b){
976
- b.classList.toggle('active',b.getAttribute('data-hash')==='video');
977
- });
978
- /* Trigger server-side switch to Video category */
979
- var el=document.getElementById('cat-input');
980
- 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}));}}
981
- var btn=document.getElementById('btn-switch-cat');
982
- if(btn){var b=btn.querySelector('button');if(b)b.click();else btn.click();}
983
- /* After content loads, scroll to the matching video in TikTok feed */
984
- var attempts=0;
985
- var finder=setInterval(function(){
986
- attempts++;
987
- var feed=document.querySelector('.tiktok-fullscreen-feed');
988
- if(feed){
989
- clearInterval(finder);
990
- var slides=feed.querySelectorAll('.tiktok-slide');
991
- var targetIdx=-1;
992
- slides.forEach(function(sl,i){
993
- if(targetIdx<0 && sl.getAttribute('data-aid')===aid) targetIdx=i;
994
- });
995
- if(targetIdx>=0 && slides[targetIdx]){
996
- slides[targetIdx].scrollIntoView({behavior:'smooth'});
997
- }
998
- window.scrollTo({top:0,behavior:'smooth'});
999
- }
1000
- if(attempts>30) clearInterval(finder);
1001
- },300);
1002
- };
1003
- window.bdpTikTokUnmute=function(hint){
1004
- var feed=hint.closest('.tiktok-fullscreen-feed')||hint.closest('.tiktok-feed');
1005
- var slide=hint.closest('.tiktok-slide');
1006
- if(!slide) return;
1007
- var video=slide.querySelector('.tiktok-video');
1008
- if(!video) return;
1009
- video.muted=!video.muted;
1010
- var isMuted=video.muted;
1011
- hint.textContent=isMuted?'🔇 Nhấn để bật tiếng':'🔊 Đang phát tiếng';
1012
- if(feed){
1013
- feed.querySelectorAll('.tiktok-video').forEach(function(v){v.muted=isMuted;});
1014
- feed.querySelectorAll('.tiktok-unmute-hint').forEach(function(h){
1015
- h.textContent=isMuted?'🔇 Nhấn để bật tiếng':'🔊 Đang phát tiếng';
1016
- });
1017
- }
1018
- };
1019
-
1020
- function initTikTokFullscreen(container){
1021
- if(container._tikInit) return;
1022
- container._tikInit=true;
1023
- var feed=container.querySelector('.tiktok-fullscreen-feed');
1024
- if(!feed) return;
1025
- var slides=feed.querySelectorAll('.tiktok-slide');
1026
- if(!slides.length) return;
1027
-
1028
- /* Init all HLS videos first */
1029
- slides.forEach(function(sl){
1030
- var v=sl.querySelector('video[data-hls-src]');
1031
- if(v) initHlsVideo(v);
1032
- var v2=sl.querySelector('video:not([data-hls-src])');
1033
- if(v2 && !v2._initDone){v2._initDone=true;v2.load();}
1034
- });
1035
-
1036
- var currentIdx=-1;
1037
-
1038
- function tryPlay(v){
1039
- var p=v.play();
1040
- if(p&&p.catch) p.catch(function(){setTimeout(function(){v.play().catch(function(){});},500);});
1041
- }
1042
-
1043
- function activateSlide(idx){
1044
- if(idx===currentIdx) return;
1045
- slides.forEach(function(sl,i){
1046
- var v=sl.querySelector('.tiktok-video');
1047
- if(!v) return;
1048
- if(i===idx){
1049
- v.currentTime=0;
1050
- tryPlay(v);
1051
- sl.classList.remove('paused');
1052
- } else {
1053
- v.pause();
1054
- sl.classList.remove('paused');
1055
- }
1056
- });
1057
- currentIdx=idx;
1058
- }
1059
-
1060
- /* Use scroll event instead of IntersectionObserver for reliability inside Gradio */
1061
- var scrollTimer=null;
1062
- feed.addEventListener('scroll',function(){
1063
- if(scrollTimer) clearTimeout(scrollTimer);
1064
- scrollTimer=setTimeout(function(){
1065
- var feedRect=feed.getBoundingClientRect();
1066
- var feedCenter=feedRect.top+feedRect.height/2;
1067
- var best=-1,bestDist=99999;
1068
- slides.forEach(function(sl,i){
1069
- var r=sl.getBoundingClientRect();
1070
- var center=r.top+r.height/2;
1071
- var dist=Math.abs(center-feedCenter);
1072
- if(dist<bestDist){bestDist=dist;best=i;}
1073
- });
1074
- if(best>=0) activateSlide(best);
1075
- },150);
1076
- });
1077
-
1078
- /* Start first video after short delay for HLS init */
1079
- setTimeout(function(){activateSlide(0);},800);
1080
-
1081
- /* Tap to pause/play + show/hide seek controls */
1082
- slides.forEach(function(sl){
1083
- var v=sl.querySelector('.tiktok-video');
1084
- var hideTimer=null;
1085
- function showSeekControls(){
1086
- sl.classList.add('show-controls');
1087
- if(hideTimer) clearTimeout(hideTimer);
1088
- hideTimer=setTimeout(function(){sl.classList.remove('show-controls');},3000);
1089
- }
1090
- if(v){
1091
- v.addEventListener('click',function(e){
1092
- e.preventDefault();
1093
- if(v.paused){tryPlay(v);sl.classList.remove('paused');}
1094
- else{v.pause();sl.classList.add('paused');}
1095
- showSeekControls();
1096
- });
1097
- v.addEventListener('touchstart',function(){showSeekControls();},{passive:true});
1098
- }
1099
- });
1100
- }
1101
-
1102
- new MutationObserver(function(muts){muts.forEach(function(m){m.addedNodes.forEach(function(n){
1103
- if(!n.querySelectorAll) return;
1104
- n.querySelectorAll('img.bdp-lazy-img').forEach(function(img){if(!img._errBound){img._errBound=true;img.addEventListener('error',function(){this.style.display='none';});}});
1105
- n.querySelectorAll('video[data-hls-src]').forEach(initHlsVideo);
1106
- n.querySelectorAll('video.bdp-video:not([data-hls-src])').forEach(function(v){if(!v._initDone){v._initDone=true;v.load();}});
1107
- n.querySelectorAll('[id^="cmt-list-"]').forEach(function(d){window.bdpRenderCmt(d.id.replace('cmt-list-',''));});
1108
- n.querySelectorAll('.tiktok-fullscreen-container').forEach(initTikTokFullscreen);
1109
- if(n.classList && n.classList.contains('tiktok-fullscreen-container')) initTikTokFullscreen(n);
1110
- });});}).observe(document.body,{childList:true,subtree:true});
1111
-
1112
- setInterval(function(){
1113
- document.querySelectorAll('video[data-hls-src]').forEach(initHlsVideo);
1114
- document.querySelectorAll('video.bdp-video:not([data-hls-src])').forEach(function(v){if(!v._initDone){v._initDone=true;v.load();}});
1115
- document.querySelectorAll('.tiktok-fullscreen-container').forEach(initTikTokFullscreen);
1116
- },1500);
1117
-
1118
- var hh=window.location.hash;
1119
- if(hh&&hh.startsWith('#/v/')){
1120
- /* TikTok video hash: #/v/slug/vid - switch to video page then scroll to vid */
1121
- var vps=hh.slice(4).split('/');
1122
- var vid=vps[vps.length-1];
1123
- setTimeout(function(){
1124
- /* Switch to Video category */
1125
- window._bdpSetCat('\U0001f3ac Video T\u1ed5ng H\u1ee3p');
1126
- document.querySelectorAll('.cat-icon-btn').forEach(function(b){
1127
- b.classList.toggle('active',b.getAttribute('data-hash')==='video');
1128
- });
1129
- /* Poll for TikTok feed to appear then scroll to matching vid */
1130
- var att=0;
1131
- var ff=setInterval(function(){
1132
- att++;
1133
- var feed=document.querySelector('.tiktok-fullscreen-feed');
1134
- if(feed){
1135
- clearInterval(ff);
1136
- var slides=feed.querySelectorAll('.tiktok-slide');
1137
- slides.forEach(function(sl,i){
1138
- if(sl.getAttribute('data-vid')===vid){
1139
- sl.scrollIntoView({behavior:'smooth'});
1140
- }
1141
- });
1142
- window.scrollTo({top:0,behavior:'smooth'});
1143
- }
1144
- if(att>30) clearInterval(ff);
1145
- },300);
1146
- },1500);
1147
- } else if(hh&&hh.startsWith('#cat/')){
1148
- /* Category hash: #cat/video, #cat/thoi-su, etc. */
1149
- var catSlug=hh.slice(5);
1150
- var catMap={""" + ",".join(f"'{ci[2]}':'{esc(CAT_KEYS[i])}'" for i,ci in enumerate(CAT_ICONS)) + """};
1151
- if(catMap[catSlug]){
1152
- setTimeout(function(){
1153
- window._bdpSetCat(catMap[catSlug]);
1154
- document.querySelectorAll('.cat-icon-btn').forEach(function(b){
1155
- b.classList.toggle('active',b.getAttribute('data-hash')===catSlug);
1156
- });
1157
- },1500);
1158
- }
1159
- } 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){}}}
1160
- }
1161
- """
1162
-
1163
- # ══════════════════════════════════════════════════════════════════════════════
1164
- def _build_cat_grid_html():
1165
- """Build the category icon grid HTML."""
1166
- items = []
1167
- for i, (icon, label, hslug) in enumerate(CAT_ICONS):
1168
- cat_key = CAT_KEYS[i]
1169
- active = " active" if i == 0 else ""
1170
- click_js = f"window.bdpSelectCat('{esc(cat_key)}','{hslug}')"
1171
- 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>')
1172
- return f'<div class="cat-grid-wrap"><div class="cat-grid">{"".join(items)}</div></div>'
1173
-
1174
- 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:
1175
- 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>')
1176
- gr.HTML(_build_cat_grid_html())
1177
- article_url=gr.Textbox(value="",visible=False,elem_id="article-url-input")
1178
- cat_input=gr.Textbox(value="",visible=False,elem_id="cat-input")
1179
- back_btn=gr.Button("← Quay lại",variant="secondary",visible=False)
1180
- news_list=gr.HTML()
1181
- article_view=gr.HTML(visible=False)
1182
- read_btn=gr.Button("Đọc",visible=False,elem_id="btn-read-article")
1183
- cat_btn=gr.Button("Cat",visible=False,elem_id="btn-switch-cat")
1184
- def show_article(url):
1185
- if not url or url=="#" or len(url)<10:
1186
- return gr.update(visible=True),gr.update(visible=False),gr.update(visible=False),""
1187
- return (gr.update(visible=False),gr.update(value=read_article(url),visible=True),gr.update(visible=True),"")
1188
- def switch_cat(cat_key):
1189
- cat_key=cat_key.strip()
1190
- if not cat_key or cat_key not in CATEGORIES:
1191
- cat_key=list(CATEGORIES.keys())[0]
1192
- return (gr.update(value=fetch_news_list(cat_key),visible=True),gr.update(visible=False),gr.update(visible=False),"")
1193
- def show_list_home():
1194
- return (gr.update(value=fetch_news_list(list(CATEGORIES.keys())[0]),visible=True),gr.update(visible=False),gr.update(visible=False))
1195
- read_btn.click(fn=show_article,inputs=[article_url],outputs=[news_list,article_view,back_btn,article_url])
1196
- cat_btn.click(fn=switch_cat,inputs=[cat_input],outputs=[news_list,article_view,back_btn,cat_input])
1197
- back_btn.click(fn=show_list_home,inputs=[],outputs=[news_list,article_view,back_btn])
1198
- timer=gr.Timer(value=REFRESH_SECONDS,active=True)
1199
- timer.tick(fn=lambda: fetch_news_list(list(CATEGORIES.keys())[0]),inputs=None,outputs=news_list)
1200
- demo.load(fn=lambda: fetch_news_list(list(CATEGORIES.keys())[0]),inputs=None,outputs=news_list)
1201
-
1202
- if __name__=="__main__":
1203
- demo.launch(ssr_mode=False)
 
1
+ """Entry point: patches main_app.py to add 24h news shorts, then runs it."""
2
+ import os, sys
3
+
4
+ APP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "main_app.py")
5
+
6
+ # Read and patch
7
+ with open(APP_PATH, "r", encoding="utf-8") as f:
8
+ code = f.read()
9
+
10
+ if "scrape_24h_news_shorts" not in code:
11
+ # 1. Add import
12
+ code = code.replace(
13
+ "from concurrent.futures import ThreadPoolExecutor, as_completed",
14
+ "from concurrent.futures import ThreadPoolExecutor, as_completed\nfrom shorts_scraper import scrape_24h_news_shorts"
15
+ )
16
+
17
+ # 2. Modify fetch_homepage
18
+ code = code.replace(
19
+ "def fetch_homepage():\n all_articles=[]\n h24_videos=[]\n",
20
+ "def fetch_homepage():\n all_articles=[]\n h24_videos=[]\n h24_shorts=[]\n"
21
+ )
22
+ code = code.replace(
23
+ " with ThreadPoolExecutor(max_workers=6) as ex:\n ex.submit(_fetch_24h)",
24
+ " def _fetch_shorts():\n nonlocal h24_shorts\n try: h24_shorts=scrape_24h_news_shorts()[:15]\n except: pass\n with ThreadPoolExecutor(max_workers=7) as ex:\n ex.submit(_fetch_24h)\n ex.submit(_fetch_shorts)"
25
+ )
26
+ code = code.replace(
27
+ " return all_articles, h24_videos",
28
+ " return all_articles, h24_videos, h24_shorts"
29
+ )
30
+
31
+ # 3. Update fetch_news_list
32
+ code = code.replace(
33
+ " articles, h24_videos = fetch_homepage()\n return render_homepage_html(articles, h24_videos)",
34
+ " articles, h24_videos, h24_shorts = fetch_homepage()\n return render_homepage_html(articles, h24_videos, h24_shorts)"
35
+ )
36
+
37
+ # 4. Update render_homepage_html signature and body
38
+ code = code.replace(
39
+ "def render_homepage_html(articles, h24_videos=None):",
40
+ "def render_homepage_html(articles, h24_videos=None, h24_shorts=None):"
41
+ )
42
+ code = code.replace(
43
+ " video_carousel = render_video_carousel_html(h24_videos or [])\n news_carousel = render_featured_carousel_html(articles)",
44
+ " shorts_carousel = render_shorts_carousel_html(h24_shorts or [])\n video_carousel = render_video_carousel_html(h24_videos or [])\n news_carousel = render_featured_carousel_html(articles)"
45
+ )
46
+ code = code.replace(
47
+ "{video_carousel}{news_carousel}",
48
+ "{shorts_carousel}{video_carousel}{news_carousel}"
49
+ )
50
+
51
+ # 5. Add render_shorts_carousel_html function
52
+ shorts_fn = '''def render_shorts_carousel_html(shorts):
53
+ """Carousel shorts tin tuc 24h - slide ngang, click vao xem TikTok."""
54
+ vids_with_img = [v for v in shorts if v.get("img")]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  if not vids_with_img: return ""
56
  items = []
57
  for i, v in enumerate(vids_with_img[:15]):
 
59
  link = v.get("link","#")
60
  title = v.get("title","")
61
  aid = make_id(link); sl = slug(title)
62
+ click_js = f"window.bdpOpenTikTok(\\'{esc(link)}\\',\\'{aid}\\')"
63
+ items.append(f\\'\\'\\'<div class="vslide-item" onclick="{click_js}">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  <div class="vslide-thumb"><img src="{img}" alt="" class="bdp-lazy-img">
65
+ <div class="vslide-play">\\u25b6</div><span class="vslide-badge vslide-badge-shorts">Shorts</span></div>
66
+ <p class="vslide-title">{title}</p></div>\\'\\'\\'\\')
67
+ return f\\'\\'\\'<div class="vslide-wrap">
68
+ <div class="vslide-header"><span class="vslide-label">\\U0001f4f1 Shorts Tin T\\u1ee9c</span>
69
+ <div class="vslide-nav"><button class="vslide-btn" onclick="window.bdpSlideScroll(-1,\\'vslide-shorts\\')">\\u25c0</button>
70
+ <button class="vslide-btn" onclick="window.bdpSlideScroll(1,\\'vslide-shorts\\')">\\u25b6</button></div></div>
71
+ <div class="vslide-track" id="vslide-shorts">{\\'\\'.join(items)}</div></div>\\'\\'\\'
72
+
73
+ '''
74
+ code = code.replace(
75
+ "def render_video_carousel_html(videos):",
76
+ shorts_fn + "def render_video_carousel_html(videos):"
77
+ )
78
+
79
+ # 6. Add CSS for shorts badge
80
+ code = code.replace(
81
+ ".vslide-badge-24h{background:#e67e22;color:#fff}",
82
+ ".vslide-badge-24h{background:#e67e22;color:#fff}\n.vslide-badge-shorts{background:#ff0050;color:#fff}"
83
+ )
84
+
85
+ # Write patched version back
86
+ with open(APP_PATH, "w", encoding="utf-8") as f:
87
+ f.write(code)
88
+ print("[patcher] Successfully patched main_app.py with shorts carousel")
89
+ else:
90
+ print("[patcher] Already patched, skipping")
91
+
92
+ # Now execute the patched app
93
+ exec(compile(open(APP_PATH, encoding="utf-8").read(), APP_PATH, "exec"), {"__name__": "__main__", "__file__": APP_PATH})