Sync all projects
This commit is contained in:
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
@@ -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 段 join:tid-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-only;p.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()
|
||||
@@ -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 URI,ts 用原始绝对地址)"""
|
||||
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]}...')
|
||||
Reference in New Issue
Block a user