Spaces:
Runtime error
Runtime error
fix: regex for multi-part probe - use greedy (.+) to match last 2 digits before .m3u8
Browse files- shorts_carousel.py +39 -99
shorts_carousel.py
CHANGED
|
@@ -61,51 +61,57 @@ def scrape_24h_news_shorts():
|
|
| 61 |
|
| 62 |
|
| 63 |
def _extract_24h_video_url(article_url):
|
| 64 |
-
"""Extract m3u8 video URL(s) from a 24h article.
|
| 65 |
-
|
| 66 |
try:
|
| 67 |
r = requests.get(article_url, headers=HEADERS, timeout=10)
|
| 68 |
r.encoding = "utf-8"
|
| 69 |
html = r.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
if not full_quality:
|
| 76 |
return None
|
| 77 |
-
|
| 78 |
# Get poster
|
| 79 |
soup = BeautifulSoup(html, "lxml")
|
| 80 |
og = soup.find("meta", property="og:image")
|
| 81 |
poster = og.get("content", "") if og else ""
|
| 82 |
-
|
| 83 |
-
#
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
#
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
# Search for all CDN video URLs (including escaped ones)
|
| 91 |
-
escaped_m3u8 = re.findall(r'(https?:\\/\\/cdn\.24h\.com\.vn\\/[^\s"\'\\<>]+\.m3u8)', html)
|
| 92 |
-
escaped_m3u8 = [u.replace('\\/','/' ) for u in escaped_m3u8]
|
| 93 |
-
all_found = list(dict.fromkeys(u for u in (full_quality + escaped_m3u8) if '_720p' not in u))
|
| 94 |
-
|
| 95 |
-
if len(all_found) > 1:
|
| 96 |
-
# Multiple m3u8 URLs found directly - these ARE the parts
|
| 97 |
-
return {"src": all_found[0], "poster": poster, "vtype": "hls", "all_parts": all_found}
|
| 98 |
-
|
| 99 |
-
# Method 4: Probe numbered variants
|
| 100 |
base_url = full_quality[0]
|
| 101 |
parts_found = [base_url]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
try:
|
| 110 |
pr = requests.head(probe_url, headers=HEADERS, timeout=3, allow_redirects=True)
|
| 111 |
if pr.status_code == 200:
|
|
@@ -114,77 +120,11 @@ def _extract_24h_video_url(article_url):
|
|
| 114 |
break
|
| 115 |
except:
|
| 116 |
break
|
| 117 |
-
|
| 118 |
-
# Method 5: Try common naming patterns for hiep/part
|
| 119 |
-
if len(parts_found) == 1 and playlist_count > 0:
|
| 120 |
-
# The page DOES have a playlist but we can't find URLs
|
| 121 |
-
# Try variations of the filename
|
| 122 |
-
filename = base_url.rsplit('/', 1)[-1] # e.g. "cp_special_bundesliga-1777224470-dortmund_freiburg01.m3u8"
|
| 123 |
-
dir_url = base_url.rsplit('/', 1)[0] + '/'
|
| 124 |
-
|
| 125 |
-
# Try: remove trailing digits and probe 1,2,3...
|
| 126 |
-
name_no_num = re.sub(r'\d+\.m3u8$', '', filename)
|
| 127 |
-
if name_no_num != filename.replace('.m3u8',''):
|
| 128 |
-
for i in range(1, 5):
|
| 129 |
-
probe = dir_url + name_no_num + str(i) + '.m3u8'
|
| 130 |
-
if probe not in parts_found:
|
| 131 |
-
try:
|
| 132 |
-
pr = requests.head(probe, headers=HEADERS, timeout=3, allow_redirects=True)
|
| 133 |
-
if pr.status_code == 200:
|
| 134 |
-
parts_found.append(probe)
|
| 135 |
-
except:
|
| 136 |
-
pass
|
| 137 |
-
|
| 138 |
-
# Try: append _part2, _hiep2 etc
|
| 139 |
-
base_name = base_url.replace('.m3u8','')
|
| 140 |
-
for suffix in ['_2.m3u8', '_part2.m3u8', '_hiep2.m3u8', 'b.m3u8']:
|
| 141 |
-
probe = base_name + suffix
|
| 142 |
-
try:
|
| 143 |
-
pr = requests.head(probe, headers=HEADERS, timeout=3, allow_redirects=True)
|
| 144 |
-
if pr.status_code == 200:
|
| 145 |
-
parts_found.append(probe)
|
| 146 |
-
except:
|
| 147 |
-
pass
|
| 148 |
-
|
| 149 |
-
# Method 6: Try AJAX endpoint for playlist
|
| 150 |
-
article_id_match = re.search(r'a(\d+)\.html', article_url)
|
| 151 |
-
if article_id_match and len(parts_found) == 1 and playlist_count > 0:
|
| 152 |
-
article_id = article_id_match.group(1)
|
| 153 |
-
ajax_urls = [
|
| 154 |
-
f"https://24h.24hstatic.com/ajax/video/get_playlist?id={article_id}",
|
| 155 |
-
f"{BASE_24H}/ajax/video/playlist/{article_id}",
|
| 156 |
-
]
|
| 157 |
-
for ajax_url in ajax_urls:
|
| 158 |
-
try:
|
| 159 |
-
ar = requests.get(ajax_url, headers={**HEADERS, "X-Requested-With": "XMLHttpRequest"}, timeout=5)
|
| 160 |
-
if ar.status_code == 200 and ar.text.strip():
|
| 161 |
-
# Try parse as JSON
|
| 162 |
-
try:
|
| 163 |
-
data = ar.json()
|
| 164 |
-
if isinstance(data, list):
|
| 165 |
-
for item in data:
|
| 166 |
-
if isinstance(item, dict):
|
| 167 |
-
src = item.get("file") or item.get("src") or item.get("source") or ""
|
| 168 |
-
if src and 'm3u8' in src and src not in parts_found:
|
| 169 |
-
parts_found.append(src)
|
| 170 |
-
elif isinstance(item, str) and 'm3u8' in item:
|
| 171 |
-
if item not in parts_found:
|
| 172 |
-
parts_found.append(item)
|
| 173 |
-
except:
|
| 174 |
-
# Try extract m3u8 from text
|
| 175 |
-
ajax_m3u8 = re.findall(r'(https?://[^\s"\'\\<>]+\.m3u8)', ar.text)
|
| 176 |
-
for u in ajax_m3u8:
|
| 177 |
-
if '_720p' not in u and u not in parts_found:
|
| 178 |
-
parts_found.append(u)
|
| 179 |
-
if len(parts_found) > 1:
|
| 180 |
-
break
|
| 181 |
-
except:
|
| 182 |
-
pass
|
| 183 |
|
| 184 |
if len(parts_found) > 1:
|
| 185 |
return {"src": parts_found[0], "poster": poster, "vtype": "hls", "all_parts": parts_found}
|
| 186 |
-
|
| 187 |
-
|
| 188 |
except:
|
| 189 |
return None
|
| 190 |
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def _extract_24h_video_url(article_url):
|
| 64 |
+
"""Extract m3u8 video URL(s) from a 24h article.
|
| 65 |
+
Returns dict with 'src', 'poster', 'vtype', and optionally 'all_parts' list."""
|
| 66 |
try:
|
| 67 |
r = requests.get(article_url, headers=HEADERS, timeout=10)
|
| 68 |
r.encoding = "utf-8"
|
| 69 |
html = r.text
|
| 70 |
+
|
| 71 |
+
# Find all m3u8 URLs in HTML (including escaped ones like \/)
|
| 72 |
+
raw_m3u8 = re.findall(r'(https?://cdn\.24h\.com\.vn/[^\s"\'\\<>]+\.m3u8)', html)
|
| 73 |
+
escaped_m3u8 = re.findall(r'(https?:\\/\\/cdn\.24h\.com\.vn\\/[^\s"\'<>]+\.m3u8)', html)
|
| 74 |
+
escaped_m3u8 = [u.replace('\\/', '/') for u in escaped_m3u8]
|
| 75 |
|
| 76 |
+
all_m3u8 = list(dict.fromkeys(raw_m3u8 + escaped_m3u8))
|
| 77 |
+
full_quality = [u for u in all_m3u8 if '_720p' not in u]
|
| 78 |
+
|
|
|
|
| 79 |
if not full_quality:
|
| 80 |
return None
|
| 81 |
+
|
| 82 |
# Get poster
|
| 83 |
soup = BeautifulSoup(html, "lxml")
|
| 84 |
og = soup.find("meta", property="og:image")
|
| 85 |
poster = og.get("content", "") if og else ""
|
| 86 |
+
|
| 87 |
+
# If multiple full-quality URLs found directly → those are the parts
|
| 88 |
+
if len(full_quality) > 1:
|
| 89 |
+
return {"src": full_quality[0], "poster": poster, "vtype": "hls", "all_parts": full_quality}
|
| 90 |
+
|
| 91 |
+
# Probe for numbered parts using GREEDY regex
|
| 92 |
+
# Example: ...psg_bayern01.m3u8 → base="...psg_bayern", num="01"
|
| 93 |
+
# Use GREEDY (.+) so it matches the LONGEST prefix, leaving only last digits
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
base_url = full_quality[0]
|
| 95 |
parts_found = [base_url]
|
| 96 |
+
|
| 97 |
+
# Pattern: ends with 2 digits before .m3u8 (like "01.m3u8", "02.m3u8")
|
| 98 |
+
match_2digit = re.match(r'^(.+?)(\d{2})(\.m3u8)$', base_url)
|
| 99 |
+
if not match_2digit:
|
| 100 |
+
# Try 1 digit at end
|
| 101 |
+
match_2digit = re.match(r'^(.+?)(\d)(\.m3u8)$', base_url)
|
| 102 |
|
| 103 |
+
if match_2digit:
|
| 104 |
+
base = match_2digit.group(1)
|
| 105 |
+
start_num = int(match_2digit.group(2))
|
| 106 |
+
ext = match_2digit.group(3)
|
| 107 |
+
num_width = len(match_2digit.group(2)) # 1 or 2 digits
|
| 108 |
+
|
| 109 |
+
# Probe next numbers
|
| 110 |
+
for i in range(start_num + 1, start_num + 6):
|
| 111 |
+
if num_width == 2:
|
| 112 |
+
probe_url = f"{base}{i:02d}{ext}"
|
| 113 |
+
else:
|
| 114 |
+
probe_url = f"{base}{i}{ext}"
|
| 115 |
try:
|
| 116 |
pr = requests.head(probe_url, headers=HEADERS, timeout=3, allow_redirects=True)
|
| 117 |
if pr.status_code == 200:
|
|
|
|
| 120 |
break
|
| 121 |
except:
|
| 122 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
if len(parts_found) > 1:
|
| 125 |
return {"src": parts_found[0], "poster": poster, "vtype": "hls", "all_parts": parts_found}
|
| 126 |
+
|
| 127 |
+
return {"src": full_quality[0], "poster": poster, "vtype": "hls"}
|
| 128 |
except:
|
| 129 |
return None
|
| 130 |
|