429 lines
18 KiB
Python
429 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
目标站: 4KVM (道长DR框架格式 - Alpine.js 增强播放解析)
|
||
首页: https://www.4kvm.net
|
||
"""
|
||
import re
|
||
import json
|
||
import time
|
||
import random
|
||
import urllib.parse
|
||
from bs4 import BeautifulSoup
|
||
from base.spider import Spider
|
||
|
||
|
||
class Spider(Spider):
|
||
# 道长格式类属性
|
||
def_headers = {
|
||
'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',
|
||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||
}
|
||
host = 'https://www.4kvm.net'
|
||
|
||
def getName(self):
|
||
return '4KVM'
|
||
|
||
def init(self, extend=''):
|
||
try:
|
||
self._fetch('/')
|
||
except:
|
||
pass
|
||
|
||
# ---------- 首页 ----------
|
||
def homeContent(self, filter):
|
||
html = self._fetch('/')
|
||
video_list = self._extractList(html) if html else []
|
||
# 返回分类(写死)和列表,可选筛选条件
|
||
return {
|
||
'class': [
|
||
{'type_id': '1', 'type_name': '电影'},
|
||
{'type_id': '2', 'type_name': '电视剧'},
|
||
{'type_id': '3', 'type_name': '动漫'},
|
||
],
|
||
'list': video_list,
|
||
'filters': self._getFilters() # 保留动态筛选功能
|
||
}
|
||
|
||
def homeVideoContent(self):
|
||
return self.categoryContent('1', 1, {}, {})
|
||
|
||
# ---------- 分类 ----------
|
||
def categoryContent(self, tid, pg, filter, extend):
|
||
page = int(pg) if pg else 1
|
||
params = {'classify': tid}
|
||
if extend:
|
||
for k, v in extend.items():
|
||
if v and v != '' and k != 'classify':
|
||
params[k] = v
|
||
if page > 1:
|
||
params['page'] = page
|
||
query = urllib.parse.urlencode(params)
|
||
html = self._fetch(f'/filter?{query}')
|
||
return {
|
||
'page': page,
|
||
'pagecount': 99,
|
||
'limit': 24,
|
||
'total': 999,
|
||
'list': self._extractList(html) if html else []
|
||
}
|
||
|
||
# ---------- 搜索 ----------
|
||
def searchContent(self, key, quick, pg='1'):
|
||
page = int(pg) if pg else 1
|
||
params = {'q': key}
|
||
if page > 1:
|
||
params['page'] = page
|
||
query = urllib.parse.urlencode(params)
|
||
html = self._fetch(f'/search?{query}')
|
||
return {
|
||
'list': self._extractList(html) if html else [],
|
||
'page': page,
|
||
'pagecount': 1,
|
||
'limit': 24,
|
||
'total': 0
|
||
}
|
||
|
||
# ---------- 详情 ----------
|
||
def detailContent(self, ids):
|
||
result = {'list': []}
|
||
vid = ids[0].split(',')[0].strip()
|
||
try:
|
||
html = self._fetch(f'/play/{vid}')
|
||
if not html:
|
||
return result
|
||
soup = BeautifulSoup(html, 'html.parser')
|
||
# 标题
|
||
title_tag = soup.select_one('h1') or soup.select_one('h2')
|
||
vod_name = title_tag.get_text(strip=True) if title_tag else vid
|
||
# 海报
|
||
vod_pic = ''
|
||
img = soup.select_one('img.w-full') or soup.select_one('img[data-src]') or soup.select_one('img[src]')
|
||
if img:
|
||
src = img.get('data-src', '') or img.get('src', '')
|
||
if src and not src.startswith('data:'):
|
||
vod_pic = self._fixPic(src)
|
||
# 导演/演员/简介
|
||
vod_director = ''
|
||
vod_actor = ''
|
||
vod_content = ''
|
||
info_area = soup.select_one('.rounded-lg div.grid') or soup.select_one('div.grid')
|
||
if info_area:
|
||
txt = info_area.get_text(' ', strip=True)
|
||
dm = re.search(r'导演[::]\s*([^主\n]+)', txt)
|
||
if dm: vod_director = dm.group(1).strip()
|
||
am = re.search(r'主演[::]\s*([^剧\n]+)', txt)
|
||
if am: vod_actor = am.group(1).strip()
|
||
cm = re.search(r'(?:剧情)?简介[::]\s*(.+?)(?:\n|$)', txt)
|
||
if cm: vod_content = cm.group(1).strip()
|
||
# 分集解析(基于 episodeManager)
|
||
play_from, play_url = [], []
|
||
ep_mgr = soup.select_one('[x-data*="episodeManager"]')
|
||
if ep_mgr:
|
||
# 提取所有剧集链接
|
||
eps = ep_mgr.select('a[data-episode][href]')
|
||
if eps:
|
||
lines = {}
|
||
for a in eps:
|
||
line = a.get('data-line', '1')
|
||
ep = a.get('data-episode', '')
|
||
href = a.get('href', '')
|
||
if not href:
|
||
continue
|
||
if not href.startswith('http'):
|
||
href = self.host + href
|
||
lines.setdefault(line, []).append((int(ep) if ep else 0, href, a.get_text(strip=True)))
|
||
for line_key in sorted(lines.keys()):
|
||
line_eps = sorted(lines[line_key], key=lambda x: x[0])
|
||
ep_strs = []
|
||
for e in line_eps:
|
||
ep_strs.append(f'{e[2] or "第"+str(e[0])+"集"}${e[1]}')
|
||
play_from.append(f'线路{line_key}')
|
||
play_url.append('#'.join(ep_strs))
|
||
if not play_url:
|
||
play_from.append('播放')
|
||
play_url.append(f'播放${vid}')
|
||
result['list'].append({
|
||
'vod_id': vid,
|
||
'vod_name': vod_name,
|
||
'vod_pic': vod_pic,
|
||
'vod_director': vod_director,
|
||
'vod_actor': vod_actor,
|
||
'vod_content': vod_content,
|
||
'vod_play_from': '$$$'.join(play_from),
|
||
'vod_play_url': '$$$'.join(play_url)
|
||
})
|
||
except Exception as e:
|
||
print(f'详情解析错误: {e}')
|
||
return result
|
||
|
||
# ---------- 播放器 (Alpine.js 增强) ----------
|
||
def playerContent(self, flag, id, vipFlags, depth=0):
|
||
try:
|
||
if depth > 3: # 防止无限递归
|
||
return {
|
||
'parse': 1,
|
||
'url': id if id.startswith('http') else f'{self.host}/play/{id}',
|
||
'header': self.def_headers
|
||
}
|
||
if id.startswith('http'):
|
||
url = id
|
||
else:
|
||
url = f'{self.host}/play/{id}'
|
||
time.sleep(random.uniform(0.3, 0.8))
|
||
html = self._fetch(url)
|
||
if not html:
|
||
return {'parse': 1, 'url': url, 'header': self.def_headers}
|
||
header = {'User-Agent': self.def_headers['User-Agent'], 'Referer': url}
|
||
|
||
# ---- 策略1:Alpine.js x-data 播放器 / episodeManager ----
|
||
# 提取 x-data 中的 JSON 对象(处理单引号、尾逗号等)
|
||
xdata_patterns = [
|
||
r'x-data\s*=\s*["\']\s*player\s*\(\s*(\{.*?\})\s*\)\s*["\']',
|
||
r'x-data\s*=\s*["\']\s*(\{[^"\'<>]*?url\s*:\s*["\'][^"\']+[^"\'<>]*?\})\s*["\']',
|
||
r'x-data\s*=\s*["\']\s*episodeManager\s*\(\s*(\{.*?\})\s*\)\s*["\']',
|
||
]
|
||
for pat in xdata_patterns:
|
||
m = re.search(pat, html, re.S | re.I)
|
||
if m:
|
||
try:
|
||
raw = m.group(1)
|
||
# 简单处理非标准JSON
|
||
raw = re.sub(r"'", '"', raw)
|
||
raw = re.sub(r',\s*[,}]', lambda x: x.group(0).replace(',', ''), raw) # 去掉多余逗号
|
||
data = json.loads(raw)
|
||
# 优先获取直接 url
|
||
pu = data.get('url', '')
|
||
if pu:
|
||
if pu.startswith('//'):
|
||
pu = 'https:' + pu
|
||
if pu.endswith('.m3u8') or pu.endswith('.mp4'):
|
||
return {'parse': 0, 'url': pu, 'header': header}
|
||
elif pu.startswith('http'):
|
||
return {'parse': 1, 'url': pu, 'header': header}
|
||
# 其次从 episodes 和 current 中取当前集
|
||
episodes = data.get('episodes', [])
|
||
current = data.get('current', data.get('currentIndex', 0))
|
||
if isinstance(episodes, list) and len(episodes) > 0:
|
||
idx = int(current) if isinstance(current, (int, str)) and str(current).isdigit() else 0
|
||
if 0 <= idx < len(episodes):
|
||
ep = episodes[idx]
|
||
if isinstance(ep, dict):
|
||
ep_url = ep.get('url', '')
|
||
else:
|
||
ep_url = str(ep)
|
||
if ep_url:
|
||
if ep_url.startswith('//'):
|
||
ep_url = 'https:' + ep_url
|
||
elif not ep_url.startswith('http'):
|
||
ep_url = self.host + '/' + ep_url.lstrip('/')
|
||
if ep_url.endswith(('.m3u8', '.mp4')):
|
||
return {'parse': 0, 'url': ep_url, 'header': header}
|
||
else:
|
||
# 再次请求该播放页(递归一次)
|
||
return self.playerContent(flag, ep_url, vipFlags, depth + 1)
|
||
except:
|
||
pass
|
||
|
||
# ---- 策略2:传统 player_aaaa ----
|
||
m = re.search(r'player_aaaa\s*=\s*(\{[^;]+\})', html, re.S)
|
||
if m:
|
||
try:
|
||
pd = json.loads(m.group(1))
|
||
pu = pd.get('url', '')
|
||
if pu:
|
||
if pu.startswith('//'):
|
||
pu = 'https:' + pu
|
||
if pu.endswith('.m3u8') or pu.endswith('.mp4'):
|
||
return {'parse': 0, 'url': pu, 'header': header}
|
||
if pu.startswith('http'):
|
||
return {'parse': 1, 'url': pu, 'header': header}
|
||
except:
|
||
pass
|
||
|
||
# ---- 策略3:正则直接匹配 m3u8/mp4 ----
|
||
media_patterns = [
|
||
r'url\s*:\s*["\']([^"\']+\.m3u8)["\']',
|
||
r'url\s*:\s*["\']([^"\']+\.mp4)["\']',
|
||
r'src\s*:\s*["\']([^"\']+\.m3u8)["\']',
|
||
r'["\']([^"\']*\.m3u8[^"\']*)["\']',
|
||
r'video:\s*["\']([^"\']+)["\']',
|
||
]
|
||
for pat in media_patterns:
|
||
m = re.search(pat, html, re.I)
|
||
if m:
|
||
pu = m.group(1)
|
||
if pu.startswith('//'):
|
||
pu = 'https:' + pu
|
||
elif not pu.startswith('http'):
|
||
pu = self.host + '/' + pu.lstrip('/')
|
||
if pu.endswith('.m3u8') or pu.endswith('.mp4'):
|
||
return {'parse': 0, 'url': pu, 'header': header}
|
||
else:
|
||
return {'parse': 1, 'url': pu, 'header': header}
|
||
|
||
# ---- 策略4:iframe ----
|
||
soup = BeautifulSoup(html, 'html.parser')
|
||
iframe = soup.find('iframe')
|
||
if iframe and iframe.get('src'):
|
||
src = iframe['src']
|
||
if src.startswith('//'):
|
||
src = 'https:' + src
|
||
elif not src.startswith('http'):
|
||
src = self.host + '/' + src.lstrip('/')
|
||
return {'parse': 1, 'url': src, 'header': header}
|
||
|
||
# ---- 策略5:episodeManager 当前激活链接 ----
|
||
ep_mgr = soup.select_one('[x-data*="episodeManager"]')
|
||
if ep_mgr:
|
||
active = ep_mgr.select_one('a.bg-indigo-600, a[class*="bg-indigo"]') or ep_mgr.select_one('a[data-episode]')
|
||
if active and active.get('href'):
|
||
href = active['href']
|
||
if href.startswith('//'):
|
||
href = 'https:' + href
|
||
elif not href.startswith('http'):
|
||
href = self.host + href
|
||
return {'parse': 1, 'url': href, 'header': header}
|
||
|
||
# 回退
|
||
return {'parse': 1, 'url': url, 'header': self.def_headers}
|
||
except Exception as e:
|
||
return {'parse': 1, 'url': id, 'header': self.def_headers}
|
||
|
||
# ---------- 列表解析 ----------
|
||
def _extractList(self, html):
|
||
if not html:
|
||
return []
|
||
videos = []
|
||
seen = set()
|
||
try:
|
||
soup = BeautifulSoup(html, 'html.parser')
|
||
cards = soup.select('div[data-vod-id]')
|
||
if not cards:
|
||
cards = soup.find_all('a', href=re.compile(r'/play/'))
|
||
for card in cards:
|
||
try:
|
||
if card.name == 'a' and '/play/' in card.get('href', ''):
|
||
a = card
|
||
else:
|
||
a = card.find('a', href=re.compile(r'/play/'))
|
||
if not a:
|
||
continue
|
||
href = a.get('href', '')
|
||
vid = href.split('/play/')[-1].strip()
|
||
if not vid or vid in seen:
|
||
continue
|
||
seen.add(vid)
|
||
# 标题
|
||
if card.name == 'a':
|
||
title = card.get('title', '') or card.get_text(strip=True)
|
||
else:
|
||
h = card.find('h3') or card.find('h2')
|
||
title = h.get_text(strip=True) if h else a.get_text(strip=True)
|
||
if not title:
|
||
title = vid
|
||
# 图片
|
||
pic = ''
|
||
img = card.find('img') if card.name != 'img' else card
|
||
if img:
|
||
src = img.get('data-src', '') or img.get('src', '')
|
||
if src and not src.startswith('data:'):
|
||
pic = self._fixPic(src)
|
||
# 备注
|
||
remark = ''
|
||
for cls in ('.pic-text', '.remarks', '.text-green-500', '.text-yellow-400'):
|
||
tag = card.select_one(cls)
|
||
if tag:
|
||
remark = tag.get_text(strip=True)
|
||
break
|
||
videos.append({
|
||
'vod_id': vid,
|
||
'vod_name': title.strip(),
|
||
'vod_pic': pic,
|
||
'vod_remarks': remark
|
||
})
|
||
except:
|
||
continue
|
||
except Exception as e:
|
||
print(f'列表解析错误: {e}')
|
||
return videos
|
||
|
||
# ---------- 筛选条件(动态) ----------
|
||
def _getFilters(self):
|
||
try:
|
||
html = self._fetch('/filter?classify=1')
|
||
if not html:
|
||
return {}
|
||
soup = BeautifulSoup(html, 'html.parser')
|
||
filters = {}
|
||
sections = soup.select('div.flex.flex-wrap')
|
||
for sec in sections:
|
||
links = sec.select('a[href*="classify="]')
|
||
if len(links) < 2:
|
||
continue
|
||
# 提取键名和组名
|
||
param_key = None
|
||
group_name = ''
|
||
for a in links:
|
||
href = a.get('href', '')
|
||
parsed = urllib.parse.urlparse(href)
|
||
qs = urllib.parse.parse_qs(parsed.query)
|
||
for k in qs:
|
||
if k not in ('classify', 'page', 'sort_by', 'order'):
|
||
param_key = k
|
||
first_text = links[0].get_text(strip=True)
|
||
if '全部' in first_text:
|
||
group_name = first_text.replace('全部', '').strip()
|
||
break
|
||
if param_key:
|
||
break
|
||
if not param_key or not group_name:
|
||
continue
|
||
options = []
|
||
for a in links:
|
||
text = a.get_text(strip=True)
|
||
href = a.get('href', '')
|
||
parsed = urllib.parse.urlparse(href)
|
||
qs = urllib.parse.parse_qs(parsed.query)
|
||
val = ''
|
||
if '全部' not in text or text != links[0].get_text(strip=True):
|
||
val = qs.get(param_key, [''])[0]
|
||
options.append({'n': text, 'v': val})
|
||
if options:
|
||
filters[param_key] = {
|
||
'key': param_key,
|
||
'name': group_name,
|
||
'value': options
|
||
}
|
||
return filters
|
||
except:
|
||
return {}
|
||
|
||
# ---------- 工具 ----------
|
||
def _fetch(self, url):
|
||
try:
|
||
if not url.startswith('http'):
|
||
url = self.host + url
|
||
time.sleep(random.uniform(0.3, 0.7))
|
||
rsp = self.fetch(url, headers=self.def_headers, verify=False)
|
||
return rsp.text if rsp and rsp.status_code == 200 else ''
|
||
except:
|
||
return ''
|
||
|
||
def _fixPic(self, u):
|
||
if not u:
|
||
return ''
|
||
if u.startswith('//'):
|
||
return 'https:' + u
|
||
if not u.startswith('http'):
|
||
return self.host + '/' + u.lstrip('/')
|
||
return u.replace('&', '&')
|
||
|
||
# ---------- 必需空方法 ----------
|
||
def localProxy(self, param=''):
|
||
return {}
|
||
def isVideoFormat(self, url):
|
||
return url.endswith(('.m3u8', '.mp4', '.flv')) if url else False
|
||
def manualVideoCheck(self):
|
||
return False
|