Spaces:
Running
Running
Upload vtv_scraper.py
Browse files- vtv_scraper.py +224 -0
vtv_scraper.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VTV Channel Scraper - Fetches live stream URLs from xemtv.net
|
| 3 |
+
Scrapes VTV1-VTV10 + VTV Cần Thơ stream URLs dynamically.
|
| 4 |
+
"""
|
| 5 |
+
import requests, re, json, time, threading
|
| 6 |
+
from bs4 import BeautifulSoup
|
| 7 |
+
from urllib.parse import urljoin
|
| 8 |
+
|
| 9 |
+
UA = {
|
| 10 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 11 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 12 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 13 |
+
}
|
| 14 |
+
REFERER = "https://hd.xemtv.net/"
|
| 15 |
+
|
| 16 |
+
# Known xemtv.net channel page patterns
|
| 17 |
+
XEMTV_CHANNELS = {
|
| 18 |
+
"VTV1": "https://hd.xemtv.net/kenh-vtv1-truc-tuyen-hot.html",
|
| 19 |
+
"VTV2": "https://hd.xemtv.net/kenh-vtv2-truc-tuyen-hot.html",
|
| 20 |
+
"VTV3": "https://hd.xemtv.net/kenh-vtv3-truc-tuyen-hot.html",
|
| 21 |
+
"VTV4": "https://hd.xemtv.net/kenh-vtv4-truc-tuyen-hot.html",
|
| 22 |
+
"VTV5": "https://hd.xemtv.net/kenh-vtv5-truc-tuyen-hot.html",
|
| 23 |
+
"VTV6": "https://hd.xemtv.net/kenh-vtv-can-tho-truc-tuyen-hot.html", # VTV Cần Thơ
|
| 24 |
+
"VTV7": "https://hd.xemtv.net/kenh-vtv7-truc-tuyen-hot.html",
|
| 25 |
+
"VTV8": "https://hd.xemtv.net/kenh-vtv8-truc-tuyen-hot.html",
|
| 26 |
+
"VTV9": "https://hd.xemtv.net/kenh-vtv9-truc-tuyen-hot.html",
|
| 27 |
+
"VTV10": "https://hd.xemtv.net/kenh-vtv10-truc-tuyen-hot.html",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Fallback: try alternate URL patterns
|
| 31 |
+
XEMTV_ALT_PATTERNS = [
|
| 32 |
+
"https://hd.xemtv.net/kenh-{slug}-truc-tuyen-hot.html",
|
| 33 |
+
"https://hd.xemtv.net/{slug}-truc-tuyen-hot.html",
|
| 34 |
+
"https://xemtv.net/kenh-{slug}-truc-tuyen-hot.html",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
_vtv_cache = {}
|
| 38 |
+
_vtv_cache_lock = threading.Lock()
|
| 39 |
+
_cache_ttl = 600 # 10 minutes
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _cached(key):
|
| 43 |
+
with _vtv_cache_lock:
|
| 44 |
+
if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _cache_ttl:
|
| 45 |
+
return _vtv_cache[key]['d']
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _set_cache(key, data):
|
| 50 |
+
with _vtv_cache_lock:
|
| 51 |
+
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _extract_stream_from_page(html, page_url=""):
|
| 55 |
+
"""Extract m3u8/stream URL from xemtv.net page HTML."""
|
| 56 |
+
streams = []
|
| 57 |
+
|
| 58 |
+
# Pattern 1: direct m3u8 URLs in page source
|
| 59 |
+
for m in re.finditer(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html):
|
| 60 |
+
url = m.group(1).strip()
|
| 61 |
+
if url not in streams:
|
| 62 |
+
streams.append(url)
|
| 63 |
+
|
| 64 |
+
# Pattern 2: m3u8 in JavaScript variables
|
| 65 |
+
for m in re.finditer(r'(?:src|source|url|file|stream|play_url|video_url)\s*[:=]\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.I):
|
| 66 |
+
url = m.group(1).strip()
|
| 67 |
+
if url not in streams:
|
| 68 |
+
streams.append(url)
|
| 69 |
+
|
| 70 |
+
# Pattern 3: in JSON-like config blocks
|
| 71 |
+
for m in re.finditer(r'"(?:src|source|url|file|stream|play_url|video_url|m3u8)"\s*:\s*"(https?://[^"]*\.m3u8[^"]*)"', html, re.I):
|
| 72 |
+
url = m.group(1).strip()
|
| 73 |
+
if url not in streams:
|
| 74 |
+
streams.append(url)
|
| 75 |
+
|
| 76 |
+
# Pattern 4: iframe src that might contain stream
|
| 77 |
+
for m in re.finditer(r'<iframe[^>]+src=["\']([^"\']+)["\']', html, re.I):
|
| 78 |
+
iframe_url = m.group(1).strip()
|
| 79 |
+
if 'xemtv' in iframe_url or 'embed' in iframe_url.lower():
|
| 80 |
+
streams.append(("iframe", iframe_url))
|
| 81 |
+
|
| 82 |
+
# Pattern 5: video/source tags
|
| 83 |
+
soup = BeautifulSoup(html, 'lxml')
|
| 84 |
+
for video in soup.find_all('video'):
|
| 85 |
+
src = video.get('src', '')
|
| 86 |
+
if src and '.m3u8' in src:
|
| 87 |
+
streams.append(src)
|
| 88 |
+
for source in video.find_all('source'):
|
| 89 |
+
src = source.get('src', '')
|
| 90 |
+
if src and '.m3u8' in src and src not in streams:
|
| 91 |
+
streams.append(src)
|
| 92 |
+
|
| 93 |
+
# Pattern 6: data-* attributes
|
| 94 |
+
for tag in soup.find_all(attrs={"data-url": True}):
|
| 95 |
+
url = tag['data-url']
|
| 96 |
+
if '.m3u8' in url and url not in streams:
|
| 97 |
+
streams.append(url)
|
| 98 |
+
for tag in soup.find_all(attrs={"data-src": True}):
|
| 99 |
+
url = tag['data-src']
|
| 100 |
+
if '.m3u8' in url and url not in streams:
|
| 101 |
+
streams.append(url)
|
| 102 |
+
for tag in soup.find_all(attrs={"data-stream": True}):
|
| 103 |
+
url = tag['data-stream']
|
| 104 |
+
if '.m3u8' in url and url not in streams:
|
| 105 |
+
streams.append(url)
|
| 106 |
+
|
| 107 |
+
# Filter: only keep direct m3u8 URLs (not iframe tuples)
|
| 108 |
+
direct = [s for s in streams if isinstance(s, str) and s.startswith('http')]
|
| 109 |
+
return direct
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _extract_stream_via_jina(page_url):
|
| 113 |
+
"""Use Jina AI to fetch page and extract stream URLs."""
|
| 114 |
+
try:
|
| 115 |
+
r = requests.get(
|
| 116 |
+
"https://r.jina.ai/" + page_url,
|
| 117 |
+
headers={"Accept": "text/markdown", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"},
|
| 118 |
+
timeout=30
|
| 119 |
+
)
|
| 120 |
+
if r.status_code == 200:
|
| 121 |
+
# Jina returns markdown, but we need the raw HTML for stream extraction
|
| 122 |
+
# Try to find m3u8 in the markdown output
|
| 123 |
+
for m in re.finditer(r'(https?://[^\s<>)]+\.m3u8[^\s<>)]*)', r.text):
|
| 124 |
+
return [m.group(1)]
|
| 125 |
+
except:
|
| 126 |
+
pass
|
| 127 |
+
return []
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def fetch_vtv_stream(channel_name):
|
| 131 |
+
"""Fetch stream URL for a specific VTV channel from xemtv.net."""
|
| 132 |
+
cache_key = f"vtv_{channel_name}"
|
| 133 |
+
cached = _cached(cache_key)
|
| 134 |
+
if cached:
|
| 135 |
+
return cached
|
| 136 |
+
|
| 137 |
+
url = XEMTV_CHANNELS.get(channel_name)
|
| 138 |
+
if not url:
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
streams = []
|
| 142 |
+
|
| 143 |
+
# Method 1: Direct fetch
|
| 144 |
+
try:
|
| 145 |
+
r = requests.get(url, headers={**UA, "Referer": REFERER}, timeout=15, allow_redirects=True)
|
| 146 |
+
if r.status_code == 200:
|
| 147 |
+
r.encoding = 'utf-8'
|
| 148 |
+
streams = _extract_stream_from_page(r.text, url)
|
| 149 |
+
except:
|
| 150 |
+
pass
|
| 151 |
+
|
| 152 |
+
# Method 2: Jina AI
|
| 153 |
+
if not streams:
|
| 154 |
+
streams = _extract_stream_via_jia(url)
|
| 155 |
+
|
| 156 |
+
# Method 3: Try alternate URL patterns
|
| 157 |
+
if not streams:
|
| 158 |
+
slug = channel_name.lower().replace(' ', '-')
|
| 159 |
+
for pattern in XEMTV_ALT_PATTERNS:
|
| 160 |
+
alt_url = pattern.format(slug=slug)
|
| 161 |
+
try:
|
| 162 |
+
r = requests.get(alt_url, headers={**UA, "Referer": REFERER}, timeout=10, allow_redirects=True)
|
| 163 |
+
if r.status_code == 200:
|
| 164 |
+
r.encoding = 'utf-8'
|
| 165 |
+
streams = _extract_stream_from_page(r.text, alt_url)
|
| 166 |
+
if streams:
|
| 167 |
+
break
|
| 168 |
+
except:
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
result = streams[0] if streams else None
|
| 172 |
+
_set_cache(cache_key, result)
|
| 173 |
+
return result
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def fetch_all_vtv_streams():
|
| 177 |
+
"""Fetch all VTV channel streams. Returns {channel_name: stream_url}."""
|
| 178 |
+
cache_key = "vtv_all"
|
| 179 |
+
cached = _cached(cache_key)
|
| 180 |
+
if cached:
|
| 181 |
+
return cached
|
| 182 |
+
|
| 183 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 184 |
+
|
| 185 |
+
results = {}
|
| 186 |
+
|
| 187 |
+
def _fetch_one(name):
|
| 188 |
+
url = fetch_vtv_stream(name)
|
| 189 |
+
return name, url
|
| 190 |
+
|
| 191 |
+
with ThreadPoolExecutor(5) as ex:
|
| 192 |
+
futs = [ex.submit(_fetch_one, name) for name in XEMTV_CHANNELS]
|
| 193 |
+
for f in as_completed(futs, timeout=30):
|
| 194 |
+
try:
|
| 195 |
+
name, url = f.result()
|
| 196 |
+
if url:
|
| 197 |
+
results[name] = url
|
| 198 |
+
except:
|
| 199 |
+
pass
|
| 200 |
+
|
| 201 |
+
_set_cache(cache_key, results)
|
| 202 |
+
return results
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def get_vtv_channels_for_frontend():
|
| 206 |
+
"""
|
| 207 |
+
Returns channel data for the frontend.
|
| 208 |
+
Tries xemtv.net first, falls back to known working streams.
|
| 209 |
+
"""
|
| 210 |
+
streams = fetch_all_vtv_streams()
|
| 211 |
+
|
| 212 |
+
# Build ordered channel list
|
| 213 |
+
channels = []
|
| 214 |
+
for name in ["VTV1", "VTV2", "VTV3", "VTV4", "VTV5", "VTV6", "VTV7", "VTV8", "VTV9", "VTV10"]:
|
| 215 |
+
url = streams.get(name)
|
| 216 |
+
display_name = name if name != "VTV6" else "VTV Cần Thơ"
|
| 217 |
+
channels.append({
|
| 218 |
+
"name": name,
|
| 219 |
+
"displayName": display_name,
|
| 220 |
+
"url": url or "",
|
| 221 |
+
"logo": f"https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/{name}_logo.svg/200px-{name}_logo.svg.png"
|
| 222 |
+
})
|
| 223 |
+
|
| 224 |
+
return channels
|