feat: 24h multi-part videos, slider->TikTok, seek buttons, grid->TikTok
Browse files
app.py
CHANGED
|
@@ -265,19 +265,35 @@ def scrape_24h_article(url):
|
|
| 265 |
except Exception as e:
|
| 266 |
return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"24h","og_image":""}
|
| 267 |
|
| 268 |
-
def
|
| 269 |
-
"""
|
|
|
|
| 270 |
try:
|
| 271 |
r = requests.get(article_url, headers=HEADERS, timeout=10); r.encoding="utf-8"
|
| 272 |
m3u8s = re.findall(r'(https?://cdn\.24h\.com\.vn/[^\s"\'\\]+\.m3u8)', r.text)
|
| 273 |
-
|
| 274 |
-
if not
|
| 275 |
-
if not videos: return None
|
| 276 |
soup = BeautifulSoup(r.text, "lxml")
|
| 277 |
og = soup.find("meta", property="og:image")
|
| 278 |
poster = og.get("content","") if og else ""
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
# ── VNE Scrapers ───────────────────────────────────────────────────────────────
|
| 283 |
def scrape_vne_list(url):
|
|
@@ -436,10 +452,14 @@ def fetch_tiktok_feed_videos():
|
|
| 436 |
"vtype": "mp4", "source": "bdp", "link": art["link"]}
|
| 437 |
|
| 438 |
def _fetch_24h_vid(art):
|
| 439 |
-
|
| 440 |
-
if not
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
|
| 444 |
# Get video lists
|
| 445 |
bdp_list = scrape_bdp_video_list()[:12]
|
|
@@ -457,7 +477,9 @@ def fetch_tiktok_feed_videos():
|
|
| 457 |
for f in as_completed(h24_futures):
|
| 458 |
try:
|
| 459 |
r = f.result()
|
| 460 |
-
if r:
|
|
|
|
|
|
|
| 461 |
except: pass
|
| 462 |
|
| 463 |
# Interleave BDP and 24h
|
|
@@ -512,7 +534,7 @@ def render_video_slider_html(videos):
|
|
| 512 |
title = v.get("title", "")
|
| 513 |
aid = make_id(link)
|
| 514 |
sl = slug(title) if title else f"video-{i}"
|
| 515 |
-
click_js = f"window.
|
| 516 |
src_badge = ""
|
| 517 |
if v.get("source") == "24h":
|
| 518 |
src_badge = '<span class="vslide-badge vslide-badge-24h">24h</span>'
|
|
@@ -575,9 +597,13 @@ def render_video_page_html():
|
|
| 575 |
else:
|
| 576 |
video_tag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} muted loop><source src="{safe_url(vsrc)}" type="video/mp4"></video>'
|
| 577 |
|
| 578 |
-
slides.append(f'''<div class="tiktok-slide" data-index="{vi}">
|
| 579 |
{video_tag}
|
| 580 |
<div class="tiktok-pause-icon">▶</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
<div class="tiktok-bottom">
|
| 582 |
<span class="bdp-badge {badge_cls}">{src_label}</span>
|
| 583 |
<p class="tiktok-title">{v.get("title","")}</p>
|
|
@@ -603,11 +629,11 @@ def render_video_page_html():
|
|
| 603 |
grid_parts.append(f'<div class="bdp-topbar"><span>⏱ {now}</span><span>📰 {len(articles)} video · BDP + 24h</span></div>')
|
| 604 |
if feat:
|
| 605 |
grid_parts.append('<div class="bdp-grid bdp-grid-featured">')
|
| 606 |
-
for a in feat[:6]: grid_parts.append(_list_card(a,True))
|
| 607 |
grid_parts.append('</div>')
|
| 608 |
if reg:
|
| 609 |
grid_parts.append('<div class="bdp-grid">')
|
| 610 |
-
for a in reg[:40]: grid_parts.append(_list_card(a,False))
|
| 611 |
grid_parts.append('</div>')
|
| 612 |
grid_parts.append('</div>')
|
| 613 |
grid_html = '\n'.join(grid_parts)
|
|
@@ -631,7 +657,7 @@ def render_list_html(articles,group_name=""):
|
|
| 631 |
parts.append('</div>')
|
| 632 |
return '\n'.join(parts)
|
| 633 |
|
| 634 |
-
def _list_card(art,big):
|
| 635 |
img_html=""
|
| 636 |
if art.get("img"):
|
| 637 |
c="bdp-card-img bdp-card-img-big" if big else "bdp-card-img"
|
|
@@ -648,7 +674,10 @@ def _list_card(art,big):
|
|
| 648 |
elif art.get("source")=="bdp": badge=f'<span class="bdp-badge bdp-badge-bdp">{grp or "BongDaPlus"}</span>'
|
| 649 |
sl=slug(art["title"])
|
| 650 |
share_js=f"event.stopPropagation();window.bdpShareHash('{esc(art['title'])}','{sl}','{aid}')"
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
| 652 |
return f"""<div class="bdp-card" onclick="{click_js}">
|
| 653 |
{img_html}<div class="bdp-card-body">{badge}<h3 class="{tc}">{art['title']}</h3>
|
| 654 |
{summ_html}<div class="bdp-card-footer">{time_html}
|
|
@@ -826,6 +855,10 @@ footer,.built-with{display:none!important}
|
|
| 826 |
.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}
|
| 827 |
.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}
|
| 828 |
.tiktok-slide.paused .tiktok-pause-icon{display:block}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 829 |
.vslide-badge{position:absolute;top:6px;left:6px;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;z-index:2}
|
| 830 |
.vslide-badge-24h{background:#e67e22;color:#fff}
|
| 831 |
"""
|
|
@@ -888,6 +921,51 @@ function initHlsVideo(v){
|
|
| 888 |
}
|
| 889 |
|
| 890 |
/* ══ TikTok Fullscreen Feed ══ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
window.bdpTikTokUnmute=function(hint){
|
| 892 |
var feed=hint.closest('.tiktok-fullscreen-feed')||hint.closest('.tiktok-feed');
|
| 893 |
var slide=hint.closest('.tiktok-slide');
|
|
|
|
| 265 |
except Exception as e:
|
| 266 |
return {"title":"⚠️ Lỗi","time":"","summary":str(e),"body":[],"related":[],"source_url":url,"source":"24h","og_image":""}
|
| 267 |
|
| 268 |
+
def _extract_24h_video_urls(article_url):
|
| 269 |
+
"""Extract ALL m3u8 URLs (multiple halves) + poster from a 24h article.
|
| 270 |
+
Returns list of {src, poster, vtype} or empty list."""
|
| 271 |
try:
|
| 272 |
r = requests.get(article_url, headers=HEADERS, timeout=10); r.encoding="utf-8"
|
| 273 |
m3u8s = re.findall(r'(https?://cdn\.24h\.com\.vn/[^\s"\'\\]+\.m3u8)', r.text)
|
| 274 |
+
full = list(dict.fromkeys(u for u in m3u8s if '_720p' not in u))
|
| 275 |
+
if not full: return []
|
|
|
|
| 276 |
soup = BeautifulSoup(r.text, "lxml")
|
| 277 |
og = soup.find("meta", property="og:image")
|
| 278 |
poster = og.get("content","") if og else ""
|
| 279 |
+
results = [{"src": full[0], "poster": poster, "vtype": "hls"}]
|
| 280 |
+
# Probe for numbered parts: {base}01.m3u8 → {base}02.m3u8, 03, etc.
|
| 281 |
+
base_match = re.match(r'(.+?)(\d{2})(\.m3u8)$', full[0])
|
| 282 |
+
if base_match:
|
| 283 |
+
base, ext = base_match.group(1), base_match.group(3)
|
| 284 |
+
for i in range(2, 10):
|
| 285 |
+
part_url = f"{base}{i:02d}{ext}"
|
| 286 |
+
if part_url in full:
|
| 287 |
+
results.append({"src": part_url, "poster": poster, "vtype": "hls"})
|
| 288 |
+
continue
|
| 289 |
+
try:
|
| 290 |
+
tr = requests.head(part_url, headers=HEADERS, timeout=3)
|
| 291 |
+
if tr.status_code == 200:
|
| 292 |
+
results.append({"src": part_url, "poster": poster, "vtype": "hls"})
|
| 293 |
+
else: break
|
| 294 |
+
except: break
|
| 295 |
+
return results
|
| 296 |
+
except: return []
|
| 297 |
|
| 298 |
# ── VNE Scrapers ───────────────────────────────────────────────────────────────
|
| 299 |
def scrape_vne_list(url):
|
|
|
|
| 452 |
"vtype": "mp4", "source": "bdp", "link": art["link"]}
|
| 453 |
|
| 454 |
def _fetch_24h_vid(art):
|
| 455 |
+
vids = _extract_24h_video_urls(art["link"])
|
| 456 |
+
if not vids: return []
|
| 457 |
+
results = []
|
| 458 |
+
for pi, v in enumerate(vids):
|
| 459 |
+
label = f" (P{pi+1})" if len(vids)>1 else ""
|
| 460 |
+
results.append({"title": art["title"]+label, "src": v["src"], "poster": v["poster"],
|
| 461 |
+
"vtype": v["vtype"], "source": "24h", "link": art["link"]})
|
| 462 |
+
return results
|
| 463 |
|
| 464 |
# Get video lists
|
| 465 |
bdp_list = scrape_bdp_video_list()[:12]
|
|
|
|
| 477 |
for f in as_completed(h24_futures):
|
| 478 |
try:
|
| 479 |
r = f.result()
|
| 480 |
+
if r:
|
| 481 |
+
if isinstance(r, list): h24_results.extend(r)
|
| 482 |
+
else: h24_results.append(r)
|
| 483 |
except: pass
|
| 484 |
|
| 485 |
# Interleave BDP and 24h
|
|
|
|
| 534 |
title = v.get("title", "")
|
| 535 |
aid = make_id(link)
|
| 536 |
sl = slug(title) if title else f"video-{i}"
|
| 537 |
+
click_js = f"window.bdpOpenTikTok('{esc(link)}','{aid}')"
|
| 538 |
src_badge = ""
|
| 539 |
if v.get("source") == "24h":
|
| 540 |
src_badge = '<span class="vslide-badge vslide-badge-24h">24h</span>'
|
|
|
|
| 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}">
|
| 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>
|
|
|
|
| 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)
|
|
|
|
| 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"
|
|
|
|
| 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}
|
|
|
|
| 855 |
.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}
|
| 856 |
.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}
|
| 857 |
.tiktok-slide.paused .tiktok-pause-icon{display:block}
|
| 858 |
+
.tiktok-seek-controls{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);display:flex;gap:40px;z-index:6;pointer-events:auto}
|
| 859 |
+
.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;opacity:.7;transition:opacity .2s}
|
| 860 |
+
.tiktok-seek-btn:hover{opacity:1}
|
| 861 |
+
.tiktok-seek-btn:active{transform:scale(.9)}
|
| 862 |
.vslide-badge{position:absolute;top:6px;left:6px;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:700;z-index:2}
|
| 863 |
.vslide-badge-24h{background:#e67e22;color:#fff}
|
| 864 |
"""
|
|
|
|
| 921 |
}
|
| 922 |
|
| 923 |
/* ══ TikTok Fullscreen Feed ══ */
|
| 924 |
+
window.bdpSeek=function(btn,sec){
|
| 925 |
+
var slide=btn.closest('.tiktok-slide');
|
| 926 |
+
if(!slide) return;
|
| 927 |
+
var v=slide.querySelector('.tiktok-video');
|
| 928 |
+
if(!v) return;
|
| 929 |
+
v.currentTime=Math.max(0,Math.min(v.duration||9999,v.currentTime+sec));
|
| 930 |
+
};
|
| 931 |
+
|
| 932 |
+
window.bdpOpenTikTok=function(url,aid){
|
| 933 |
+
/* Switch to Video tab and scroll TikTok feed to matching video */
|
| 934 |
+
try{localStorage.setItem('bdp_url_'+aid,url);}catch(e){}
|
| 935 |
+
/* Find the dropdown and switch to Video Tổng Hợp */
|
| 936 |
+
var dd=document.querySelector('.controls-row select, .controls-row input[role="listbox"]');
|
| 937 |
+
if(!dd){
|
| 938 |
+
/* Gradio dropdown: find the actual element */
|
| 939 |
+
var labels=document.querySelectorAll('.controls-row .wrap');
|
| 940 |
+
labels.forEach(function(l){
|
| 941 |
+
var inp=l.querySelector('input');
|
| 942 |
+
if(inp){
|
| 943 |
+
inp.value='🎬 Video Tổng Hợp';
|
| 944 |
+
inp.dispatchEvent(new Event('input',{bubbles:true}));
|
| 945 |
+
/* Also try clicking the option */
|
| 946 |
+
setTimeout(function(){
|
| 947 |
+
var opts=document.querySelectorAll('[role="option"]');
|
| 948 |
+
opts.forEach(function(o){if(o.textContent.indexOf('Video')>-1)o.click();});
|
| 949 |
+
},200);
|
| 950 |
+
}
|
| 951 |
+
});
|
| 952 |
+
}
|
| 953 |
+
/* After switching, scroll to the matching video in TikTok feed */
|
| 954 |
+
setTimeout(function(){
|
| 955 |
+
var feed=document.querySelector('.tiktok-fullscreen-feed');
|
| 956 |
+
if(!feed) return;
|
| 957 |
+
/* Find slide with matching link */
|
| 958 |
+
var slides=feed.querySelectorAll('.tiktok-slide');
|
| 959 |
+
var targetIdx=-1;
|
| 960 |
+
slides.forEach(function(sl,i){
|
| 961 |
+
if(sl.getAttribute('data-aid')===aid) targetIdx=i;
|
| 962 |
+
});
|
| 963 |
+
if(targetIdx>=0 && slides[targetIdx]){
|
| 964 |
+
slides[targetIdx].scrollIntoView({behavior:'smooth'});
|
| 965 |
+
}
|
| 966 |
+
window.scrollTo({top:0,behavior:'smooth'});
|
| 967 |
+
},1500);
|
| 968 |
+
};
|
| 969 |
window.bdpTikTokUnmute=function(hint){
|
| 970 |
var feed=hint.closest('.tiktok-fullscreen-feed')||hint.closest('.tiktok-feed');
|
| 971 |
var slide=hint.closest('.tiktok-slide');
|