Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-22 02:48:26 +00:00
parent 642075c99a
commit 6eb7a7e7f4
18 changed files with 12871 additions and 12024 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+315
View File
@@ -0,0 +1,315 @@
# -*- coding: utf-8 -*-
# FongMi/TVBox Python Spider - 嘀嗒影视 didahd.xyz
import re, json, html, base64, binascii, hashlib, time
from urllib.parse import urljoin, quote, unquote
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
except Exception:
AES = None
def unpad(data, bs): return data
try:
from base.spider import Spider as BaseSpider
except Exception:
class BaseSpider(object):
def fetch(self, url, headers=None, timeout=15, **kwargs):
import requests
return requests.get(url, headers=headers, timeout=timeout, verify=False)
def post(self, url, headers=None, data=None, timeout=15, **kwargs):
import requests
return requests.post(url, headers=headers, data=data, timeout=timeout, verify=False)
class Spider(BaseSpider):
def __init__(self):
self.host = 'https://www.didahd.xyz'
self.headers = {
'User-Agent':'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36',
'Referer':self.host + '/',
'Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
self.classes = [
{'type_id':'1','type_name':'电影'},
{'type_id':'2','type_name':'电视剧'},
{'type_id':'3','type_name':'纪录片'},
{'type_id':'4','type_name':'动漫'},
{'type_id':'5','type_name':'综艺'}
]
def getName(self): return '嘀嗒影视'
def getDependence(self): return []
def init(self, extend=''): pass
def isVideoFormat(self, url): return bool(re.search(r'\.(m3u8|mp4|flv|mkv)(\?|$)', str(url), re.I))
def manualVideoCheck(self): return True
def action(self, action): return None
def destroy(self): pass
def liveContent(self, url): return {'list': []}
def localProxy(self, param): return [404, 'text/plain', 'Not Found']
def log(self, msg):
try: print('[嘀嗒影视] ' + str(msg))
except Exception: pass
def getHtml(self, url, referer=None):
if not url.startswith('http'): url = urljoin(self.host, url)
h = dict(self.headers)
if referer: h['Referer'] = referer
try:
r = self.fetch(url, headers=h, timeout=15)
if hasattr(r, 'content'):
enc = getattr(r, 'encoding', None) or 'utf-8'
return r.content.decode(enc, 'ignore')
return getattr(r, 'text', '') or ''
except Exception as e:
self.log('请求失败 %s %s' % (url, e)); return ''
def postHtml(self, url, data, referer=None):
if not url.startswith('http'): url = urljoin(self.host, url)
h = dict(self.headers)
if referer: h['Referer'] = referer
h['Content-Type'] = 'application/x-www-form-urlencoded'
try:
if hasattr(super(), 'post'):
r = self.post(url, headers=h, data=data, timeout=15)
else:
raise Exception('no post')
if hasattr(r, 'content'):
enc = getattr(r, 'encoding', None) or 'utf-8'
return r.content.decode(enc, 'ignore')
return getattr(r, 'text', '') or ''
except Exception as e:
self.log('POST失败 %s %s' % (url, e)); return ''
def clean(self, s):
s = html.unescape(str(s or ''))
s = re.sub(r'<script[\s\S]*?</script>|<style[\s\S]*?</style>', ' ', s, flags=re.I)
s = re.sub(r'<[^>]+>', ' ', s)
return re.sub(r'\s+', ' ', s).strip()
def fix(self, u):
if not u: return ''
u = html.unescape(str(u)).replace('\\/', '/').strip()
return urljoin(self.host, u)
def homeContent(self, filter):
return {'class': self.classes, 'filters': self.makeFilters() if filter else {}}
def makeFilters(self):
years = [{'n':'全部','v':''}] + [{'n':str(y),'v':str(y)} for y in range(2026, 2009, -1)]
areas = [{'n':'全部','v':''}] + [{'n':x,'v':x} for x in ['大陆','香港','台湾','美国','日本','韩国','英国','法国','德国','泰国','印度','其它']]
langs = [{'n':'全部','v':''}] + [{'n':x,'v':x} for x in ['国语','英语','粤语','韩语','日语','泰语','其它']]
bys = [{'n':'时间','v':'time'},{'n':'人气','v':'hits'},{'n':'评分','v':'score'}]
letters = [{'n':'全部','v':''}] + [{'n':c,'v':c} for c in list('ABCDEFGHIJKLMNOPQRSTUVWXYZ')] + [{'n':'0-9','v':'0-9'}]
fs = [{'key':'area','name':'地区','value':areas},{'key':'year','name':'年份','value':years},{'key':'lang','name':'语言','value':langs},{'key':'letter','name':'字母','value':letters},{'key':'by','name':'排序','value':bys}]
return {c['type_id']:fs for c in self.classes}
def homeVideoContent(self):
return {'list': self.parseList(self.getHtml(self.host + '/'))[:30]}
def buildCategoryUrl(self, tid, pg, extend):
pg = str(pg or '1'); ext = extend or {}
area = str(ext.get('area','') or '')
by = str(ext.get('by','') or '')
lang = str(ext.get('lang','') or '')
letter = str(ext.get('letter','') or '')
year = str(ext.get('year','') or '')
if any([area, by, lang, letter, year]):
# 真实 href 是 12 段 jointid-area-by-lang-空-空-空-空-pg-空-空-year
# 例:/show/1-----------2025.html -> /show/1--------2---2025.html
p = '' if pg == '1' else pg
fields = [str(tid), area, by, lang, '', '', '', '', p, '', '', year]
return self.host + '/show/' + '-'.join(fields) + '.html'
if pg == '1': return self.host + '/type/%s.html' % tid
return self.host + '/type/%s-%s.html' % (tid, pg)
def isNoResultPage(self, txt):
return bool(re.search(r'没有找到您想要的结果|没有找到.*?结果|搜索无结果|暂无数据', txt or '', re.I))
def categoryContent(self, tid, pg, filter, extend):
url = self.buildCategoryUrl(tid, pg, extend or {})
txt = self.getHtml(url, self.host + '/')
vods = [] if self.isNoResultPage(txt) else self.parseList(txt)
return {'list':vods, 'page':int(pg or 1), 'pagecount':999999 if vods else int(pg or 1), 'limit':len(vods), 'total':999999 if vods else 0}
def parseList(self, txt):
vods, seen = [], set()
blocks = re.findall(r'(<a\b(?=[^>]*class=["\'][^"\']*myui-vodlist__thumb[^"\']*["\'])(?=[^>]*href=["\'][^"\']*/detail/\d+\.html["\'])[\s\S]*?</a>)', txt or '', re.I)
if not blocks:
blocks = re.findall(r'(<div\b[^>]*class=["\'][^"\']*myui-vodlist__box[^"\']*["\'][\s\S]*?</div>\s*</div>)', txt or '', re.I)
if not blocks:
blocks = re.findall(r'(<a\b[^>]+href=["\'][^"\']*/detail/\d+\.html["\'][\s\S]*?</a>)', txt or '', re.I)
for b in blocks:
try:
hm = re.search(r'href=["\']([^"\']*/detail/(\d+)\.html)["\']', b, re.I)
if not hm: continue
vid = self.fix(hm.group(1))
if vid in seen: continue
seen.add(vid)
tm = re.search(r'title=["\']([^"\']+)["\']', b, re.I) or re.search(r'alt=["\']([^"\']+)["\']', b, re.I) or re.search(r'<h4[^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)</a>', b, re.I)
title = self.clean(tm.group(1)) if tm else ''
pm = re.search(r'(?:data-original|data-src)=["\']([^"\']+)["\']', b, re.I) or re.search(r'<img[^>]+src=["\']((?!/template/|/static/)[^"\']+)["\']', b, re.I)
rm = re.search(r'<span[^>]*class=["\'][^"\']*pic-text[^"\']*["\'][^>]*>([\s\S]*?)</span>', b, re.I)
if title:
vods.append({'vod_id':vid,'vod_name':title,'vod_pic':self.fix(pm.group(1)) if pm else '', 'vod_remarks':self.clean(rm.group(1)) if rm else ''})
except Exception as e:
self.log('列表单条失败 %s' % e)
return vods
def detailContent(self, ids):
url = ids[0]
txt = self.getHtml(url, self.host + '/')
mt = re.search(r'<h1[^>]*class=["\'][^"\']*title[^"\']*["\'][^>]*>([\s\S]*?)</h1>', txt, re.I) or re.search(r'<title>(.*?)\s*-\s*嘀嗒影视', txt, re.S)
title = self.clean(mt.group(1)) if mt else ''
pic_block = re.search(r'<a[^>]*class=["\'][^"\']*myui-vodlist__thumb[^"\']*picture[^"\']*["\'][\s\S]*?</a>', txt, re.I)
picm = None
if pic_block:
pb = pic_block.group(0)
picm = re.search(r'(?:data-original|data-src)=["\']([^"\']+)["\']', pb, re.I) or re.search(r'<img[^>]+src=["\']((?!/template/|/static/)[^"\']+)["\']', pb, re.I)
def info(name):
m = re.search(r'<span[^>]*class=["\'][^"\']*text-muted[^"\']*["\'][^>]*>%s[:]</span>([\s\S]*?)(?:<span[^>]*class=["\'][^"\']*split-line|</p>)' % name, txt, re.I)
return self.clean(m.group(1)) if m else ''
cm = re.search(r'剧情简介[:]</span>[\s\S]*?<span>([\s\S]*?)</span>', txt, re.I) or re.search(r'剧情简介[:]</span>([\s\S]*?)<br', txt, re.I)
content = self.clean(cm.group(1)) if cm else ''
tab_area = re.search(r'<ul[^>]*class=["\'][^"\']*nav-tabs[^"\']*active[^"\']*["\'][^>]*>([\s\S]*?)</ul>', txt, re.I)
names = [self.clean(x[1]) for x in re.findall(r'href=["\']#playlist(\d+)["\'][^>]*>([\s\S]*?)</a>', tab_area.group(1) if tab_area else '', re.I)]
groups = []
for m in re.finditer(r'<div[^>]*id=["\']playlist(\d+)["\'][^>]*>([\s\S]*?)(?=<div[^>]*id=["\']playlist\d+["\']|</div>\s*</div>\s*<!--|<!-- 下载地址|$)', txt, re.I):
groups.append(m.group(2))
play_from, play_url = [], []
for i,g in enumerate(groups):
eps, used = [], set()
for h,n in re.findall(r'<a\b[^>]+href=["\']([^"\']*/play/\d+-\d+-\d+\.html)["\'][^>]*>([\s\S]*?)</a>', g, re.I):
fu = self.fix(h)
if fu in used: continue
used.add(fu)
name = self.clean(n) or ('%d' % (len(eps)+1))
eps.append(name + '$' + fu)
if eps:
line = names[i] if i < len(names) and names[i] else '线路%d' % (i+1)
if re.search(r'网盘|云盘|夸克|百度|UC|PikPak|阿里', line, re.I):
continue
play_from.append(line); play_url.append('#'.join(eps))
if not play_url:
eps=[]
for h,n in re.findall(r'href=["\']([^"\']*/play/\d+-\d+-\d+\.html)["\'][^>]*>([\s\S]*?)</a>', txt, re.I):
item=(self.clean(n) or '播放') + '$' + self.fix(h)
if item not in eps: eps.append(item)
if eps: play_from, play_url = ['默认'], ['#'.join(eps)]
vod = {'vod_id':url,'vod_name':title,'vod_pic':self.fix(picm.group(1)) if picm else '', 'type_name':info('分类'), 'vod_year':info('年份')[:4], 'vod_area':info('地区'), 'vod_remarks':info('更新时间'), 'vod_actor':info('主演'), 'vod_director':info('导演'), 'vod_content':content, 'vod_play_from':'$$$'.join(play_from), 'vod_play_url':'$$$'.join(play_url)}
return {'list':[vod]}
def searchContent(self, key, quick, pg='1'):
url = self.host + '/search/%s-------------.html' % quote(key)
txt = self.getHtml(url, self.host + '/')
vods = [] if self.isNoResultPage(txt) else self.parseList(txt)
return {'list':vods, 'page':int(pg or 1), 'pagecount':1, 'limit':len(vods), 'total':len(vods)}
def decodePlayerUrl(self, data):
url = data.get('url','') if isinstance(data, dict) else ''
enc = str(data.get('encrypt','0')) if isinstance(data, dict) else '0'
try:
if enc == '1': url = unquote(url)
elif enc == '2': url = unquote(base64.b64decode(url).decode('utf-8','ignore'))
elif enc == '3' and re.fullmatch(r'[0-9a-fA-F]+', url or ''):
# didahd 的 artplayer 线路要求把 hex 原文作为 url 参数,解码值仅作备用
return url
except Exception as e:
self.log('播放器URL解码失败 %s' % e)
return url.replace('\\/', '/')
def decodeArtUrl(self, cipher_text, timestamp):
if not AES or not cipher_text or not timestamp: return ''
try:
seed = str(timestamp) + 'RY7e48naFXPsLJC'
md5 = hashlib.md5(seed.encode('utf-8')).hexdigest()
key = md5[16:32].encode('utf-8')
iv = md5[0:16].encode('utf-8')
raw = cipher_text.replace('\\/', '/')
dec = AES.new(key, AES.MODE_CBC, iv).decrypt(base64.b64decode(raw))
return unpad(dec, 16).decode('utf-8', 'ignore')
except Exception as e:
self.log('artplayer AES解密失败 %s' % e); return ''
def parseSmartPlay(self, txt, timestamp, referer):
try:
if 'isSmartPlay' not in txt or 'true' not in txt[:8000]: return ''
vm = re.search(r'const\s+playPageUrl\s*=\s*["\']([^"\']+)', txt, re.I)
cm = re.search(r'const\s+secretKeySeed\s*=\s*["\']([^"\']+)', txt, re.I)
if not vm or not cm or not timestamp: return ''
api = 'https://hd.ticktockwow.com/smartplay-cache/api/webvideo_ty.php'
t = int(time.time())
body = json.dumps({'vkey':vm.group(1), 'code':cm.group(1), 't':t, 'signature':hashlib.md5(str(t).encode('utf-8')).hexdigest()})
h = dict(self.headers)
h.update({'Referer':self.host + '/static/player/artplayer/', 'Origin':self.host, 'Content-Type':'application/json', 'Accept':'application/json,text/plain,*/*'})
r = self.post(api, headers=h, data=body, timeout=15)
text = r.content.decode(getattr(r, 'encoding', None) or 'utf-8', 'ignore') if hasattr(r, 'content') else (getattr(r, 'text', '') or '')
js = json.loads(text)
enc = (js or {}).get('url','')
u = self.decodeArtUrl(enc, timestamp)
u = u.replace('\\/', '/') if u else ''
return u if self.isVideoFormat(u) else ''
except Exception as e:
self.log('smartplay解析失败 %s' % e); return ''
def makePlayHeader(self, url):
# 播放端优先“空防盗链头”:不主动带 Referer/Origin,避免第三方 CDN 因来源不匹配而限速/卡顿。
# 实测 didahd 的 didahd secure、天翼云、快手、超星、小红书分片均可用 UA-onlyp.ananas 空 UA 可能 403,所以保留 UA。
h = {'User-Agent':self.headers['User-Agent']}
try:
if re.search(r'\.m3u8(?:\?|$)|qd-tjwq-person\.tjtele\.com|ctyunxs\.cn|PERSONCLOUD|video_m3u8/secure\.php', url, re.I):
r = self.fetch(url, headers=h, timeout=8)
txt = r.content[:2048].decode('utf-8', 'ignore') if hasattr(r, 'content') else (getattr(r, 'text', '') or '')[:2048]
# 只做健康探测,不再返回 Referer/Origin;减少 EXO 分片请求卡顿。
if '#EXTM3U' not in txt and txt:
self.log('m3u8探测异常片段 ' + txt[:60].replace('\n',' '))
except Exception as e:
self.log('播放头检测失败 %s' % e)
return h
def parseArtPlayer(self, raw_url, referer, next_url=''):
if not raw_url or re.match(r'https?://(?:pan\.quark|pan\.baidu|www\.aliyundrive|drive\.uc)', raw_url, re.I): return ''
art = self.host + '/static/player/artplayer/?url=' + quote(raw_url, safe='')
if next_url: art += '&next=' + quote(next_url, safe='')
txt = self.getHtml(art, referer)
ts = re.search(r'const\s+timestamp\s*=\s*["\']([^"\']+)', txt, re.I)
if not ts: return ''
sm = self.parseSmartPlay(txt, ts.group(1), referer)
if sm: return sm
qm = re.search(r'const\s+qualities\s*=\s*(\[[\s\S]*?\]);', txt, re.I)
if not qm: return ''
try:
arr = json.loads(qm.group(1))
for it in arr:
u = self.decodeArtUrl(it.get('url',''), ts.group(1))
if u:
u = self.fix(u)
if self.isVideoFormat(u): return u
except Exception as e:
self.log('artplayer qualities解析失败 %s' % e)
return ''
def playerContent(self, flag, id, vipFlags):
if self.isVideoFormat(id): return {'parse':0, 'url':id, 'header':self.makePlayHeader(id)}
txt = self.getHtml(id, self.host + '/')
data = None
m = re.search(r'var\s+player_[a-zA-Z0-9_]+\s*=\s*(\{[\s\S]*?\})\s*</script>', txt, re.I)
if m:
try: data = json.loads(m.group(1))
except Exception as e: self.log('播放器JSON失败 %s' % e)
url = self.decodePlayerUrl(data or {})
if self.isVideoFormat(url):
fu = self.fix(url)
return {'parse':0, 'url':fu, 'header':self.makePlayHeader(fu)}
final = self.parseArtPlayer(url, id, (data or {}).get('link_next',''))
if final:
return {'parse':0, 'url':final, 'header':self.makePlayHeader(final)}
mm = re.search(r'(https?:\\?/\\?/[^"\']+?\.(?:m3u8|mp4)[^"\']*)', txt, re.I)
if mm:
u = self.fix(mm.group(1))
return {'parse':0, 'url':u, 'header':self.makePlayHeader(u)}
if url and re.match(r'https?://', url):
return {'parse':1, 'url':url, 'header':self.headers}
return {'parse':1, 'url':id, 'header':self.headers}
spider = Spider()
+448
View File
@@ -0,0 +1,448 @@
# -*- coding: utf-8 -*-
"""
黄豆短剧爬虫
站点: https://www.hdmgdj.com
"""
import json
import urllib.parse
import requests
try:
from base.spider import Spider as BaseSpider
except ImportError:
class BaseSpider:
pass
class Spider(BaseSpider):
"""黄豆短剧爬虫"""
BASE_URL = 'https://www.hdmgdj.com'
API_BASE = 'https://hdmgdj.com/api'
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': 'application/json, text/plain, */*',
'Referer': 'https://www.hdmgdj.com/',
'Origin': 'https://www.hdmgdj.com',
}
_filter_cache = {} # 分类筛选缓存
def __init__(self):
super().__init__()
self.name = ""
self.error_play_url = "https://kjjsaas-sh.oss-cn-shanghai.aliyuncs.com/u/3401405881/20240818-936952-fc31b16575e80a7562cdb1f81a39c6b0.mp4"
self.session = requests.Session()
self.session.headers.update(self.HEADERS)
# ==================== 标准接口 ====================
def init(self, extend="{}"):
"""初始化"""
if extend:
try:
self.extend = json.loads(extend)
if 'name' in self.extend:
self.name = self.extend['name']
if 'base_url' in self.extend:
self.BASE_URL = self.extend['base_url']
self.API_BASE = self.extend['base_url'] + '/api'
except Exception as e:
print(e)
return None
def getName(self):
"""获取爬虫名称"""
return "黄豆短剧"
def homeContent(self, filter):
"""首页"""
result = {
"class": [],
"filters": {},
"list": [],
"parse": 0,
"jx": 0,
}
try:
# 获取频道首页数据(包含分类和推荐)
channel_data = self._get('/channel/home?platform=mobile&size=20')
if channel_data and isinstance(channel_data, dict):
# 分类:用 sections 里的 l3 分类(有实际内容的)
sections = channel_data.get('sections', [])
if isinstance(sections, list):
for sec in sections:
l3_id = sec.get('l3Id')
name = sec.get('name', '')
if l3_id and name:
result["class"].append({
"type_id": f"l3_{l3_id}",
"type_name": name,
})
# 首页推荐:把各个板块的内容合并
for sec in sections:
dramas = sec.get('dramas', [])
if isinstance(dramas, list):
for item in dramas:
result["list"].append(self._parse_vod(item))
# 如果 sections 里没有数据,用 guess/feature
if not result["list"]:
home_data = self._get('/home')
if home_data and isinstance(home_data, dict):
guess_list = home_data.get('guess', [])
if isinstance(guess_list, list):
for item in guess_list:
result["list"].append(self._parse_vod(item))
feature_list = home_data.get('feature', [])
if isinstance(feature_list, list):
for item in feature_list:
result["list"].append(self._parse_vod(item))
except Exception as e:
print(e)
return result
def categoryContent(self, tid, pg, filter, extend):
"""分类页"""
result = {
"page": pg,
"pagecount": 999,
"limit": 20,
"total": 99999,
"list": [],
"parse": 0,
"jx": 0,
}
try:
# 分类ID格式: l3_{id}
l3_id = tid.replace('l3_', '')
data = self._get(f'/dramas?platform=mobile&l3Id={l3_id}&sort=最新&page={pg}&size=20')
if data and isinstance(data, dict):
lst = data.get('list', [])
total = data.get('total', 0)
result["total"] = total
result["pagecount"] = (total + 19) // 20 if total else 999
for item in lst:
result["list"].append(self._parse_vod(item))
except Exception as e:
print(e)
return result
def detailContent(self, ids):
"""详情页"""
result = {
"list": [],
"parse": 0,
"jx": 0,
}
try:
vid = ids[0]
data = self._get(f'/dramas/{vid}')
if data and isinstance(data, dict):
episodes = data.get('episodes', [])
# 组装播放地址
play_url_parts = []
for ep in episodes:
ep_title = ep.get('title', f"{ep.get('ep', 0)}")
play_url = ep.get('playUrl', '')
if play_url:
play_url_parts.append(f"{ep_title}${play_url}")
cover = data.get('cover', '')
# 加密海报走本地代理解密
if cover and ('encryptimages' in cover or '.bng' in cover):
try:
cover = f"{self.getProxyUrl()}&url={urllib.parse.quote(cover)}"
except Exception:
pass
vod = {
"vod_id": str(data['id']),
"vod_name": data.get('t', ''),
"vod_pic": cover,
"type_name": data.get('sub', ''),
"vod_year": '',
"vod_area": '',
"vod_remarks": f"{data.get('serial', '')}·{data.get('plays', '')}播放",
"vod_actor": '',
"vod_director": '未知',
"vod_content": data.get('summary', '') or data.get('t', ''),
"vod_play_from": '黄豆短剧',
"vod_play_url": '#'.join(play_url_parts),
}
result["list"].append(vod)
except Exception as e:
print(e)
return result
def searchContent(self, key, quick, pg="1"):
"""搜索"""
result = {
"page": pg,
"pagecount": 999,
"limit": 20,
"total": 99999,
"list": [],
"parse": 0,
"jx": 0,
}
try:
data = self._get(f'/search?kw={urllib.parse.quote(key)}&page={pg}&size=20')
if data and isinstance(data, dict):
lst = data.get('list', [])
total = data.get('total', 0)
result["total"] = total
result["pagecount"] = (total + 19) // 20 if total else 0
for item in lst:
result["list"].append(self._parse_vod(item))
except Exception as e:
print(e)
return result
def playerContent(self, flag, id, vipFlags):
"""播放页 - 直接返回 m3u8 data URI"""
result = {
"parse": 0,
"playUrl": "",
"url": self.error_play_url,
"jx": 0,
"header": "",
}
if id:
# 直接在 playerContent 里生成解密后的 m3u8,用 data URI 返回
# 这样播放地址就不是 127.0.0.1 代理了
m3u8_content = self._build_m3u8_with_key(id)
if m3u8_content:
import base64
m3u8_b64 = base64.b64encode(m3u8_content.encode('utf-8')).decode('ascii')
result["url"] = "data:application/vnd.apple.mpegurl;base64," + m3u8_b64
result["parse"] = 0
return result
def _build_m3u8_with_key(self, url):
"""构建 m3u8 内容(key 内嵌为 base64 data URIts 用原始绝对地址)"""
import hashlib
import re
import base64
if not url:
return None
try:
r = self.session.get(url, timeout=15, verify=False)
content = r.text
# 计算 key
key_bytes = self._get_key_bytes(url)
if key_bytes:
key_b64 = base64.b64encode(key_bytes).decode('ascii')
key_data_uri = "data:text/plain;base64," + key_b64
content = re.sub(
r'(#EXT-X-KEY:.*?URI=")[^"]*(")',
r'\1' + key_data_uri + r'\2',
content
)
# 把相对路径的 ts 改成绝对路径
base_url = url.rsplit('/', 1)[0] + '/'
lines = content.split('\n')
new_lines = []
for line in lines:
line = line.strip()
if line and not line.startswith('#'):
if line.startswith('http'):
new_lines.append(line)
else:
new_lines.append(base_url + line)
else:
new_lines.append(line)
content = '\n'.join(new_lines)
return content
except Exception as e:
print(f"_build_m3u8_with_key error: {e}")
return None
def _get_key_bytes(self, url):
"""从 m3u8 URL 计算解密 key"""
import hashlib
import re
m = re.search(r'/hls/([0-9a-f]{64})/', url)
if not m:
return None
video_id = m.group(1)
ver_match = re.search(r'[?&]version=([^&#]+)', url)
version = ver_match.group(1) if ver_match else 'v1'
prefix = "xnaichanping"
key_str = prefix + video_id + version
return hashlib.md5(key_str.encode()).digest()
def localProxy(self, param):
"""本地代理 - 解密海报图片"""
try:
url = param['url']
r = self.session.get(url, timeout=15, verify=False)
decrypted = self._aes_decrypt_img(r.content, url)
# 确定图片类型
content_type = "image/jpeg"
if decrypted[:8] == b'\x89PNG\r\n\x1a\n':
content_type = "image/png"
elif decrypted[:6] in (b'GIF87a', b'GIF89a'):
content_type = "image/gif"
elif decrypted[:4] == b'RIFF' and decrypted[8:12] == b'WEBP':
content_type = "image/webp"
return [200, content_type, decrypted]
except Exception as e:
print(f"localProxy error: {e}")
return [500, 'text/html', b'']
def _aes_decrypt_img(self, encrypted, url):
"""AES 解密图片 - 网站自定义 CBC 算法"""
import hashlib
import re
from Crypto.Cipher import AES
# 提取 imageId (64位哈希)
m = re.search(r'([0-9a-f]{64})', url)
if not m:
return encrypted
image_id = m.group(1)
# 提取 version
ver_match = re.search(r'[?&]version=([^&#]+)', url)
version = ver_match.group(1) if ver_match else 'v1'
# 计算解密 key
prefix = "xnaichanping"
key_str = prefix + image_id + version
key_bytes = hashlib.md5(key_str.encode()).digest()
# 网站自定义 CBC 解密 (mC 函数)
t = len(encrypted) // 16
if t < 1:
return encrypted
iv = bytes(16) # IV=0
# 取最后一块 XOR 16
last_block = encrypted[(t - 1) * 16:t * 16]
a = bytes([b ^ 16 for b in last_block])
# 加密 a
cipher_enc = AES.new(key_bytes, AES.MODE_CBC, iv)
o = cipher_enc.encrypt(a)[:16]
# 扩展密文并解密
extended = encrypted + o
cipher_dec = AES.new(key_bytes, AES.MODE_CBC, iv)
c = cipher_dec.decrypt(extended)
# 自定义 CBC:每块 XOR 前一块密文
u = bytearray(len(c))
u[:16] = c[:16]
for f in range(1, t):
for h in range(16):
u[f * 16 + h] = c[f * 16 + h] ^ encrypted[(f - 1) * 16 + h]
# 去掉 padding
d = u[-1]
if 1 <= d <= 16:
u = u[:-d]
return bytes(u)
# ==================== 内部方法 ====================
def _parse_vod(self, item):
"""解析视频条目 - 海报用 getProxyUrl 代理解密"""
cover = item.get('cover', '')
# 加密海报走本地代理解密
if cover and ('encryptimages' in cover or '.bng' in cover):
try:
cover = f"{self.getProxyUrl()}&url={urllib.parse.quote(cover)}"
except Exception:
pass
return {
"vod_id": str(item['id']),
"vod_name": item.get('t', ''),
"vod_pic": cover,
"vod_remarks": f"{item.get('serial', '')}·{item.get('eps', 0)}",
}
def _get(self, path):
"""发送 GET 请求"""
url = self.API_BASE + path
try:
r = self.session.get(url, timeout=15, verify=False)
resp = r.json()
if resp.get('code') == 0 and resp.get('data') is not None:
return resp['data']
return None
except Exception as e:
print(e)
return None
# 调试用
if __name__ == '__main__':
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
s = Spider()
s.init()
print('=== 首页 ===')
home = s.homeContent(True)
print(f'分类: {len(home["class"])}')
for c in home['class']:
print(f' {c["type_id"]}: {c["type_name"]}')
print(f'推荐: {len(home["list"])}')
for v in home['list'][:5]:
print(f' {v["vod_id"]}: {v["vod_name"]} - {v["vod_remarks"]}')
print()
print('=== 分类1(都市)第1页 ===')
cr = s.categoryContent('1', 1, True, {})
print(f'总数: {cr["total"]}, 本页: {len(cr["list"])}')
for v in cr['list'][:5]:
print(f' {v["vod_id"]}: {v["vod_name"]}')
print()
print('=== 搜索 穿越 ===')
sr = s.searchContent('穿越', False, '1')
print(f'结果: {len(sr["list"])}个, 总数: {sr["total"]}')
for v in sr['list'][:5]:
print(f' {v["vod_id"]}: {v["vod_name"]}')
print()
if sr['list']:
vid = sr['list'][0]['vod_id']
print(f'=== 详情 {vid} ===')
dr = s.detailContent([vid])
if dr['list']:
v = dr['list'][0]
print(f'标题: {v["vod_name"]}')
print(f'分类: {v["type_name"]}')
print(f'备注: {v["vod_remarks"]}')
print(f'播放源: {v["vod_play_from"]}')
play_urls = v["vod_play_url"].split('#')
print(f'集数: {len(play_urls)}')
print(f'第一集: {play_urls[0][:80]}...')