Sync all projects
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,314 @@
|
||||
# coding=utf-8
|
||||
# !/usr/bin/python
|
||||
|
||||
"""
|
||||
|
||||
作者 丢丢喵 🚓 内容均从互联网收集而来 仅供交流学习使用 版权归原创者所有 如侵犯了您的权益 请通知作者 将及时删除侵权内容
|
||||
====================Diudiumiao====================
|
||||
|
||||
"""
|
||||
|
||||
from Crypto.Util.Padding import unpad
|
||||
from Crypto.Util.Padding import pad
|
||||
from urllib.parse import unquote
|
||||
from Crypto.Cipher import ARC4
|
||||
from urllib.parse import quote
|
||||
from base.spider import Spider
|
||||
from Crypto.Cipher import AES
|
||||
from datetime import datetime
|
||||
from bs4 import BeautifulSoup
|
||||
from base64 import b64decode
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import datetime
|
||||
import binascii
|
||||
import requests
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
|
||||
sys.path.append('..')
|
||||
|
||||
xurl = "https://search.bilibili.com"
|
||||
|
||||
xurl1 = "https://api.live.bilibili.com"
|
||||
|
||||
headerx = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0'
|
||||
}
|
||||
|
||||
class Spider(Spider):
|
||||
global xurl
|
||||
global xurl1
|
||||
global headerx
|
||||
|
||||
def getName(self):
|
||||
return "首页"
|
||||
|
||||
def init(self, extend):
|
||||
pass
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def extract_middle_text(self, text, start_str, end_str, pl, start_index1: str = '', end_index2: str = ''):
|
||||
if pl == 3:
|
||||
plx = []
|
||||
while True:
|
||||
start_index = text.find(start_str)
|
||||
if start_index == -1:
|
||||
break
|
||||
end_index = text.find(end_str, start_index + len(start_str))
|
||||
if end_index == -1:
|
||||
break
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
plx.append(middle_text)
|
||||
text = text.replace(start_str + middle_text + end_str, '')
|
||||
if len(plx) > 0:
|
||||
purl = ''
|
||||
for i in range(len(plx)):
|
||||
matches = re.findall(start_index1, plx[i])
|
||||
output = ""
|
||||
for match in matches:
|
||||
match3 = re.search(r'(?:^|[^0-9])(\d+)(?:[^0-9]|$)', match[1])
|
||||
if match3:
|
||||
number = match3.group(1)
|
||||
else:
|
||||
number = 0
|
||||
if 'http' not in match[0]:
|
||||
output += f"#{match[1]}${number}{xurl}{match[0]}"
|
||||
else:
|
||||
output += f"#{match[1]}${number}{match[0]}"
|
||||
output = output[1:]
|
||||
purl = purl + output + "$$$"
|
||||
purl = purl[:-3]
|
||||
return purl
|
||||
else:
|
||||
return ""
|
||||
else:
|
||||
start_index = text.find(start_str)
|
||||
if start_index == -1:
|
||||
return ""
|
||||
end_index = text.find(end_str, start_index + len(start_str))
|
||||
if end_index == -1:
|
||||
return ""
|
||||
|
||||
if pl == 0:
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
return middle_text.replace("\\", "")
|
||||
|
||||
if pl == 1:
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
matches = re.findall(start_index1, middle_text)
|
||||
if matches:
|
||||
jg = ' '.join(matches)
|
||||
return jg
|
||||
|
||||
if pl == 2:
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
matches = re.findall(start_index1, middle_text)
|
||||
if matches:
|
||||
new_list = [f'{item}' for item in matches]
|
||||
jg = '$$$'.join(new_list)
|
||||
return jg
|
||||
|
||||
def homeContent(self, filter):
|
||||
result = {}
|
||||
result = {"class": [{"type_id": "舞", "type_name": "舞蹈"},
|
||||
{"type_id": "音乐", "type_name": "音乐"},
|
||||
{"type_id": "手游", "type_name": "手游"},
|
||||
{"type_id": "网游", "type_name": "网游"},
|
||||
{"type_id": "单机游戏", "type_name": "单机游戏"},
|
||||
{"type_id": "虚拟主播", "type_name": "虚拟主播"},
|
||||
{"type_id": "电台", "type_name": "电台"},
|
||||
{"type_id": "体育", "type_name": "体育"},
|
||||
{"type_id": "聊天", "type_name": "聊天"},
|
||||
{"type_id": "娱乐", "type_name": "娱乐"},
|
||||
{"type_id": "电影", "type_name": "影视"},
|
||||
{"type_id": "新闻", "type_name": "新闻"}]
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def homeVideoContent(self):
|
||||
pass
|
||||
|
||||
def categoryContent(self, cid, pg, filter, ext):
|
||||
result = {}
|
||||
videos = []
|
||||
|
||||
if pg:
|
||||
page = int(pg)
|
||||
else:
|
||||
page = 1
|
||||
|
||||
url = f'{xurl}/live?keyword={cid}&page={str(page)}'
|
||||
detail = requests.get(url=url, headers=headerx)
|
||||
detail.encoding = "utf-8"
|
||||
res = detail.text
|
||||
doc = BeautifulSoup(res, "lxml")
|
||||
|
||||
soups = doc.find_all('div', class_="video-list-item")
|
||||
|
||||
for vod in soups:
|
||||
|
||||
names = vod.find('h3', class_="bili-live-card__info--tit")
|
||||
name = names.text.strip().replace('直播中', '')
|
||||
|
||||
id = names.find('a')['href']
|
||||
id = self.extract_middle_text(id, 'bilibili.com/', '?', 0)
|
||||
|
||||
pic = vod.find('img')['src']
|
||||
if 'http' not in pic:
|
||||
pic = "https:" + pic
|
||||
|
||||
remarks = vod.find('a', class_="bili-live-card__info--uname")
|
||||
remark = remarks.text.strip()
|
||||
|
||||
video = {
|
||||
"vod_id": id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark
|
||||
}
|
||||
videos.append(video)
|
||||
|
||||
result = {'list': videos}
|
||||
result['page'] = pg
|
||||
result['pagecount'] = 9999
|
||||
result['limit'] = 90
|
||||
result['total'] = 999999
|
||||
return result
|
||||
|
||||
def detailContent(self, ids):
|
||||
did = ids[0]
|
||||
result = {}
|
||||
videos = []
|
||||
xianlu = ''
|
||||
bofang = ''
|
||||
|
||||
url = f'{xurl1}/xlive/web-room/v2/index/getRoomPlayInfo?room_id={did}&platform=web&protocol=0,1&format=0,1,2&codec=0,1'
|
||||
detail = requests.get(url=url, headers=headerx)
|
||||
detail.encoding = "utf-8"
|
||||
data = detail.json()
|
||||
|
||||
content = '欢迎观看哔哩直播'
|
||||
|
||||
setup = data['data']['playurl_info']['playurl']['stream']
|
||||
|
||||
nam = 0
|
||||
|
||||
for vod in setup:
|
||||
|
||||
try:
|
||||
host = vod['format'][nam]['codec'][0]['url_info'][1]['host']
|
||||
except (KeyError, IndexError):
|
||||
continue
|
||||
|
||||
base = vod['format'][nam]['codec'][0]['base_url']
|
||||
|
||||
extra = vod['format'][nam]['codec'][0]['url_info'][1]['extra']
|
||||
|
||||
id = host + base + extra
|
||||
|
||||
nam = nam + 1
|
||||
|
||||
namc = f"{nam}号线路"
|
||||
|
||||
bofang = bofang + namc + '$' + id + '#'
|
||||
|
||||
bofang = bofang[:-1]
|
||||
|
||||
xianlu = '哔哩专线'
|
||||
|
||||
videos.append({
|
||||
"vod_id": did,
|
||||
"vod_content": content,
|
||||
"vod_play_from": xianlu,
|
||||
"vod_play_url": bofang
|
||||
})
|
||||
|
||||
result['list'] = videos
|
||||
return result
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
|
||||
result = {}
|
||||
result["parse"] = 0
|
||||
result["playUrl"] = ''
|
||||
result["url"] = id
|
||||
result["header"] = headerx
|
||||
return result
|
||||
|
||||
def searchContentPage(self, key, quick, pg):
|
||||
result = {}
|
||||
videos = []
|
||||
|
||||
if pg:
|
||||
page = int(pg)
|
||||
else:
|
||||
page = 1
|
||||
|
||||
url = f'{xurl}/live?keyword={key}&page={str(page)}'
|
||||
detail = requests.get(url=url, headers=headerx)
|
||||
detail.encoding = "utf-8"
|
||||
res = detail.text
|
||||
doc = BeautifulSoup(res, "lxml")
|
||||
|
||||
soups = doc.find_all('div', class_="video-list-item")
|
||||
|
||||
for vod in soups:
|
||||
|
||||
names = vod.find('h3', class_="bili-live-card__info--tit")
|
||||
name = names.text.strip().replace('直播中', '')
|
||||
|
||||
id = names.find('a')['href']
|
||||
id = self.extract_middle_text(id, 'bilibili.com/', '?', 0)
|
||||
|
||||
pic = vod.find('img')['src']
|
||||
if 'http' not in pic:
|
||||
pic = "https:" + pic
|
||||
|
||||
remarks = vod.find('a', class_="bili-live-card__info--uname")
|
||||
remark = remarks.text.strip()
|
||||
|
||||
video = {
|
||||
"vod_id": id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark
|
||||
}
|
||||
videos.append(video)
|
||||
|
||||
result['list'] = videos
|
||||
result['page'] = pg
|
||||
result['pagecount'] = 9999
|
||||
result['limit'] = 90
|
||||
result['total'] = 999999
|
||||
return result
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
return self.searchContentPage(key, quick, '1')
|
||||
|
||||
def localProxy(self, params):
|
||||
if params['type'] == "m3u8":
|
||||
return self.proxyM3u8(params)
|
||||
elif params['type'] == "media":
|
||||
return self.proxyMedia(params)
|
||||
elif params['type'] == "ts":
|
||||
return self.proxyTs(params)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
# coding = utf-8
|
||||
#!/usr/bin/python
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
import urllib.parse
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_v1_5
|
||||
from base.spider import Spider
|
||||
|
||||
sys.path.append('..')
|
||||
|
||||
class Spider(Spider):
|
||||
def __init__(self):
|
||||
self.name = "瓜子"
|
||||
self.hosts = [
|
||||
'https://apinew.uozvr.com',
|
||||
'https://api.w32z7vtd.com',
|
||||
'https://api.6a7nnf7.com',
|
||||
'https://api.umygrx3.com',
|
||||
'https://api.rmedphk.com'
|
||||
]
|
||||
self.host_index = 0
|
||||
self.host = self.hosts[self.host_index]
|
||||
|
||||
# AES 固定密钥(与Java版一致)
|
||||
self.AES_KEY = 'OITxa5OqAYjhswxx'
|
||||
self.AES_IV = 'rCMNwZASNBKZ8mXV'
|
||||
|
||||
# RSA 公钥/私钥
|
||||
self.RSA_PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDUM5+/y8sPsWkd1/RQS64X259EUwxFXFE5HlA65MqrxnPs0JqoSRojSDy5QhwvROlaD6TwRQHKMY2OAZ6SnQeUJsChTEFIR9qUkwrs3/MVUMxjsv6JS6Oe/juclyJGTgVmDhB55EafXsD0SQYVj/QXXsxR6ewR5E2kL52yAAD4yQIDAQAB"
|
||||
self.RSA_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGAe6hKrWLi1zQmjTT1
|
||||
ozbE4QdFeJGNxubxld6GrFGximxfMsMB6BpJhpcTouAqywAFppiKetUBBbXwYsYU
|
||||
1wNr648XVmPmCMCy4rY8vdliFnbMUj086DU6Z+/oXBdWU3/b1G0DN3E9wULRSwcK
|
||||
ZT3wj/cCI1vsCm3gj2R5SqkA9Y0CAwEAAQKBgAJH+4CxV0/zBVcLiBCHvSANm0l7
|
||||
HetybTh/j2p0Y1sTXro4ALwAaCTUeqdBjWiLSo9lNwDHFyq8zX90+gNxa7c5EqcW
|
||||
V9FmlVXr8VhfBzcZo1nXeNdXFT7tQ2yah/odtdcx+vRMSGJd1t/5k5bDd9wAvYdI
|
||||
DblMAg+wiKKZ5KcdAkEA1cCakEN4NexkF5tHPRrR6XOY/XHfkqXxEhMqmNbB9U34
|
||||
saTJnLWIHC8IXys6Qmzz30TtzCjuOqKRRy+FMM4TdwJBAJQZFPjsGC+RqcG5UvVM
|
||||
iMPhnwe/bXEehShK86yJK/g/UiKrO87h3aEu5gcJqBygTq3BBBoH2md3pr/W+hUM
|
||||
WBsCQQChfhTIrdDinKi6lRxrdBnn0Ohjg2cwuqK5zzU9p/N+S9x7Ck8wUI53DKm8
|
||||
jUJE8WAG7WLj/oCOWEh+ic6NIwTdAkEAj0X8nhx6AXsgCYRql1klbqtVmL8+95KZ
|
||||
K7PnLWG/IfjQUy3pPGoSaZ7fdquG8bq8oyf5+dzjE/oTXcByS+6XRQJAP/5ciy1b
|
||||
L3NhUhsaOVy55MHXnPjdcTX0FaLi+ybXZIfIQ2P4rb19mVq1feMbCXhz+L1rG8oa
|
||||
t5lYKfpe8k83ZA==
|
||||
-----END RSA PRIVATE KEY-----"""
|
||||
|
||||
self.DEVICE_OLD_KEY = "aLFBMWpxBrIDAD1Si/KVvm41"
|
||||
|
||||
# 设备信息(随机生成)
|
||||
self.deviceId = str(864150060000000 + random.randint(0, 9999))
|
||||
self.deviceKey = ''.join(random.choices('0123456789ABCDEF', k=40)) # 20字节hex大写
|
||||
self.token = ""
|
||||
self.token_id = ""
|
||||
self.registered = False
|
||||
|
||||
self.header = {
|
||||
'User-Agent': 'Lavf/57.83.100',
|
||||
'code': 'GZ0369',
|
||||
'deviceId': self.deviceId,
|
||||
'lang': 'zh_cn',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Version': '2604028',
|
||||
'PackageName': 'com.ae06aebdbb.y286327f5a.ofe849883320260517',
|
||||
'Ver': '3.0.3.2',
|
||||
'api-ver': '3.0.3.2',
|
||||
'Referer': self.host
|
||||
}
|
||||
|
||||
self.cache = {}
|
||||
self.cache_timeout = 300
|
||||
|
||||
# 初始化token
|
||||
self.init_token()
|
||||
|
||||
def getName(self):
|
||||
return self.name
|
||||
|
||||
def init(self, extend=''):
|
||||
pass
|
||||
|
||||
# ---------- 设备注册与认证 ----------
|
||||
def init_token(self):
|
||||
"""初始化token:注册设备 -> 刷新"""
|
||||
print("===== 初始化设备认证 =====")
|
||||
try:
|
||||
if not self.registered:
|
||||
self.sign_up()
|
||||
# 刷新获取最终token
|
||||
self.refresh_token()
|
||||
except Exception as e:
|
||||
print(f"初始化token失败: {e}")
|
||||
# 兜底使用原有硬编码(几乎没用)
|
||||
self.token = '024212ef0975c5306a1434e113a46463.bc77313e11a248558a6ca244ca980944ec3421fa480c50e0229ad91f1cb15aea582603202cd71796885c9e5163e500f1b72f737059aff1ddb8beea47c5a331d6760540345b7f88b2302a0e6e09589f9dcf3ff9175d8c905f990203f5fc04748008ea7a366571cbf5b09509a873dcfba3cf1d5590385f5f7ef6e01d1850974aa220eb5178c89e61c24411af9b9a19435e.06fde789ece48d9b33c5dc857e04e9b5838f08264d928b87237d3476c4484b46'
|
||||
|
||||
def sign_up(self):
|
||||
"""注册设备"""
|
||||
print("注册新设备...")
|
||||
params = {
|
||||
"new_key": self.deviceKey,
|
||||
"old_key": self.DEVICE_OLD_KEY,
|
||||
"phone_type": 1,
|
||||
"code": ""
|
||||
}
|
||||
result = self._auth_request('/App/Authentication/Device/signUp', params)
|
||||
self._apply_auth(result)
|
||||
self.registered = True
|
||||
|
||||
def sign_in(self):
|
||||
"""登录设备"""
|
||||
print("设备登录...")
|
||||
params = {
|
||||
"new_key": self.deviceKey,
|
||||
"old_key": self.DEVICE_OLD_KEY
|
||||
}
|
||||
result = self._auth_request('/App/Authentication/Device/signIn', params)
|
||||
self._apply_auth(result)
|
||||
|
||||
def _apply_auth(self, result):
|
||||
"""从认证响应中提取token"""
|
||||
new_token = result.get('token', '')
|
||||
if not new_token:
|
||||
raise Exception("认证失败,无token返回: {}".format(result))
|
||||
self.token = new_token
|
||||
new_token_id = result.get('app_user_id', '')
|
||||
if new_token_id:
|
||||
self.token_id = new_token_id
|
||||
print(f"获取token成功, token前缀: {self.token[:30]}...")
|
||||
|
||||
def refresh_token(self):
|
||||
"""刷新token"""
|
||||
print("刷新token...")
|
||||
result = self._auth_request('/App/Authentication/Authenticator/refresh', {})
|
||||
self._apply_auth(result)
|
||||
|
||||
def _auth_request(self, path, params):
|
||||
"""认证类请求(不需要ensure_token)"""
|
||||
return self._send_encrypted_request(params, path, is_auth=True)
|
||||
|
||||
# ---------- 业务请求核心(修复加密与签名) ----------
|
||||
def ensure_token(self):
|
||||
"""确保token有效,如未就绪则重新获取"""
|
||||
if not self.token or not self.token_id:
|
||||
if self.registered:
|
||||
self.sign_in()
|
||||
else:
|
||||
self.sign_up()
|
||||
self.refresh_token()
|
||||
|
||||
def _send_encrypted_request(self, data, path, is_auth=False):
|
||||
"""
|
||||
发送加密请求,返回解密后的字典
|
||||
:param data: 业务参数字典
|
||||
:param path: 请求路径
|
||||
:param is_auth: 是否为认证类请求(signUp/signIn/refresh),此时不使用ensure_token
|
||||
"""
|
||||
try:
|
||||
if not is_auth:
|
||||
self.ensure_token()
|
||||
|
||||
# 1. 将参数转为JSON并AES加密
|
||||
json_params = json.dumps(data)
|
||||
encrypted = self.aes_encrypt(json_params, self.AES_KEY, self.AES_IV)
|
||||
request_key = encrypted.upper() # Java中是bytesToHex(encrypted).toUpperCase()
|
||||
|
||||
# 2. 生成keys (RSA加密 iv/key JSON)
|
||||
key_json = json.dumps({"iv": self.AES_IV, "key": self.AES_KEY})
|
||||
keys = self.rsa_encrypt(key_json, self.RSA_PUBLIC_KEY)
|
||||
|
||||
# 3. 生成签名
|
||||
t = str(int(time.time()))
|
||||
sign_str = f"token_id=,token={self.token},phone_type=1,request_key={request_key},app_id=1,time={t},keys={keys}*&zvdvdvddbfikkkumtmdwqppp?|4Y!s!2br"
|
||||
signature = self.get_md5(sign_str) # 已改为大写
|
||||
|
||||
# 4. 构建请求体
|
||||
body = {
|
||||
'token': self.token,
|
||||
'token_id': '',
|
||||
'phone_type': '1',
|
||||
'time': t,
|
||||
'phone_model': 'xiaomi-25031', # 与Java版保持一致
|
||||
'keys': keys,
|
||||
'request_key': request_key,
|
||||
'signature': signature,
|
||||
'app_id': '1',
|
||||
'ad_version': '1'
|
||||
}
|
||||
|
||||
# 5. 发送请求
|
||||
url = f"{self.host}{path}"
|
||||
response = self.post(url, headers=self.header, data=body, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}")
|
||||
|
||||
resp_json = response.json()
|
||||
# 检查业务code(若不为200可能token过期)
|
||||
if 'code' in resp_json and resp_json['code'] != 200:
|
||||
print(f"业务错误码: {resp_json['code']}, 信息: {resp_json}")
|
||||
# 如果不是认证请求,尝试重新获取token后重试一次(这里简单处理,外层get_data已有重试)
|
||||
raise Exception("业务错误")
|
||||
|
||||
data_section = resp_json.get('data')
|
||||
if not data_section:
|
||||
raise Exception("响应缺少data字段")
|
||||
|
||||
encrypted_response = data_section.get('response_key', '')
|
||||
encrypted_keys = data_section.get('keys', '')
|
||||
|
||||
# 6. 解密响应
|
||||
decrypted_keys_json = self.rsa_decrypt(encrypted_keys, self.RSA_PRIVATE_KEY)
|
||||
key_info = json.loads(decrypted_keys_json)
|
||||
resp_key = key_info['key']
|
||||
resp_iv = key_info['iv']
|
||||
decrypted_data = self.aes_decrypt(encrypted_response, resp_key, resp_iv)
|
||||
return json.loads(decrypted_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"请求失败 [{path}]: {e}")
|
||||
return None
|
||||
|
||||
def get_data(self, data, path, use_cache=True):
|
||||
"""带重试和域名轮询的数据获取(保持原框架)"""
|
||||
try:
|
||||
cache_key = f"{path}_{hash(str(data))}" if use_cache else None
|
||||
if use_cache and cache_key in self.cache:
|
||||
cached_data, timestamp = self.cache[cache_key]
|
||||
if time.time() - timestamp < self.cache_timeout:
|
||||
return cached_data
|
||||
|
||||
for attempt in range(3):
|
||||
tried = 0
|
||||
while tried < len(self.hosts):
|
||||
self.host = self.hosts[self.host_index]
|
||||
self.header['Referer'] = self.host
|
||||
result = self._send_encrypted_request(data, path)
|
||||
if result is not None:
|
||||
print(f"请求成功: {path}, 域名: {self.host}")
|
||||
if use_cache and cache_key:
|
||||
self.cache[cache_key] = (result, time.time())
|
||||
return result
|
||||
|
||||
# 切换到下一个域名
|
||||
self.host_index = (self.host_index + 1) % len(self.hosts)
|
||||
tried += 1
|
||||
|
||||
# 所有域名失败,尝试重新认证并重试
|
||||
if attempt < 2:
|
||||
print("所有域名失败,尝试重新认证...")
|
||||
try:
|
||||
self.ensure_token()
|
||||
except:
|
||||
pass
|
||||
self.host_index = 0
|
||||
else:
|
||||
break
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"get_data异常: {e}")
|
||||
return None
|
||||
|
||||
# ---------- 加解密工具 ----------
|
||||
def aes_encrypt(self, text, key, iv):
|
||||
try:
|
||||
key_bytes = key.encode('utf-8')
|
||||
iv_bytes = iv.encode('utf-8')
|
||||
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
|
||||
encrypted = cipher.encrypt(pad(text.encode('utf-8'), AES.block_size))
|
||||
return encrypted.hex().upper()
|
||||
except Exception as e:
|
||||
print(f"AES加密失败: {e}")
|
||||
return ""
|
||||
|
||||
def aes_decrypt(self, text, key, iv):
|
||||
try:
|
||||
key_bytes = key.encode('utf-8')
|
||||
iv_bytes = iv.encode('utf-8')
|
||||
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
|
||||
encrypted_bytes = bytes.fromhex(text)
|
||||
decrypted = unpad(cipher.decrypt(encrypted_bytes), AES.block_size)
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"AES解密失败: {e}")
|
||||
return ""
|
||||
|
||||
def rsa_encrypt(self, text, public_key_str):
|
||||
"""RSA公钥加密(PKCS1v1.5)"""
|
||||
try:
|
||||
key = RSA.import_key("-----BEGIN PUBLIC KEY-----\n" + public_key_str + "\n-----END PUBLIC KEY-----")
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
encrypted = cipher.encrypt(text.encode('utf-8'))
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"RSA加密失败: {e}")
|
||||
return ""
|
||||
|
||||
def rsa_decrypt(self, encrypted_data, private_key_str):
|
||||
"""RSA私钥解密"""
|
||||
try:
|
||||
encrypted_bytes = base64.b64decode(encrypted_data)
|
||||
rsa_key = RSA.import_key(private_key_str)
|
||||
cipher = PKCS1_v1_5.new(rsa_key)
|
||||
decrypted = cipher.decrypt(encrypted_bytes, None)
|
||||
return decrypted.decode('utf-8') if decrypted else ""
|
||||
except Exception as e:
|
||||
print(f"RSA解密失败: {e}")
|
||||
return ""
|
||||
|
||||
def get_md5(self, text):
|
||||
return hashlib.md5(text.encode()).hexdigest().upper() # 与Java一致大写
|
||||
|
||||
# ---------- 业务方法(不变) ----------
|
||||
def homeContent(self, filter):
|
||||
result = {}
|
||||
classes = [
|
||||
{"type_name": "电影", "type_id": "1"},
|
||||
{"type_name": "电视剧", "type_id": "2"},
|
||||
{"type_name": "动漫", "type_id": "4"},
|
||||
{"type_name": "综艺", "type_id": "3"},
|
||||
{"type_name": "短剧", "type_id": "64"}
|
||||
]
|
||||
result['class'] = classes
|
||||
filters = {}
|
||||
for cate in classes:
|
||||
tid = cate['type_id']
|
||||
filters[tid] = [
|
||||
{"key": "area", "name": "地区", "value": [
|
||||
{"n": "全部", "v": "0"}, {"n": "大陆", "v": "大陆"}, {"n": "香港", "v": "香港"},
|
||||
{"n": "台湾", "v": "台湾"}, {"n": "美国", "v": "美国"}, {"n": "韩国", "v": "韩国"},
|
||||
{"n": "日本", "v": "日本"}, {"n": "英国", "v": "英国"}, {"n": "法国", "v": "法国"},
|
||||
{"n": "泰国", "v": "泰国"}, {"n": "印度", "v": "印度"}, {"n": "其他", "v": "其他"}
|
||||
]},
|
||||
{"key": "year", "name": "年份", "value": [
|
||||
{"n": "全部", "v": "0"}, {"n": "2025", "v": "2025"}, {"n": "2024", "v": "2024"},
|
||||
{"n": "2023", "v": "2023"}, {"n": "2022", "v": "2022"}, {"n": "2021", "v": "2021"},
|
||||
{"n": "2020", "v": "2020"}, {"n": "2019", "v": "2019"}, {"n": "2018", "v": "2018"},
|
||||
{"n": "2017", "v": "2017"}, {"n": "2016", "v": "2016"}, {"n": "2015", "v": "2015"},
|
||||
{"n": "2014", "v": "2014"}, {"n": "2013", "v": "2013"}, {"n": "2012", "v": "2012"},
|
||||
{"n": "2011", "v": "2011"}, {"n": "2010", "v": "2010"}, {"n": "2009", "v": "2009"},
|
||||
{"n": "2008", "v": "2008"}, {"n": "2007", "v": "2007"}, {"n": "2006", "v": "2006"},
|
||||
{"n": "2005", "v": "2005"}, {"n": "更早", "v": "2004"}
|
||||
]},
|
||||
{"key": "sort", "name": "排序", "value": [
|
||||
{"n": "最新", "v": "d_id"}, {"n": "最热", "v": "d_hits"}, {"n": "推荐", "v": "d_score"}
|
||||
]}
|
||||
]
|
||||
result['filters'] = filters
|
||||
return result
|
||||
|
||||
def homeVideoContent(self):
|
||||
return {'list': []}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
videos = []
|
||||
try:
|
||||
body = {
|
||||
"area": extend.get('area', '0'),
|
||||
"year": extend.get('year', '0'),
|
||||
"pageSize": "30",
|
||||
"sort": extend.get('sort', 'd_id'),
|
||||
"page": str(pg),
|
||||
"tid": tid
|
||||
}
|
||||
cache_key = f"category_{tid}_{pg}_{hash(str(body))}"
|
||||
data = self.get_cached_data(cache_key, body, '/App/IndexList/indexList')
|
||||
if data and 'list' in data:
|
||||
for item in data['list']:
|
||||
vod_continu = item.get('vod_continu', 0)
|
||||
remarks = '电影' if vod_continu == 0 else f'更新至{vod_continu}集'
|
||||
video = {
|
||||
"vod_id": f"{item.get('vod_id', '')}/{vod_continu}",
|
||||
"vod_name": item.get('vod_name', ''),
|
||||
"vod_pic": item.get('vod_pic', ''),
|
||||
"vod_remarks": remarks
|
||||
}
|
||||
videos.append(video)
|
||||
except Exception as e:
|
||||
print(f"获取分类内容失败: {e}")
|
||||
return {'list': videos, 'page': int(pg), 'pagecount': 9999, 'limit': 30, 'total': 999999}
|
||||
|
||||
def detailContent(self, ids):
|
||||
try:
|
||||
vod_id = ids[0].split('/')[0]
|
||||
t = str(int(time.time()))
|
||||
body1 = {"token_id": self.token_id, "vod_id": vod_id, "mobile_time": t, "token": self.token}
|
||||
qdata = self.get_data(body1, '/App/IndexPlay/playInfo')
|
||||
body2 = {"vurl_cloud_id": "2", "vod_d_id": vod_id}
|
||||
jdata = self.get_data(body2, '/App/Resource/Vurl/show')
|
||||
if not qdata or 'vodInfo' not in qdata:
|
||||
return {'list': []}
|
||||
vod = qdata['vodInfo']
|
||||
video_detail = {
|
||||
"vod_id": vod_id,
|
||||
"vod_name": vod.get('vod_name', ''),
|
||||
"vod_pic": vod.get('vod_pic', ''),
|
||||
"vod_year": vod.get('vod_year', ''),
|
||||
"vod_area": vod.get('vod_area', ''),
|
||||
"vod_actor": vod.get('vod_actor', ''),
|
||||
"vod_director": vod.get('vod_director', ''),
|
||||
"vod_content": vod.get('vod_use_content', '').strip(),
|
||||
"vod_play_from": "瓜子影视"
|
||||
}
|
||||
play_list = []
|
||||
if jdata and 'list' in jdata:
|
||||
for index, item in enumerate(jdata['list']):
|
||||
if 'play' in item:
|
||||
n, p = [], []
|
||||
for key, value in item['play'].items():
|
||||
if 'param' in value and value['param']:
|
||||
n.append(key)
|
||||
p.append(value['param'])
|
||||
if p:
|
||||
play_name = str(index + 1) if len(jdata['list']) != 1 else vod.get('vod_name', '')
|
||||
play_url = f"{p[-1]}||{'@'.join(n)}"
|
||||
play_list.append(f"{play_name}${play_url}")
|
||||
video_detail["vod_play_url"] = "#".join(play_list)
|
||||
return {'list': [video_detail]}
|
||||
except Exception as e:
|
||||
print(f"获取详情失败: {e}")
|
||||
return {'list': []}
|
||||
|
||||
def searchContent(self, key, quick, pg=1):
|
||||
videos = []
|
||||
try:
|
||||
body = {"keywords": key, "order_val": "1", "page": str(pg)}
|
||||
data = self.get_data(body, '/App/Index/findMoreVod', use_cache=False)
|
||||
if data and 'list' in data:
|
||||
for item in data['list']:
|
||||
vod_continu = item.get('vod_continu', 0)
|
||||
remarks = '电影' if vod_continu == 0 else f'更新至{vod_continu}集'
|
||||
videos.append({
|
||||
"vod_id": f"{item.get('vod_id', '')}/{vod_continu}",
|
||||
"vod_name": item.get('vod_name', ''),
|
||||
"vod_pic": item.get('vod_pic', ''),
|
||||
"vod_remarks": remarks
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"搜索失败: {e}")
|
||||
return {'list': videos, 'page': int(pg), 'pagecount': 9999, 'limit': 30, 'total': 999999}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
try:
|
||||
parts = id.split('||')
|
||||
if len(parts) < 2:
|
||||
return {"parse": 0, "playUrl": "", "url": ""}
|
||||
param_str = parts[0]
|
||||
resolutions = parts[1].split('@') if len(parts) > 1 else []
|
||||
params = {}
|
||||
for pair in param_str.split('&'):
|
||||
if '=' in pair:
|
||||
key, value = pair.split('=', 1)
|
||||
params[key] = value
|
||||
if resolutions:
|
||||
resolutions.sort(key=lambda x: int(x) if x.isdigit() else 0, reverse=True)
|
||||
params['resolution'] = resolutions[0]
|
||||
data = self.get_data(params, '/App/Resource/VurlDetail/showOne', use_cache=False)
|
||||
if data and 'url' in data:
|
||||
return {"parse": 0, "playUrl": "", "url": data['url'],
|
||||
"header": json.dumps({"User-Agent": "Lavf/57.83.100", "Referer": "http://WJiZxLXA2.com/"}), 'danmaku': 'http://127.0.0.1:9978/proxy?do=diydanmu'}
|
||||
return {"parse": 0, "playUrl": "", "url": ""}
|
||||
except Exception as e:
|
||||
print(f"播放解析失败: {e}")
|
||||
return {"parse": 0, "playUrl": "", "url": ""}
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
video_formats = ['.m3u8', '.mp4', '.avi', '.mkv', '.flv', '.ts']
|
||||
return any(url.lower().endswith(fmt) for fmt in video_formats)
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def localProxy(self, params):
|
||||
return None
|
||||
|
||||
def get_cached_data(self, cache_key, data, path):
|
||||
current_time = time.time()
|
||||
if cache_key in self.cache:
|
||||
cached_data, timestamp = self.cache[cache_key]
|
||||
if current_time - timestamp < self.cache_timeout:
|
||||
return cached_data
|
||||
result = self.get_data(data, path)
|
||||
if result:
|
||||
self.cache[cache_key] = (result, current_time)
|
||||
return result
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
@@ -0,0 +1,330 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# by @嗷呜
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from base64 import b64encode, b64decode
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
# 引入 RSA 加解密所需模块
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_v1_5
|
||||
|
||||
sys.path.append('..')
|
||||
from base.spider import Spider
|
||||
|
||||
class Spider(Spider):
|
||||
|
||||
def init(self, extend=""):
|
||||
did = self.getdid()
|
||||
self.headers.update({'deviceId': did})
|
||||
token = self.gettk()
|
||||
self.headers.update({'token': token})
|
||||
|
||||
def getName(self):
|
||||
pass
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
# 1. 修改为主机域名
|
||||
host = 'http://qkys.qukanwh.com'
|
||||
|
||||
# 2. 同步原脚本的配置请求头
|
||||
headers = {
|
||||
'HOST': 'qkys.qukanwh.com',
|
||||
'User-Agent': 'okhttp/4.12.0',
|
||||
'client': 'app',
|
||||
'deviceType': 'Android',
|
||||
'Referer': ''
|
||||
}
|
||||
|
||||
# 3. 导入原脚本中的 RSA 密钥对与配置
|
||||
publicKey_str = "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoYt0BP77U+DM08BiI/QbSRIfxijXo85BTPqIM1Ow8BNwhLETzRIZ+dEwdWDbydG/PspgBAfRpGaYVdJYtvaC2JnoO8+Ik6qMWojfEJxSFLa0Pb0A892tun4gsxoEMjcreZ+YGyaBxAfqX0BSMfdrOgIYaZQjYrw9TRLlUT31QoQIDAQAB\n-----END PUBLIC KEY-----"
|
||||
privateKey_str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCquQQ5r6+yJI8CDFkXRp8vUsdD45ov8EP12ooLs56ca2DQXaSNGS9910bAPVA9chkp0mKIvKqjAsHz5Tl9EeNPblarGEeJUIxpxZtiSqNTpvtiD/TjhpzuHYic7RAfQ/h7p/ypE8ymU42pYjsB5t26Mv6XgkLV+jzrSf73HlCuS0iMyLmt6zz3Mw9izM13EpB8iFLtfbbYymycKTx4RAmPQLwhNGex/AlUIYxXP4R2yyaa4W6mEtc6aME2QuzJFxPgP3HJ9NBx/LWVn4skxWjZ7zg+VRQRHnjyVaSLu3Z5gN5ITWCyE32qaHJa6WBahZj5jWhRyAG1bQ+xKJa8lBL5AgMBAAECggEAUwv9SjJ0PSwbhNuM2w23kcWquROWhYtTA91zGY4esehqB/IFgb2mpIh8Gje5OKqwIu/8jpd4SiOlRYdUF8sD0DfUYRZGdj2AkFNX6tBz8tVfo6wvbB6naA1lzzBij1L5JO3qsjS3cJFkb+kg2yP66AC2Z+0tpfk8eRhdtshAZwfcd1DEGt1uAvYL1eaUK9HRvpt9lPeGcHERDl2hBd4uyaF0K1O+zF9y59nYbTySWPxRZq3sFEE85xRMlstD7YZi7W2gKvMFRD4/FKmrZ3m7aKJRITtyKOyyPcYmepNv3Qv7kk59Pg38n2WWQ0Ra/bCH3E48YNCnQvZMpitkTfJhoQKBgQDbnROOYTP8OTJ6f/qhoGjxeO3x1VOaOp8l0x7b0SCfoqNGS0Cyiqj72BmJtPMPqSTjn6MmNzqbg1KOdhXyzNozs+i5ccW1M56j96mr5I/Z0FpE3oyIHNfDDBlf9M8YQqEF9oYxniYYft9oapO7cRQkHER6qpvnHTavwlv4m78CXwKBgQDHAjs2YlpKDdI1lcbZJCc7TwtH+Pd2bUki8YXafWNcPhITQHbOZjr310eK1QJC6GJncjkOqbX7yv3ivvTO35FZTQhuA1xEG1P00FG8bE0tHYPIwQHi9y0eA5cieMdo8E6XYria1mw/3fqSQEsfZyJlR32JQIoGAipM8iO1X2nZpwKBgDkMFIhnt5lNQk+P7wsNIDWZtDWdtJnboHuy29E+Abt2A/O+mI/IdRz2hau/1WO8DFkUnszOi+rZshhPlGP90rCbi1igtTrcrdjp/KkqNjPea5R4OwkgdOu1uOG0NheXNzzVTQaWjk7Opjn5dWa7eP/oV+GFb/oZHJuLYVizHGsBAoGADA7rjZEKDYCm4w5PPSr+oY5ZjaPdQrS+gLqHtMRyN82fBMGcMUdqfUfzEstzVqCEDeaS5HuOBlK3bXzKkppjUTjksN3NQmcxgBz7RuJ9DqXCLXDcb2cwuafYCYOt+YLOEEgwDVm+t2P44dG5e46hO+fICH/7nP+WlpD5buz4GfMCgYB57r3g/6hi9WUDnfc7ZAzWMqR0EhJVYKYy+KFEtdIPzhkkIHq5RASe88E9kzoGoZFdb3tIjvGZWcHerirrqWkMsuQtP/Qi0zjieid5tAPj+r4kbiCVTw0E0jnmPBzGInQi7lpeTTKnG1fbyS5lBS+WmHfIuzpECgCkxhaT+LJJkg==\n-----END PRIVATE KEY-----"
|
||||
|
||||
# RSA 公钥加密实现
|
||||
def rsa_encrypt(self, text):
|
||||
try:
|
||||
key = RSA.import_key(self.publicKey_str)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
cipher_text = cipher.encrypt(text.encode('utf-8'))
|
||||
return b64encode(cipher_text).decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"RSA加密失败: {e}")
|
||||
return ""
|
||||
|
||||
# RSA 私钥解密实现
|
||||
def rsa_decrypt(self, text):
|
||||
try:
|
||||
key = RSA.import_key(self.privateKey_str)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
raw_bytes = b64decode(text.encode('utf-8'))
|
||||
|
||||
decrypted = b""
|
||||
offset = 0
|
||||
while offset < len(raw_bytes):
|
||||
chunk = raw_bytes[offset:offset + 256]
|
||||
decrypted += cipher.decrypt(chunk, None)
|
||||
offset += 256
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"RSA解密失败: {e}")
|
||||
return ""
|
||||
|
||||
def homeContent(self, filter):
|
||||
data = self.post(f"{self.host}/api/v1/app/screen/screenType", headers=self.headers).json()
|
||||
result = {}
|
||||
cate = {
|
||||
"类型": "type",
|
||||
"地区": "area",
|
||||
"年份": "year"
|
||||
}
|
||||
sort = {
|
||||
'key': 'sort',
|
||||
'name': '排序',
|
||||
'value': [{'n': '最新', 'v': 'NEWEST'}, {'n': '热门', 'v': 'HOT'}, {'n': '收藏', 'v': 'COLLECT'}]
|
||||
}
|
||||
classes = []
|
||||
filters = {}
|
||||
for k in data.get('data', []):
|
||||
classes.append({
|
||||
'type_name': k['name'],
|
||||
'type_id': str(k['id'])
|
||||
})
|
||||
filters[str(k['id'])] = []
|
||||
for v in k.get('children', []):
|
||||
if v['name'] in cate:
|
||||
filters[str(k['id'])].append({
|
||||
'name': v['name'],
|
||||
'key': cate[v['name']],
|
||||
'value': [{'n': i['name'], 'v': i['name']} for i in v.get('children', [])]
|
||||
})
|
||||
filters[str(k['id'])].append(sort)
|
||||
result['class'] = classes
|
||||
result['filters'] = filters
|
||||
return result
|
||||
|
||||
def homeVideoContent(self):
|
||||
jdata = {
|
||||
"condition": {
|
||||
"sreecnTypeEnum": "NEWEST"
|
||||
},
|
||||
"pageNum": 1,
|
||||
"pageSize": 40
|
||||
}
|
||||
data = self.post(f"{self.host}/api/v1/app/screen/screenMovie", headers=self.headers, json=jdata).json()
|
||||
return {'list': self.getlist(data.get('data', {}).get('records', []))}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
# 保持最纯粹的条件字段,移除任何空字符串占位
|
||||
condition = {
|
||||
'sreecnTypeEnum': 'NEWEST',
|
||||
'typeId': int(tid) if str(tid).isdigit() else tid
|
||||
}
|
||||
|
||||
if extend:
|
||||
if 'sort' in extend:
|
||||
condition['sreecnTypeEnum'] = extend.pop('sort')
|
||||
condition.update(extend)
|
||||
|
||||
jdata = {
|
||||
'condition': condition,
|
||||
'pageNum': int(pg),
|
||||
'pageSize': 40,
|
||||
}
|
||||
|
||||
try:
|
||||
data = self.post(f"{self.host}/api/v1/app/screen/screenMovie", headers=self.headers, json=jdata).json()
|
||||
result = {}
|
||||
if data and data.get('data') and 'records' in data['data']:
|
||||
result['list'] = self.getlist(data['data']['records'])
|
||||
else:
|
||||
result['list'] = []
|
||||
result['page'] = pg
|
||||
result['pagecount'] = 9999
|
||||
result['limit'] = 40
|
||||
result['total'] = 999999
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"分类获取错误: {e}")
|
||||
return {'list': [], 'page': pg}
|
||||
|
||||
def detailContent(self, ids):
|
||||
ids = ids[0].split('@@')
|
||||
jdata = {"id": int(ids[0]), "typeId": ids[-1]}
|
||||
v = self.post(f"{self.host}/api/v1/app/play/movieDesc", headers=self.headers, json=jdata).json()
|
||||
v = v.get('data', {})
|
||||
vod = {
|
||||
'type_name': v.get('typeId', ''),
|
||||
'vod_year': v.get('year', ''),
|
||||
'vod_area': v.get('area', ''),
|
||||
'vod_actor': v.get('star', ''),
|
||||
'vod_director': v.get('director', ''),
|
||||
'vod_content': v.get('introduce', ''),
|
||||
'vod_play_from': '',
|
||||
'vod_play_url': ''
|
||||
}
|
||||
|
||||
play_params = {
|
||||
"id": int(ids[0]),
|
||||
"source": 0,
|
||||
"typeId": ids[-1]
|
||||
}
|
||||
encrypt_payload = {"key": self.rsa_encrypt(json.dumps(play_params))}
|
||||
|
||||
c_res = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=encrypt_payload).json()
|
||||
decrypted_play_str = self.rsa_decrypt(c_res.get('data', ''))
|
||||
if not decrypted_play_str:
|
||||
return {'list': [vod]}
|
||||
|
||||
decrypted_play_data = json.loads(decrypted_play_str)
|
||||
l = decrypted_play_data.get('moviePlayerList', [])
|
||||
if not l:
|
||||
return {'list': [vod]}
|
||||
|
||||
n = {str(i['id']): i['moviePlayerName'] for i in l}
|
||||
|
||||
m = play_params.copy()
|
||||
m.update({'playerId': l[0]['id']})
|
||||
|
||||
first_source_payload = {"key": self.rsa_encrypt(json.dumps(m))}
|
||||
first_res = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=first_source_payload).json()
|
||||
|
||||
decrypted_first_str = self.rsa_decrypt(first_res.get('data', ''))
|
||||
if decrypted_first_str:
|
||||
decrypted_first_episode = json.loads(decrypted_first_str)
|
||||
pd = self.getv(m, decrypted_first_episode.get('episodeList', []))
|
||||
else:
|
||||
pd = {}
|
||||
|
||||
if len(l) > 1:
|
||||
with ThreadPoolExecutor(max_workers=len(l)-1) as executor:
|
||||
future_to_player = {executor.submit(self.getd, play_params, player): player for player in l[1:]}
|
||||
for future in future_to_player:
|
||||
try:
|
||||
o, p = future.result()
|
||||
if p:
|
||||
pd.update(self.getv(o, p))
|
||||
except Exception as e:
|
||||
print(f"多线路请求失败: {e}")
|
||||
w, e = [], []
|
||||
for i, x in pd.items():
|
||||
if x:
|
||||
w.append(n.get(i, '未知线路'))
|
||||
e.append(x)
|
||||
vod['vod_play_from'] = '$$$'.join(w)
|
||||
vod['vod_play_url'] = '$$$'.join(e)
|
||||
return {'list': [vod]}
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
jdata = {
|
||||
"condition": {
|
||||
"value": str(key)
|
||||
},
|
||||
"pageNum": int(pg),
|
||||
"pageSize": 40
|
||||
}
|
||||
try:
|
||||
data = self.post(f"{self.host}/api/v1/app/search/searchMovie", headers=self.headers, json=jdata).json()
|
||||
return {'list': self.getlist(data.get('data', {}).get('records', [])), 'page': pg}
|
||||
except Exception as e:
|
||||
print(f"搜索请求失败: {e}")
|
||||
return {'list': [], 'page': pg}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
raw_id_str = self.d64(id)
|
||||
if not raw_id_str:
|
||||
return {'parse': 0, 'url': ''}
|
||||
jdata = json.loads(raw_id_str)
|
||||
encrypt_payload = {"key": self.rsa_encrypt(json.dumps(jdata))}
|
||||
data = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=encrypt_payload).json()
|
||||
|
||||
try:
|
||||
decrypted_url_data = json.loads(self.rsa_decrypt(data.get('data', '')))
|
||||
playerUrl = decrypted_url_data.get('url', '')
|
||||
if not playerUrl:
|
||||
return {'parse': 0, 'url': ''}
|
||||
|
||||
params = {'playerUrl': playerUrl, 'playerId': jdata['playerId']}
|
||||
pd = self.fetch(f"{self.host}/api/v1/app/play/analysisMovieUrl", headers=self.headers, params=params).json()
|
||||
url, p = pd.get('data', ''), 0
|
||||
except Exception as e:
|
||||
print(f"解析流媒体直链失败: {e}")
|
||||
url, p = "", 0
|
||||
return {'parse': p, 'url': url, 'header': {'User-Agent': 'okhttp/4.12.0'}, 'danmaku': 'http://127.0.0.1:9978/proxy?do=diydanmu'}
|
||||
|
||||
def localProxy(self, param):
|
||||
pass
|
||||
|
||||
def liveContent(self, url):
|
||||
pass
|
||||
|
||||
def gettk(self):
|
||||
self.headers.update({'deviceId': self.getdid()})
|
||||
try:
|
||||
data = self.fetch(f"{self.host}/api/v1/app/user/visitorInfo", headers=self.headers).json()
|
||||
return data.get('data', {}).get('token', '')
|
||||
except:
|
||||
return ""
|
||||
|
||||
def getdid(self):
|
||||
did = self.getCache('ldid')
|
||||
if not did:
|
||||
hex_chars = '0123456789abcdef'
|
||||
did = ''.join(random.choice(hex_chars) for _ in range(16))
|
||||
self.setCache('ldid', did)
|
||||
return did
|
||||
|
||||
def getd(self, jdata, player):
|
||||
x = jdata.copy()
|
||||
x.update({'playerId': player['id']})
|
||||
encrypt_payload = {"key": self.rsa_encrypt(json.dumps(x))}
|
||||
response = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=encrypt_payload).json()
|
||||
decrypted_str = self.rsa_decrypt(response.get('data', ''))
|
||||
if decrypted_str:
|
||||
decrypted_episode = json.loads(decrypted_str)
|
||||
return x, decrypted_episode.get('episodeList', [])
|
||||
return x, []
|
||||
|
||||
def getv(self, d, c):
|
||||
f = {str(d['playerId']): ''}
|
||||
g = []
|
||||
for i in c:
|
||||
j = d.copy()
|
||||
j.update({'episodeId': i['id']})
|
||||
g.append(f"{i['episode']}${self.e64(json.dumps(j))}")
|
||||
f[str(d['playerId'])] = '#'.join(g)
|
||||
return f
|
||||
|
||||
def getlist(self, data):
|
||||
videos = []
|
||||
for i in data:
|
||||
if not i.get('id'):
|
||||
continue
|
||||
videos.append({
|
||||
'vod_id': f"{i['id']}@@{i.get('typeId', '')}",
|
||||
'vod_name': i.get('name', ''),
|
||||
'vod_pic': i.get('cover', ''),
|
||||
'vod_year': i.get('year', ''),
|
||||
'vod_remarks': i.get('totalEpisode', '')
|
||||
})
|
||||
return videos
|
||||
|
||||
def e64(self, text):
|
||||
try:
|
||||
return b64encode(text.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return ""
|
||||
|
||||
def d64(self, encoded_text):
|
||||
try:
|
||||
return b64decode(encoded_text.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return ""
|
||||
@@ -0,0 +1,1260 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# by @星河
|
||||
# 修复版本 - 参考最新三合一.js重构虎牙、斗鱼、B站直播逻辑
|
||||
# 修复:虎牙清晰度选择,确保ratio参数正确传递码率值
|
||||
# 修复:斗鱼切换分辨率只能播放1秒的问题(每次重新获取安全密钥和签名)
|
||||
# 修复:B站使用特殊UA和WBI签名绕过-352风控 [^90^][^30^]
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import hashlib
|
||||
import random
|
||||
import urllib.parse
|
||||
from base64 import b64decode, b64encode
|
||||
from urllib.parse import parse_qs
|
||||
import requests
|
||||
from pyquery import PyQuery as pq
|
||||
sys.path.append('..')
|
||||
from base.spider import Spider
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
|
||||
class Spider(Spider):
|
||||
|
||||
def init(self, extend=""):
|
||||
# 初始化B站WBI密钥
|
||||
self.bili_wbi_keys = None
|
||||
self.bili_wbi_expire = 0
|
||||
pass
|
||||
|
||||
def getName(self):
|
||||
return "直播"
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
headers = [
|
||||
{
|
||||
# 特殊UA绕过B站风控 [^90^]
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0"
|
||||
},
|
||||
{
|
||||
"User-Agent": "Dart/3.4 (dart:io)"
|
||||
}
|
||||
]
|
||||
|
||||
excepturl = 'https://www.baidu.com'
|
||||
|
||||
hosts = {
|
||||
"huya": ["https://www.huya.com", "https://mp.huya.com"],
|
||||
"douyu": "https://www.douyu.com",
|
||||
"wangyi": "https://cc.163.com",
|
||||
"bili": ["https://api.live.bilibili.com", "https://api.bilibili.com"]
|
||||
}
|
||||
|
||||
referers = {
|
||||
"huya": "https://live.cdn.huya.com",
|
||||
"douyu": "https://m.douyu.com",
|
||||
"bili": "https://live.bilibili.com"
|
||||
}
|
||||
|
||||
playheaders = {
|
||||
"wangyi": {
|
||||
"User-Agent": "ExoPlayer",
|
||||
"Connection": "Keep-Alive",
|
||||
"Icy-MetaData": "1"
|
||||
},
|
||||
"bili": {
|
||||
'Accept': '*/*',
|
||||
'Icy-MetaData': '1',
|
||||
'referer': 'https://live.bilibili.com',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
|
||||
},
|
||||
'huya': {
|
||||
'User-Agent': 'ExoPlayer',
|
||||
'Connection': 'Keep-Alive',
|
||||
'Icy-MetaData': '1'
|
||||
},
|
||||
'douyu': {
|
||||
'User-Agent': 'libmpv',
|
||||
'Icy-MetaData': '1'
|
||||
}
|
||||
}
|
||||
|
||||
# WBI签名相关常量 [^30^]
|
||||
MIXIN_KEY_ENC_TAB = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
||||
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
|
||||
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
|
||||
36, 20, 34, 44, 52
|
||||
]
|
||||
|
||||
def _get_bili_wbi_keys(self):
|
||||
"""获取B站WBI密钥 [^30^]"""
|
||||
try:
|
||||
# 检查缓存
|
||||
if self.bili_wbi_keys and time.time() < self.bili_wbi_expire:
|
||||
return self.bili_wbi_keys
|
||||
|
||||
# 从导航接口获取 - 使用特殊UA [^90^]
|
||||
resp = self.fetch(
|
||||
'https://api.bilibili.com/x/web-interface/nav',
|
||||
headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.bilibili.com/'
|
||||
}
|
||||
).json()
|
||||
|
||||
if resp.get('code') != 0:
|
||||
return None
|
||||
|
||||
img_url = resp['data']['wbi_img']['img_url']
|
||||
sub_url = resp['data']['wbi_img']['sub_url']
|
||||
|
||||
# 提取文件名作为key
|
||||
img_key = img_url.rsplit('/', 1)[1].split('.')[0]
|
||||
sub_key = sub_url.rsplit('/', 1)[1].split('.')[0]
|
||||
|
||||
self.bili_wbi_keys = (img_key, sub_key)
|
||||
self.bili_wbi_expire = time.time() + 86400 # 24小时过期
|
||||
|
||||
return self.bili_wbi_keys
|
||||
except Exception as e:
|
||||
print(f"获取B站WBI密钥失败: {e}")
|
||||
return None
|
||||
|
||||
def _get_mixin_key(self, orig: str):
|
||||
"""生成mixin_key [^30^]"""
|
||||
return ''.join([orig[i] for i in self.MIXIN_KEY_ENC_TAB])[:32]
|
||||
|
||||
def _enc_wbi(self, params: dict):
|
||||
"""WBI签名 [^30^]"""
|
||||
keys = self._get_bili_wbi_keys()
|
||||
if not keys:
|
||||
return params
|
||||
|
||||
img_key, sub_key = keys
|
||||
mixin_key = self._get_mixin_key(img_key + sub_key)
|
||||
|
||||
# 添加时间戳
|
||||
params['wts'] = round(time.time())
|
||||
|
||||
# 排序参数
|
||||
params = dict(sorted(params.items()))
|
||||
|
||||
# 过滤特殊字符
|
||||
params = {
|
||||
k: ''.join(filter(lambda c: c not in "!'()*", str(v)))
|
||||
for k, v in params.items()
|
||||
}
|
||||
|
||||
# 计算签名
|
||||
query = urllib.parse.urlencode(params)
|
||||
w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
|
||||
|
||||
params['w_rid'] = w_rid
|
||||
return params
|
||||
|
||||
def process_bili(self):
|
||||
"""获取B站分类列表 - 使用WBI签名 [^30^]"""
|
||||
try:
|
||||
# 尝试获取分类列表 - 使用特殊UA和WBI签名
|
||||
params = {'need_entrance': 1, 'parent_id': 0}
|
||||
signed_params = self._enc_wbi(params)
|
||||
|
||||
data = self.fetch(
|
||||
f'{self.hosts["bili"][0]}/room/v1/Area/getList',
|
||||
params=signed_params,
|
||||
headers=self.headers[0]
|
||||
).json()
|
||||
|
||||
if data.get('code') == 0 and data.get('data'):
|
||||
# 保存分类数据供后续使用
|
||||
self.bili_areas = data['data']
|
||||
return ('bili', [{'key': 'cate', 'name': '分类',
|
||||
'value': [{'n': i['name'], 'v': str(i['id'])}
|
||||
for i in data['data']]}])
|
||||
return 'bili', None
|
||||
except Exception as e:
|
||||
print(f"bili处理错误: {e}")
|
||||
# 使用默认分类
|
||||
return 'bili', [{'key': 'cate', 'name': '分类',
|
||||
'value': [{'n': '网游', 'v': '2'}, {'n': '手游', 'v': '3'},
|
||||
{'n': '单机', 'v': '6'}, {'n': '娱乐', 'v': '1'},
|
||||
{'n': '电台', 'v': '5'}, {'n': '虚拟主播', 'v': '9'},
|
||||
{'n': '生活', 'v': '10'}, {'n': '知识', 'v': '11'},
|
||||
{'n': '赛事', 'v': '13'}]}]
|
||||
|
||||
def process_douyu(self):
|
||||
try:
|
||||
self.dyufdata = self.fetch(
|
||||
f'{self.referers["douyu"]}/api/cate/list',
|
||||
headers=self.headers[1]
|
||||
).json()
|
||||
return ('douyu', [{'key': 'cate', 'name': '分类',
|
||||
'value': [{'n': i['cate1Name'], 'v': str(i['cate1Id'])}
|
||||
for i in self.dyufdata['data']['cate1Info']]}])
|
||||
except Exception as e:
|
||||
print(f"douyu错误: {e}")
|
||||
return 'douyu', None
|
||||
|
||||
def homeContent(self, filter):
|
||||
result = {}
|
||||
cateManual = {
|
||||
"虎牙": "huya",
|
||||
"斗鱼": "douyu",
|
||||
"网易": "wangyi",
|
||||
"B站": "bili"
|
||||
}
|
||||
classes = []
|
||||
filters = {
|
||||
'huya': [{'key': 'cate', 'name': '分类',
|
||||
'value': [{'n': '网游', 'v': '1'}, {'n': '单机', 'v': '2'},
|
||||
{'n': '娱乐', 'v': '8'}, {'n': '手游', 'v': '3'}]}]
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = {
|
||||
executor.submit(self.process_bili): 'bili',
|
||||
executor.submit(self.process_douyu): 'douyu'
|
||||
}
|
||||
|
||||
for future in futures:
|
||||
platform, filter_data = future.result()
|
||||
if filter_data:
|
||||
filters[platform] = filter_data
|
||||
|
||||
for k in cateManual:
|
||||
classes.append({
|
||||
'type_name': k,
|
||||
'type_id': cateManual[k]
|
||||
})
|
||||
|
||||
result['class'] = classes
|
||||
result['filters'] = filters
|
||||
return result
|
||||
|
||||
def homeVideoContent(self):
|
||||
pass
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
vdata = []
|
||||
result = {}
|
||||
pagecount = 9999
|
||||
result['page'] = pg
|
||||
result['limit'] = 90
|
||||
result['total'] = 999999
|
||||
if tid == 'wangyi':
|
||||
vdata, pagecount = self.wyccContent(tid, pg, filter, extend, vdata)
|
||||
elif 'bili' in tid:
|
||||
vdata, pagecount = self.biliContent(tid, pg, filter, extend, vdata)
|
||||
elif 'huya' in tid:
|
||||
vdata, pagecount = self.huyaContent(tid, pg, filter, extend, vdata)
|
||||
elif 'douyu' in tid:
|
||||
vdata, pagecount = self.douyuContent(tid, pg, filter, extend, vdata)
|
||||
result['list'] = vdata
|
||||
result['pagecount'] = pagecount
|
||||
return result
|
||||
|
||||
def wyccContent(self, tid, pg, filter, extend, vdata):
|
||||
params = {
|
||||
'format': 'json',
|
||||
'start': (int(pg) - 1) * 20,
|
||||
'size': '20',
|
||||
}
|
||||
response = self.fetch(f'{self.hosts[tid]}/api/category/live/', params=params, headers=self.headers[0]).json()
|
||||
for i in response['lives']:
|
||||
if i.get('cuteid'):
|
||||
bvdata = self.buildvod(
|
||||
vod_id=f"{tid}@@{i['cuteid']}",
|
||||
vod_name=i.get('title'),
|
||||
vod_pic=i.get('cover'),
|
||||
vod_remarks=i.get('nickname'),
|
||||
style={"type": "rect", "ratio": 1.33}
|
||||
)
|
||||
vdata.append(bvdata)
|
||||
return vdata, 9999
|
||||
|
||||
def biliContent(self, tid, pg, filter, extend, vdata):
|
||||
"""B站分类内容 - 使用WBI签名绕过风控 [^30^][^90^]"""
|
||||
try:
|
||||
# 分类列表 - 显示子分类
|
||||
if extend.get('cate') and pg == '1' and 'click' not in tid:
|
||||
# 从已保存的分类数据中找到对应分类的子分类
|
||||
if hasattr(self, 'bili_areas'):
|
||||
for area in self.bili_areas:
|
||||
if str(area['id']) == extend['cate']:
|
||||
for sub_area in area.get('list', []):
|
||||
v = self.buildvod(
|
||||
vod_id=f"click_{tid}@@{extend['cate']}@@{sub_area['id']}",
|
||||
vod_name=sub_area.get('name'),
|
||||
vod_pic=sub_area.get('pic'),
|
||||
vod_tag=1,
|
||||
style={"type": "oval", "ratio": 1}
|
||||
)
|
||||
vdata.append(v)
|
||||
return vdata, 1
|
||||
# 如果没有找到子分类,直接返回空,让用户进入房间列表
|
||||
return vdata, 1
|
||||
|
||||
# 房间列表 - 使用getList接口并添加WBI签名 [^30^]
|
||||
if 'click' in tid:
|
||||
# 子分类房间
|
||||
ids = tid.split('_')[1].split('@@')
|
||||
tid = ids[0]
|
||||
parent_area_id = ids[1]
|
||||
area_id = ids[2]
|
||||
else:
|
||||
# 默认使用分类ID作为parent_area_id,area_id为0表示该分类下所有
|
||||
parent_area_id = extend.get('cate', '2') # 默认网游
|
||||
area_id = 0
|
||||
|
||||
# 构建请求参数并添加WBI签名 [^30^]
|
||||
params = {
|
||||
'parent_area_id': parent_area_id,
|
||||
'area_id': area_id,
|
||||
'page': pg,
|
||||
'platform': 'web',
|
||||
'sort_type': 'online' # 按热度排序
|
||||
}
|
||||
signed_params = self._enc_wbi(params)
|
||||
|
||||
# 调用getList接口
|
||||
api_url = f'{self.hosts[tid][0]}/xlive/web-interface/v1/second/getList'
|
||||
data = self.fetch(api_url, params=signed_params, headers=self.headers[0]).json()
|
||||
|
||||
# 如果WBI签名失败,尝试不带签名
|
||||
if data.get('code') == -352:
|
||||
print("WBI签名失败,尝试无签名请求...")
|
||||
params = {
|
||||
'parent_area_id': parent_area_id,
|
||||
'area_id': area_id,
|
||||
'page': pg,
|
||||
'platform': 'web',
|
||||
'sort_type': 'online'
|
||||
}
|
||||
data = self.fetch(api_url, params=params, headers=self.headers[0]).json()
|
||||
|
||||
if data.get('code') == 0:
|
||||
room_list = data.get('data', {}).get('list', [])
|
||||
for room in room_list:
|
||||
if room.get('roomid'):
|
||||
# 处理在线人数显示
|
||||
online = room.get('online', 0)
|
||||
if online > 10000:
|
||||
online_str = f"{online / 10000:.1f}万"
|
||||
else:
|
||||
online_str = str(online)
|
||||
|
||||
v = self.buildvod(
|
||||
f"{tid}@@{room['roomid']}",
|
||||
room.get('title', '未知标题'),
|
||||
room.get('cover') or room.get('system_cover'),
|
||||
f"{online_str}人",
|
||||
0,
|
||||
room.get('uname', ''),
|
||||
style={"type": "rect", "ratio": 1.33}
|
||||
)
|
||||
vdata.append(v)
|
||||
|
||||
# 检查是否有更多数据
|
||||
has_more = data.get('data', {}).get('has_more', 0)
|
||||
if not has_more:
|
||||
pagecount = int(pg)
|
||||
else:
|
||||
pagecount = 9999
|
||||
else:
|
||||
print(f"B站API返回错误: {data.get('message', '未知错误')} (code: {data.get('code')})")
|
||||
pagecount = 1
|
||||
|
||||
return vdata, pagecount
|
||||
|
||||
except Exception as e:
|
||||
print(f"B站内容获取错误: {e}")
|
||||
return vdata, 1
|
||||
|
||||
def huyaContent(self, tid, pg, filter, extend, vdata):
|
||||
if extend.get('cate') and pg == '1' and 'click' not in tid:
|
||||
id = extend.get('cate')
|
||||
data = self.fetch(f'{self.referers[tid]}/liveconfig/game/bussLive?bussType={id}',
|
||||
headers=self.headers[1]).json()
|
||||
for i in data['data']:
|
||||
v = self.buildvod(
|
||||
vod_id=f"click_{tid}@@{int(i['gid'])}",
|
||||
vod_name=i.get('gameFullName'),
|
||||
vod_pic=f'https://huyaimg.msstatic.com/cdnimage/game/{int(i["gid"])}-MS.jpg',
|
||||
vod_tag=1,
|
||||
style={"type": "oval", "ratio": 1}
|
||||
)
|
||||
vdata.append(v)
|
||||
return vdata, 1
|
||||
else:
|
||||
gid = ''
|
||||
if 'click' in tid:
|
||||
ids = tid.split('_')[1].split('@@')
|
||||
tid = ids[0]
|
||||
gid = f'&gameId={ids[1]}'
|
||||
data = self.fetch(f'{self.hosts[tid][0]}/cache.php?m=LiveList&do=getLiveListByPage&tagAll=0{gid}&page={pg}',
|
||||
headers=self.headers[1]).json()
|
||||
for i in data['data']['datas']:
|
||||
if i.get('profileRoom'):
|
||||
v = self.buildvod(
|
||||
f"{tid}@@{i['profileRoom']}",
|
||||
i.get('introduction'),
|
||||
i.get('screenshot'),
|
||||
str(int(i.get('totalCount', '1')) / 10000) + '万',
|
||||
0,
|
||||
i.get('nick'),
|
||||
style={"type": "rect", "ratio": 1.33}
|
||||
|
||||
)
|
||||
vdata.append(v)
|
||||
return vdata, 9999
|
||||
|
||||
def douyuContent(self, tid, pg, filter, extend, vdata):
|
||||
if extend.get('cate') and pg == '1' and 'click' not in tid:
|
||||
for i in self.dyufdata['data']['cate2Info']:
|
||||
if str(i['cate1Id']) == extend['cate']:
|
||||
v = self.buildvod(
|
||||
vod_id=f"click_{tid}@@{i['cate2Id']}",
|
||||
vod_name=i.get('cate2Name'),
|
||||
vod_pic=i.get('icon'),
|
||||
vod_remarks=i.get('count'),
|
||||
vod_tag=1,
|
||||
style={"type": "oval", "ratio": 1}
|
||||
)
|
||||
vdata.append(v)
|
||||
return vdata, 1
|
||||
else:
|
||||
path = f'/japi/weblist/apinc/allpage/6/{pg}'
|
||||
if 'click' in tid:
|
||||
ids = tid.split('_')[1].split('@@')
|
||||
tid = ids[0]
|
||||
path = f'/gapi/rkc/directory/mixList/2_{ids[1]}/{pg}'
|
||||
url = f'{self.hosts[tid]}{path}'
|
||||
data = self.fetch(url, headers=self.headers[1]).json()
|
||||
for i in data['data']['rl']:
|
||||
v = self.buildvod(
|
||||
vod_id=f"{tid}@@{i['rid']}",
|
||||
vod_name=i.get('rn'),
|
||||
vod_pic=i.get('rs16'),
|
||||
vod_year=str(int(i.get('ol', 1)) / 10000) + '万',
|
||||
vod_remarks=i.get('nn'),
|
||||
style={"type": "rect", "ratio": 1.33}
|
||||
)
|
||||
vdata.append(v)
|
||||
return vdata, 9999
|
||||
|
||||
def detailContent(self, ids):
|
||||
ids = ids[0].split('@@')
|
||||
if ids[0] == 'wangyi':
|
||||
vod = self.wyccDetail(ids)
|
||||
elif ids[0] == 'bili':
|
||||
vod = self.biliDetail(ids)
|
||||
elif ids[0] == 'huya':
|
||||
vod = self.huyaDetail(ids)
|
||||
elif ids[0] == 'douyu':
|
||||
vod = self.douyuDetail(ids)
|
||||
return {'list': [vod]}
|
||||
|
||||
def wyccDetail(self, ids):
|
||||
try:
|
||||
vdata = self.getpq(f'{self.hosts[ids[0]]}/{ids[1]}', self.headers[0])('script').eq(-1).text()
|
||||
|
||||
def get_quality_name(vbr):
|
||||
if vbr <= 600:
|
||||
return "标清"
|
||||
elif vbr <= 1000:
|
||||
return "高清"
|
||||
elif vbr <= 2000:
|
||||
return "超清"
|
||||
else:
|
||||
return "蓝光"
|
||||
|
||||
data = json.loads(vdata)['props']['pageProps']['roomInfoInitData']
|
||||
name = data['live'].get('title', ids[0])
|
||||
vod = self.buildvod(vod_name=data.get('keywords_suffix'), vod_remarks=data['live'].get('title'),
|
||||
vod_content=data.get('description_suffix'))
|
||||
resolution_data = data['live']['quickplay']['resolution']
|
||||
all_streams = {}
|
||||
sorted_qualities = sorted(resolution_data.items(),
|
||||
key=lambda x: x[1]['vbr'],
|
||||
reverse=True)
|
||||
for quality, data in sorted_qualities:
|
||||
vbr = data['vbr']
|
||||
quality_name = get_quality_name(vbr)
|
||||
for cdn_name, url in data['cdn'].items():
|
||||
if cdn_name not in all_streams and type(url) == str and url.startswith('http'):
|
||||
all_streams[cdn_name] = []
|
||||
if isinstance(url, str) and url.startswith('http'):
|
||||
all_streams[cdn_name].extend([quality_name, url])
|
||||
plists = []
|
||||
names = []
|
||||
for i, (cdn_name, stream_list) in enumerate(all_streams.items(), 1):
|
||||
names.append(f'线路{i}')
|
||||
pstr = f"{name}${ids[0]}@@{self.e64(json.dumps(stream_list))}"
|
||||
plists.append(pstr)
|
||||
vod['vod_play_from'] = "$$$".join(names)
|
||||
vod['vod_play_url'] = "$$$".join(plists)
|
||||
return vod
|
||||
except Exception as e:
|
||||
return self.handle_exception(e)
|
||||
|
||||
def biliDetail(self, ids):
|
||||
"""
|
||||
B站直播详情 - 使用playUrl接口获取多清晰度
|
||||
"""
|
||||
try:
|
||||
room_id = ids[1]
|
||||
|
||||
# 获取房间信息
|
||||
info_res = self.fetch(
|
||||
f'{self.hosts["bili"][0]}/room/v1/Room/get_info?room_id={room_id}',
|
||||
headers=self.headers[0]
|
||||
).json()
|
||||
|
||||
if info_res.get('code') != 0:
|
||||
return self.handle_exception(Exception("获取房间信息失败"))
|
||||
|
||||
room_info = info_res['data']
|
||||
title = room_info.get('title', 'B站直播')
|
||||
|
||||
vod = self.buildvod(
|
||||
vod_name=title,
|
||||
type_name=f"{room_info.get('parent_area_name', '')}/{room_info.get('area_name', '')}",
|
||||
vod_director=room_info.get('uname', ''),
|
||||
vod_remarks=f"在线{room_info.get('online', 0)}人"
|
||||
)
|
||||
|
||||
# 获取播放地址信息
|
||||
play_res = self.fetch(
|
||||
f'{self.hosts["bili"][0]}/room/v1/Room/playUrl?cid={room_id}&qn=10000&platform=web',
|
||||
headers={
|
||||
**self.headers[0],
|
||||
'Referer': 'https://live.bilibili.com/',
|
||||
'Origin': 'https://live.bilibili.com'
|
||||
}
|
||||
).json()
|
||||
|
||||
if play_res.get('code') != 0:
|
||||
return self.handle_exception(Exception("获取播放地址失败"))
|
||||
|
||||
play_data = play_res['data']
|
||||
accept_quality = play_data.get('accept_quality', ['10000', '400', '250', '150'])
|
||||
quality_desc = {item['qn']: item['desc'] for item in play_data.get('quality_description', [])}
|
||||
|
||||
# 构建清晰度列表
|
||||
qualities = []
|
||||
for qn in sorted([int(q) for q in accept_quality], reverse=True):
|
||||
desc = quality_desc.get(qn, f'清晰度{qn}')
|
||||
qualities.append(f"{desc}$bili@@{room_id}@@{qn}")
|
||||
|
||||
vod['vod_play_from'] = 'B站直播'
|
||||
vod['vod_play_url'] = '#'.join(qualities)
|
||||
return vod
|
||||
|
||||
except Exception as e:
|
||||
print(f"B站详情错误: {e}")
|
||||
return self.handle_exception(e)
|
||||
|
||||
def huyaDetail(self, ids):
|
||||
"""
|
||||
虎牙播放详情 - 参考最新三合一.js重构
|
||||
支持多线路多清晰度选择
|
||||
核心算法:通过房间信息API获取uid、streamName和rateArray,为每个清晰度生成签名URL
|
||||
清晰度说明:
|
||||
- 蓝光8M/6M/4M/10M = 8000/6000/4000/10000 kbps = 1080P+
|
||||
- 蓝光 = 3000 kbps = 1080P
|
||||
- 超清 = 2000 kbps = 1080P (官方标准)
|
||||
- 高清 = 1200 kbps = 720P
|
||||
- 标清/流畅 = 500-800 kbps = 480P/540P
|
||||
"""
|
||||
try:
|
||||
room_id = ids[1]
|
||||
|
||||
# 1. 获取房间信息
|
||||
api_url = f'{self.hosts[ids[0]][1]}/cache.php?m=Live&do=profileRoom&roomid={room_id}'
|
||||
res = self.fetch(api_url, headers=self.headers[0])
|
||||
|
||||
if res.status_code != 200:
|
||||
return self.handle_exception(Exception(f"API请求失败: {res.status_code}"))
|
||||
|
||||
data = res.json()
|
||||
if not data or not data.get('data'):
|
||||
return self.handle_exception(Exception("房间数据为空"))
|
||||
|
||||
room_data = data['data']
|
||||
|
||||
# 2. 提取关键信息
|
||||
uid = room_data.get('profileInfo', {}).get('uid')
|
||||
stream_info = room_data.get('stream', {})
|
||||
live_data = room_data.get('liveData', {})
|
||||
|
||||
if not uid:
|
||||
return self.handle_exception(Exception("缺少uid"))
|
||||
|
||||
# 3. 获取streamName和码率信息
|
||||
base_stream_list = stream_info.get('baseSteamInfoList', [])
|
||||
if not base_stream_list:
|
||||
return self.handle_exception(Exception("无直播流信息"))
|
||||
|
||||
# 获取第一个CDN的streamName作为基准
|
||||
base_stream = base_stream_list[0]
|
||||
stream_name = base_stream.get('sStreamName')
|
||||
if not stream_name:
|
||||
return self.handle_exception(Exception("无法获取streamName"))
|
||||
|
||||
# 4. 构建VOD对象
|
||||
vod = self.buildvod(
|
||||
vod_name=live_data.get('introduction', '虎牙直播'),
|
||||
type_name=live_data.get('gameFullName', ''),
|
||||
vod_director=live_data.get('nick', ''),
|
||||
vod_remarks=live_data.get('contentIntro', ''),
|
||||
)
|
||||
|
||||
# 5. 获取所有CDN线路
|
||||
cdn_list = []
|
||||
for stream in base_stream_list:
|
||||
cdn_type = stream.get('sCdnType', 'AL')
|
||||
flv_url = stream.get('sFlvUrl', '')
|
||||
hls_url = stream.get('sHlsUrl', '')
|
||||
stream_name_cdn = stream.get('sStreamName', stream_name)
|
||||
|
||||
if flv_url:
|
||||
cdn_list.append({
|
||||
'cdn': cdn_type,
|
||||
'flv_base': flv_url,
|
||||
'hls_base': hls_url,
|
||||
'stream_name': stream_name_cdn,
|
||||
'priority': stream.get('iWebPriorityRate', 0)
|
||||
})
|
||||
|
||||
# 按优先级排序
|
||||
cdn_list.sort(key=lambda x: x['priority'], reverse=True)
|
||||
|
||||
# 6. 获取清晰度列表 (rateArray)
|
||||
rate_array = stream_info.get('rateArray', [])
|
||||
|
||||
# 如果没有rateArray,尝试从vMultiStreamInfo获取
|
||||
if not rate_array and 'vMultiStreamInfo' in room_data:
|
||||
rate_array = room_data['vMultiStreamInfo']
|
||||
|
||||
# 如果仍然没有,使用默认清晰度(按虎牙官方标准)
|
||||
if not rate_array:
|
||||
rate_array = [
|
||||
{'sDisplayName': '蓝光4M', 'iBitRate': 4000},
|
||||
{'sDisplayName': '蓝光', 'iBitRate': 3000},
|
||||
{'sDisplayName': '超清', 'iBitRate': 2000}, # 2000kbps = 1080P
|
||||
{'sDisplayName': '高清', 'iBitRate': 1200}, # 1200kbps = 720P
|
||||
{'sDisplayName': '流畅', 'iBitRate': 500}
|
||||
]
|
||||
|
||||
# 过滤和排序清晰度
|
||||
# 虎牙的rateArray中,iBitRate就是码率值,sDisplayName是显示名称
|
||||
# 需要确保:超清=2000kbps(1080P),高清=1200kbps(720P)
|
||||
filtered_rates = []
|
||||
seen_bitrates = set()
|
||||
|
||||
for rate in rate_array:
|
||||
bit_rate = rate.get('iBitRate', 0)
|
||||
name = rate.get('sDisplayName', '')
|
||||
|
||||
# 跳过重复的码率
|
||||
if bit_rate in seen_bitrates:
|
||||
continue
|
||||
|
||||
# 修正清晰度名称,确保符合虎牙标准
|
||||
# 2000kbps应该是超清(1080P),不是高清
|
||||
if bit_rate == 2000 and ('高清' in name or '720' in name):
|
||||
name = '超清' # 强制修正为超清
|
||||
elif bit_rate == 1200 and ('标清' in name or '480' in name):
|
||||
name = '高清' # 1200kbps对应高清
|
||||
elif bit_rate == 2000 and name == '原画':
|
||||
name = '超清' # 修正原画为超清
|
||||
|
||||
seen_bitrates.add(bit_rate)
|
||||
filtered_rates.append({
|
||||
'sDisplayName': name,
|
||||
'iBitRate': bit_rate
|
||||
})
|
||||
|
||||
# 按码率从高到低排序
|
||||
sorted_rates = sorted(filtered_rates, key=lambda x: x['iBitRate'], reverse=True)
|
||||
|
||||
# 7. 为每个CDN生成各清晰度的播放URL
|
||||
play_lines = []
|
||||
line_names = []
|
||||
|
||||
for cdn_idx, cdn in enumerate(cdn_list[:3]): # 最多取3个CDN
|
||||
cdn_name = cdn['cdn']
|
||||
line_names.append(f"线路{cdn_idx + 1}({cdn_name})")
|
||||
|
||||
qualities = []
|
||||
for rate in sorted_rates:
|
||||
quality_name = rate['sDisplayName']
|
||||
bit_rate = rate['iBitRate']
|
||||
|
||||
# 生成该清晰度的URL
|
||||
quality_url = self._generate_huya_play_url(
|
||||
cdn, uid, stream_name, bit_rate
|
||||
)
|
||||
|
||||
qualities.extend([quality_name, quality_url])
|
||||
|
||||
# 编码该线路的所有清晰度
|
||||
encoded_qualities = self.e64(json.dumps(qualities))
|
||||
play_lines.append(f"{live_data.get('introduction', '直播')}${ids[0]}@@{encoded_qualities}")
|
||||
|
||||
# 8. 构建播放数据
|
||||
vod['vod_play_from'] = "$$$".join(line_names)
|
||||
vod['vod_play_url'] = "$$$".join(play_lines)
|
||||
|
||||
return vod
|
||||
|
||||
except Exception as e:
|
||||
return self.handle_exception(e)
|
||||
|
||||
def _generate_huya_play_url(self, cdn, uid, stream_name, bit_rate):
|
||||
"""
|
||||
生成虎牙播放URL,参考最新三合一.js算法
|
||||
关键:ratio参数必须正确设置为iBitRate值(如2000、4000等)
|
||||
"""
|
||||
# 基础URL构建
|
||||
flv_base = cdn['flv_base']
|
||||
stream = cdn['stream_name']
|
||||
|
||||
# 生成时间戳和签名参数
|
||||
timestamp = int(time.time())
|
||||
seqid = f"{uid}{timestamp}"
|
||||
ss = hashlib.md5(f"{seqid}|huya_adr|102".encode()).hexdigest()
|
||||
ws_time = hex(timestamp + 21600)[2:] # 16进制,有效期6小时
|
||||
|
||||
# 计算wsSecret
|
||||
ws_secret = hashlib.md5(
|
||||
f"DWq8BcJ3h6DJt6TY_{uid}_{stream_name}_{ss}_{ws_time}".encode()
|
||||
).hexdigest()
|
||||
|
||||
# 构建基础URL
|
||||
base_url = f"{flv_base}/{stream}.flv"
|
||||
|
||||
# 关键修复:ratio参数直接使用iBitRate值
|
||||
# 超清=2000,高清=1200,蓝光=3000/4000/6000/8000
|
||||
if bit_rate > 0:
|
||||
ratio_param = f"ratio={bit_rate}"
|
||||
else:
|
||||
# 原画/0码率时,使用默认2000或从URL推断
|
||||
ratio_param = "ratio=2000"
|
||||
|
||||
# 构建完整URL
|
||||
play_url = (
|
||||
f"{base_url}?{ratio_param}&wsSecret={ws_secret}&wsTime={ws_time}"
|
||||
f"&ctype=huya_adr&seqid={seqid}&uid={uid}"
|
||||
f"&fs=bgct&ver=1&t=102"
|
||||
)
|
||||
|
||||
return play_url
|
||||
|
||||
def douyuDetail(self, ids):
|
||||
"""
|
||||
斗鱼播放详情 - 参考最新三合一.js重构
|
||||
核心算法:设备ID生成 -> 获取加密密钥 -> 计算签名 -> 获取播放地址
|
||||
修复:切换分辨率只能播放1秒的问题
|
||||
方案:存储房间号和码率信息,在playerContent中实时获取对应码率的URL
|
||||
"""
|
||||
try:
|
||||
channel = ids[1]
|
||||
headers = self.gethr(0, zr=f'{self.hosts[ids[0]]}/{channel}')
|
||||
|
||||
# 1. 初始化会话和设备ID (参考JS中的initialize和setupDeviceId)
|
||||
session = {}
|
||||
|
||||
# 请求首页获取Cookie
|
||||
try:
|
||||
home_res = self.fetch(f'{self.hosts[ids[0]]}/{channel}', headers=headers)
|
||||
if home_res.headers.get('Set-Cookie'):
|
||||
cookie_str = home_res.headers.get('Set-Cookie')
|
||||
# 解析dy_did
|
||||
did_match = re.search(r'dy_did=([a-f0-9]{32})', cookie_str)
|
||||
if did_match:
|
||||
device_id = did_match.group(1)
|
||||
else:
|
||||
device_id = self._generate_random_hex(32)
|
||||
else:
|
||||
device_id = self._generate_random_hex(32)
|
||||
except:
|
||||
device_id = self._generate_random_hex(32)
|
||||
|
||||
session['dy_did'] = device_id
|
||||
session['mantine-color-scheme-value'] = 'light'
|
||||
|
||||
# 2. 获取房间基本信息
|
||||
betard_res = self.fetch(f'{self.hosts[ids[0]]}/betard/{channel}', headers=headers).json()
|
||||
if not betard_res or not betard_res.get('room'):
|
||||
return self.handle_exception(Exception("获取房间信息失败"))
|
||||
|
||||
room_info = betard_res['room']
|
||||
vname = room_info.get('room_name', '斗鱼直播')
|
||||
|
||||
vod = self.buildvod(
|
||||
vod_name=vname,
|
||||
vod_remarks=room_info.get('second_lvl_name', ''),
|
||||
vod_director=room_info.get('nickname', ''),
|
||||
)
|
||||
|
||||
# 3. 获取安全密钥 (参考JS中的getSecurityKey)
|
||||
sec_url = f"{self.hosts[ids[0]]}/wgapi/livenc/liveweb/websec/getEncryption?did={device_id}"
|
||||
sec_res = self.fetch(sec_url, headers=headers).json()
|
||||
|
||||
if not sec_res or sec_res.get('error') != 0:
|
||||
return self.handle_exception(Exception("获取加密密钥失败"))
|
||||
|
||||
security_data = sec_res['data']
|
||||
secret_key = security_data.get('key')
|
||||
random_str = security_data.get('rand_str')
|
||||
enc_time = security_data.get('enc_time', 1)
|
||||
enc_data = security_data.get('enc_data')
|
||||
|
||||
# 4. 计算签名 (参考JS中的computeSignature)
|
||||
current_time = int(time.time())
|
||||
|
||||
# 迭代计算MD5
|
||||
current = random_str
|
||||
for _ in range(enc_time):
|
||||
current = hashlib.md5(f"{current}{secret_key}".encode()).hexdigest()
|
||||
|
||||
signature = hashlib.md5(f"{current}{secret_key}{channel}{current_time}".encode()).hexdigest()
|
||||
|
||||
# 5. 请求播放地址 (参考JS中的requestStreamData)
|
||||
play_payload = {
|
||||
'enc_data': enc_data,
|
||||
'tt': str(current_time),
|
||||
'did': device_id,
|
||||
'auth': signature,
|
||||
'cdn': '',
|
||||
'rate': '',
|
||||
'hevc': '0',
|
||||
'fa': '0',
|
||||
'ive': '0'
|
||||
}
|
||||
|
||||
play_api = f"{self.hosts[ids[0]]}/lapi/live/getH5PlayV1/{channel}"
|
||||
|
||||
# 构建请求头带Cookie
|
||||
play_headers = headers.copy()
|
||||
cookie_str = '; '.join([f"{k}={v}" for k, v in session.items()])
|
||||
play_headers['Cookie'] = cookie_str
|
||||
play_headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
|
||||
play_res = requests.post(play_api, data=play_payload, headers=play_headers, timeout=10).json()
|
||||
|
||||
if not play_res or play_res.get('error') != 0:
|
||||
# 尝试旧版API
|
||||
play_res = self._try_legacy_douyu_api(channel, device_id, signature, current_time, play_headers)
|
||||
if not play_res:
|
||||
return self.handle_exception(Exception("获取播放地址失败"))
|
||||
|
||||
stream_info = play_res.get('data', {})
|
||||
|
||||
# 6. 检查并更新设备ID (参考JS中的checkAndUpdateDeviceId)
|
||||
rtmp_live = stream_info.get('rtmp_live', '')
|
||||
if rtmp_live:
|
||||
did_match = re.search(r'did=([a-f0-9]{32})', rtmp_live)
|
||||
if did_match and did_match.group(1) != device_id:
|
||||
device_id = did_match.group(1)
|
||||
session['dy_did'] = device_id
|
||||
# 重新请求
|
||||
play_payload['did'] = device_id
|
||||
play_res = requests.post(play_api, data=play_payload, headers=play_headers, timeout=10).json()
|
||||
if play_res and play_res.get('error') == 0:
|
||||
stream_info = play_res.get('data', {})
|
||||
|
||||
# 7. 提取播放URL和多码率信息
|
||||
stream_url = None
|
||||
if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
|
||||
stream_url = f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
|
||||
elif stream_info.get('hls_url'):
|
||||
stream_url = stream_info['hls_url']
|
||||
|
||||
if not stream_url:
|
||||
return self.handle_exception(Exception("无法获取播放地址"))
|
||||
|
||||
# 8. 构建多码率选项
|
||||
multirates = stream_info.get('multirates', [])
|
||||
|
||||
# 关键修复:存储房间号和码率信息,而不是直接存储URL
|
||||
# 这样在切换清晰度时可以重新获取对应码率的签名URL
|
||||
qualities = []
|
||||
|
||||
if multirates:
|
||||
# 按码率排序
|
||||
sorted_rates = sorted(multirates, key=lambda x: x.get('bit', 0), reverse=True)
|
||||
for rate in sorted_rates:
|
||||
bit_rate = rate.get('rate', -1)
|
||||
name = rate.get('name', f"{bit_rate}P")
|
||||
|
||||
# 存储格式:码率值,用于playerContent中重新获取URL
|
||||
# 使用特殊标记#来区分这是码率值而不是URL
|
||||
qualities.extend([name, f"#{bit_rate}"])
|
||||
else:
|
||||
# 只有原画
|
||||
qualities = ['原画', '#-1']
|
||||
|
||||
# 同时存储房间号和设备信息,用于重新获取URL
|
||||
# 格式:房间号|设备ID|签名信息(base64编码)
|
||||
session_info = {
|
||||
'channel': channel,
|
||||
'device_id': device_id,
|
||||
'secret_key': secret_key,
|
||||
'random_str': random_str,
|
||||
'enc_time': enc_time,
|
||||
'enc_data': enc_data
|
||||
}
|
||||
encoded_session = self.e64(json.dumps(session_info))
|
||||
|
||||
# 9. 构建播放数据
|
||||
# vod_play_url格式:房间名$平台@@base64(清晰度列表)@@base64(会话信息)
|
||||
encoded_qualities = self.e64(json.dumps(qualities))
|
||||
vod['vod_play_from'] = '斗鱼直播'
|
||||
vod['vod_play_url'] = f"{vname}${ids[0]}@@{encoded_qualities}@@{encoded_session}"
|
||||
|
||||
return vod
|
||||
|
||||
except Exception as e:
|
||||
return self.handle_exception(e)
|
||||
|
||||
def _generate_random_hex(self, length):
|
||||
"""生成随机十六进制字符串"""
|
||||
hex_chars = '0123456789abcdef'
|
||||
return ''.join(random.choice(hex_chars) for _ in range(length))
|
||||
|
||||
def _try_legacy_douyu_api(self, channel, device_id, signature, timestamp, headers):
|
||||
"""尝试使用旧版API获取播放地址"""
|
||||
try:
|
||||
legacy_payload = {
|
||||
'did': device_id,
|
||||
'tt': str(timestamp),
|
||||
'sign': signature,
|
||||
'cdn': '',
|
||||
'rate': '-1',
|
||||
'ver': 'Douyu_223061205',
|
||||
'iar': '1',
|
||||
'ive': '1',
|
||||
'hevc': '0',
|
||||
'fa': '0'
|
||||
}
|
||||
legacy_api = f"https://www.douyu.com/lapi/live/getH5Play/{channel}"
|
||||
res = requests.post(legacy_api, data=legacy_payload, headers=headers, timeout=10)
|
||||
return res.json() if res.status_code == 200 else None
|
||||
except:
|
||||
return None
|
||||
|
||||
def _get_douyu_play_url(self, channel, device_id, secret_key, random_str, enc_time, enc_data, rate):
|
||||
"""
|
||||
获取斗鱼指定码率的播放URL(带签名)
|
||||
用于切换清晰度时重新获取URL
|
||||
"""
|
||||
try:
|
||||
current_time = int(time.time())
|
||||
|
||||
# 重新计算签名
|
||||
current = random_str
|
||||
for _ in range(enc_time):
|
||||
current = hashlib.md5(f"{current}{secret_key}".encode()).hexdigest()
|
||||
|
||||
signature = hashlib.md5(f"{current}{secret_key}{channel}{current_time}".encode()).hexdigest()
|
||||
|
||||
# 构建请求
|
||||
play_payload = {
|
||||
'enc_data': enc_data,
|
||||
'tt': str(current_time),
|
||||
'did': device_id,
|
||||
'auth': signature,
|
||||
'cdn': '',
|
||||
'rate': str(rate) if rate > 0 else '',
|
||||
'hevc': '0',
|
||||
'fa': '0',
|
||||
'ive': '0'
|
||||
}
|
||||
|
||||
play_api = f"https://www.douyu.com/lapi/live/getH5PlayV1/{channel}"
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.headers[0]['User-Agent'],
|
||||
'Referer': f'https://www.douyu.com/{channel}',
|
||||
'Origin': 'https://www.douyu.com',
|
||||
'Cookie': f'dy_did={device_id}; mantine-color-scheme-value=light',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
play_res = requests.post(play_api, data=play_payload, headers=headers, timeout=10).json()
|
||||
|
||||
if not play_res or play_res.get('error') != 0:
|
||||
# 尝试旧版API
|
||||
return self._get_douyu_play_url_legacy(channel, device_id, signature, current_time, rate)
|
||||
|
||||
stream_info = play_res.get('data', {})
|
||||
|
||||
# 检查设备ID是否匹配
|
||||
if stream_info.get('rtmp_live'):
|
||||
did_match = re.search(r'did=([a-f0-9]{32})', stream_info['rtmp_live'])
|
||||
if did_match and did_match.group(1) != device_id:
|
||||
# 设备ID不匹配,使用新设备ID重新获取
|
||||
return self._get_douyu_play_url(channel, did_match.group(1), secret_key, random_str, enc_time, enc_data, rate)
|
||||
|
||||
if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
|
||||
return f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
|
||||
elif stream_info.get('hls_url'):
|
||||
return stream_info['hls_url']
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"获取斗鱼播放URL失败: {e}")
|
||||
return None
|
||||
|
||||
def _get_douyu_play_url_legacy(self, channel, device_id, signature, timestamp, rate):
|
||||
"""使用旧版API获取斗鱼播放URL"""
|
||||
try:
|
||||
legacy_payload = {
|
||||
'did': device_id,
|
||||
'tt': str(timestamp),
|
||||
'sign': signature,
|
||||
'cdn': '',
|
||||
'rate': str(rate) if rate > 0 else '-1',
|
||||
'ver': 'Douyu_223061205',
|
||||
'iar': '1',
|
||||
'ive': '1',
|
||||
'hevc': '0',
|
||||
'fa': '0'
|
||||
}
|
||||
legacy_api = f"https://www.douyu.com/lapi/live/getH5Play/{channel}"
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.headers[0]['User-Agent'],
|
||||
'Referer': f'https://www.douyu.com/{channel}',
|
||||
'Cookie': f'dy_did={device_id}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
res = requests.post(legacy_api, data=legacy_payload, headers=headers, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
if data.get('error') == 0:
|
||||
stream_info = data.get('data', {})
|
||||
if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
|
||||
return f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
|
||||
return None
|
||||
except:
|
||||
return None
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
pass
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
try:
|
||||
ids = id.split('@@')
|
||||
p = 1
|
||||
if ids[0] in ['wangyi']:
|
||||
p, url = 0, json.loads(self.d64(ids[1]))
|
||||
elif ids[0] == 'bili':
|
||||
p, url = self.biliplay(ids)
|
||||
elif ids[0] == 'huya':
|
||||
p, url = self.huyaplay(ids)
|
||||
elif ids[0] == 'douyu':
|
||||
p, url = self.douyuplay(ids)
|
||||
return {'parse': p, 'url': url, 'header': self.playheaders[ids[0]]}
|
||||
except Exception as e:
|
||||
return {'parse': 1, 'url': self.excepturl, 'header': self.headers[0]}
|
||||
|
||||
def biliplay(self, ids):
|
||||
"""
|
||||
B站播放解析 - 使用playUrl接口获取指定清晰度直播流
|
||||
ids: [平台, 房间号, 清晰度qn]
|
||||
支持多线路返回
|
||||
"""
|
||||
try:
|
||||
room_id = ids[1]
|
||||
qn = ids[2] if len(ids) > 2 else '10000'
|
||||
|
||||
# 使用playUrl接口获取直播流
|
||||
play_url = f'{self.hosts["bili"][0]}/room/v1/Room/playUrl?cid={room_id}&qn={qn}&platform=web'
|
||||
data = self.fetch(play_url, headers={
|
||||
**self.headers[0],
|
||||
'Referer': 'https://live.bilibili.com/',
|
||||
'Origin': 'https://live.bilibili.com'
|
||||
}).json()
|
||||
|
||||
if data.get('code') != 0:
|
||||
return 1, self.excepturl
|
||||
|
||||
play_data = data['data']
|
||||
durl_list = play_data.get('durl', [])
|
||||
|
||||
if not durl_list:
|
||||
return 1, self.excepturl
|
||||
|
||||
# 构建多线路结果 [线路1, URL1, 线路2, URL2, ...]
|
||||
urls = []
|
||||
for idx, item in enumerate(durl_list, 1):
|
||||
url = item.get('url')
|
||||
if url:
|
||||
urls.extend([f'线路{idx}', url])
|
||||
|
||||
# 如果只有一条线路,直接返回URL
|
||||
if len(urls) == 2:
|
||||
return 0, urls[1] # 直接返回URL字符串
|
||||
|
||||
return 0, urls
|
||||
|
||||
except Exception as e:
|
||||
print(f"B站播放错误: {e}")
|
||||
return 1, self.excepturl
|
||||
|
||||
def huyaplay(self, ids):
|
||||
"""
|
||||
虎牙播放解析 - 返回所有清晰度选项供用户选择
|
||||
ids[1] 格式: base64编码的 [清晰度名称1, URL1, 清晰度名称2, URL2, ...]
|
||||
"""
|
||||
try:
|
||||
# ids[1] 是编码后的播放地址列表 [名称1, URL1, 名称2, URL2, ...]
|
||||
decoded = json.loads(self.d64(ids[1]))
|
||||
# decoded 是一个列表,奇数索引是名称,偶数索引是URL
|
||||
return 0, decoded
|
||||
except Exception as e:
|
||||
print(f"虎牙播放解析错误: {e}")
|
||||
return 1, self.excepturl
|
||||
|
||||
def douyuplay(self, ids):
|
||||
"""
|
||||
斗鱼播放解析 - 实时获取对应码率的播放URL
|
||||
ids格式: [平台, base64(清晰度列表), base64(会话信息)]
|
||||
清晰度列表: [名称1, #码率1, 名称2, #码率2, ...]
|
||||
#表示这是码率值,需要重新获取URL
|
||||
"""
|
||||
try:
|
||||
if len(ids) < 3:
|
||||
# 兼容旧格式
|
||||
decoded = json.loads(self.d64(ids[1]))
|
||||
return 0, decoded
|
||||
|
||||
# 解析清晰度列表和会话信息
|
||||
qualities = json.loads(self.d64(ids[1]))
|
||||
session_info = json.loads(self.d64(ids[2]))
|
||||
|
||||
channel = session_info['channel']
|
||||
device_id = session_info['device_id']
|
||||
secret_key = session_info['secret_key']
|
||||
random_str = session_info['random_str']
|
||||
enc_time = session_info['enc_time']
|
||||
enc_data = session_info['enc_data']
|
||||
|
||||
# 为每个清晰度实时获取播放URL
|
||||
result = []
|
||||
for i in range(0, len(qualities), 2):
|
||||
name = qualities[i]
|
||||
rate_marker = qualities[i + 1]
|
||||
|
||||
# 解析码率值(去掉#前缀)
|
||||
if rate_marker.startswith('#'):
|
||||
rate = int(rate_marker[1:])
|
||||
else:
|
||||
rate = -1
|
||||
|
||||
# 实时获取对应码率的URL
|
||||
play_url = self._get_douyu_play_url(
|
||||
channel, device_id, secret_key, random_str,
|
||||
enc_time, enc_data, rate
|
||||
)
|
||||
|
||||
if play_url:
|
||||
result.extend([name, play_url])
|
||||
|
||||
if not result:
|
||||
return 1, self.excepturl
|
||||
|
||||
return 0, result
|
||||
except Exception as e:
|
||||
print(f"斗鱼播放解析错误: {e}")
|
||||
return 1, self.excepturl
|
||||
|
||||
def localProxy(self, param):
|
||||
pass
|
||||
|
||||
def e64(self, text):
|
||||
try:
|
||||
text_bytes = text.encode('utf-8')
|
||||
encoded_bytes = b64encode(text_bytes)
|
||||
return encoded_bytes.decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"Base64编码错误: {str(e)}")
|
||||
return ""
|
||||
|
||||
def d64(self, encoded_text):
|
||||
try:
|
||||
encoded_bytes = encoded_text.encode('utf-8')
|
||||
decoded_bytes = b64decode(encoded_bytes)
|
||||
return decoded_bytes.decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"Base64解码错误: {str(e)}")
|
||||
return ""
|
||||
|
||||
def josn_to_params(self, params, skip_empty=False):
|
||||
query = []
|
||||
for k, v in params.items():
|
||||
if skip_empty and not v:
|
||||
continue
|
||||
query.append(f"{k}={v}")
|
||||
return "&".join(query)
|
||||
|
||||
def params_to_json(self, query_string):
|
||||
parsed_data = parse_qs(query_string)
|
||||
result = {key: value[0] for key, value in parsed_data.items()}
|
||||
return result
|
||||
|
||||
def buildvod(self, vod_id='', vod_name='', vod_pic='', vod_year='', vod_tag='', vod_remarks='', style='',
|
||||
type_name='', vod_area='', vod_actor='', vod_director='',
|
||||
vod_content='', vod_play_from='', vod_play_url=''):
|
||||
vod = {
|
||||
'vod_id': vod_id,
|
||||
'vod_name': vod_name,
|
||||
'vod_pic': vod_pic,
|
||||
'vod_year': vod_year,
|
||||
'vod_tag': 'folder' if vod_tag else '',
|
||||
'vod_remarks': vod_remarks,
|
||||
'style': style,
|
||||
'type_name': type_name,
|
||||
'vod_area': vod_area,
|
||||
'vod_actor': vod_actor,
|
||||
'vod_director': vod_director,
|
||||
'vod_content': vod_content,
|
||||
'vod_play_from': vod_play_from,
|
||||
'vod_play_url': vod_play_url
|
||||
}
|
||||
vod = {key: value for key, value in vod.items() if value}
|
||||
return vod
|
||||
|
||||
def getpq(self, url, headers=None, cookies=None):
|
||||
data = self.fetch(url, headers=headers, cookies=cookies).text
|
||||
try:
|
||||
return pq(data)
|
||||
except Exception as e:
|
||||
print(f"解析页面错误: {str(e)}")
|
||||
return pq(data.encode('utf-8'))
|
||||
|
||||
def gethr(self, index, rf='', zr=''):
|
||||
headers = self.headers[index]
|
||||
if zr:
|
||||
headers['referer'] = zr
|
||||
else:
|
||||
headers['referer'] = f"{self.referers[rf]}/"
|
||||
return headers
|
||||
|
||||
def handle_exception(self, e):
|
||||
print(f"报错: {str(e)}")
|
||||
return {'vod_play_from': '哎呀翻车啦', 'vod_play_url': f'翻车啦${self.excepturl}'}
|
||||
@@ -0,0 +1,343 @@
|
||||
# coding = utf-8
|
||||
# !/usr/bin/python
|
||||
|
||||
"""
|
||||
"""
|
||||
|
||||
from Crypto.Util.Padding import unpad
|
||||
from Crypto.Util.Padding import pad
|
||||
from urllib.parse import unquote
|
||||
from Crypto.Cipher import ARC4
|
||||
from urllib.parse import quote
|
||||
from base.spider import Spider
|
||||
from Crypto.Cipher import AES
|
||||
from bs4 import BeautifulSoup
|
||||
from base64 import b64decode
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import binascii
|
||||
import requests
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
|
||||
sys.path.append('..')
|
||||
|
||||
xurl = "https://app.whjzjx.cn"
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Linux; Android 12; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.101 Mobile Safari/537.36'
|
||||
}
|
||||
|
||||
headerf = {
|
||||
"platform": "1",
|
||||
"user_agent": "Mozilla/5.0 (Linux; Android 9; V1938T Build/PQ3A.190705.08211809; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36",
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
}
|
||||
|
||||
times = int(time.time() * 1000)
|
||||
|
||||
data = {
|
||||
"device": "2a50580e69d38388c94c93605241fb306",
|
||||
"package_name": "com.jz.xydj",
|
||||
"android_id": "ec1280db12795506",
|
||||
"install_first_open": True,
|
||||
"first_install_time": 1752505243345,
|
||||
"last_update_time": 1752505243345,
|
||||
"report_link_url": "",
|
||||
"authorization": "",
|
||||
"timestamp": times
|
||||
}
|
||||
|
||||
plain_text = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
key = "B@ecf920Od8A4df7"
|
||||
key_bytes = key.encode('utf-8')
|
||||
plain_bytes = plain_text.encode('utf-8')
|
||||
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
||||
padded_data = pad(plain_bytes, AES.block_size)
|
||||
ciphertext = cipher.encrypt(padded_data)
|
||||
encrypted = base64.b64encode(ciphertext).decode('utf-8')
|
||||
|
||||
response = requests.post("https://u.shytkjgs.com/user/v3/account/login", headers=headerf, data=encrypted)
|
||||
response_data = response.json()
|
||||
Authorization = response_data['data']['token']
|
||||
|
||||
headerx = {
|
||||
'authorization': Authorization,
|
||||
'platform': '1',
|
||||
'version_name': '3.8.3.1'
|
||||
}
|
||||
|
||||
class Spider(Spider):
|
||||
global xurl
|
||||
global headerx
|
||||
global headers
|
||||
|
||||
def getName(self):
|
||||
return "首页"
|
||||
|
||||
def init(self, extend):
|
||||
pass
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def extract_middle_text(self, text, start_str, end_str, pl, start_index1: str = '', end_index2: str = ''):
|
||||
if pl == 3:
|
||||
plx = []
|
||||
while True:
|
||||
start_index = text.find(start_str)
|
||||
if start_index == -1:
|
||||
break
|
||||
end_index = text.find(end_str, start_index + len(start_str))
|
||||
if end_index == -1:
|
||||
break
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
plx.append(middle_text)
|
||||
text = text.replace(start_str + middle_text + end_str, '')
|
||||
if len(plx) > 0:
|
||||
purl = ''
|
||||
for i in range(len(plx)):
|
||||
matches = re.findall(start_index1, plx[i])
|
||||
output = ""
|
||||
for match in matches:
|
||||
match3 = re.search(r'(?:^|[^0-9])(\d+)(?:[^0-9]|$)', match[1])
|
||||
if match3:
|
||||
number = match3.group(1)
|
||||
else:
|
||||
number = 0
|
||||
if 'http' not in match[0]:
|
||||
output += f"#{match[1]}${number}{xurl}{match[0]}"
|
||||
else:
|
||||
output += f"#{match[1]}${number}{match[0]}"
|
||||
output = output[1:]
|
||||
purl = purl + output + "$$$"
|
||||
purl = purl[:-3]
|
||||
return purl
|
||||
else:
|
||||
return ""
|
||||
else:
|
||||
start_index = text.find(start_str)
|
||||
if start_index == -1:
|
||||
return ""
|
||||
end_index = text.find(end_str, start_index + len(start_str))
|
||||
if end_index == -1:
|
||||
return ""
|
||||
|
||||
if pl == 0:
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
return middle_text.replace("\\", "")
|
||||
|
||||
if pl == 1:
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
matches = re.findall(start_index1, middle_text)
|
||||
if matches:
|
||||
jg = ' '.join(matches)
|
||||
return jg
|
||||
|
||||
if pl == 2:
|
||||
middle_text = text[start_index + len(start_str):end_index]
|
||||
matches = re.findall(start_index1, middle_text)
|
||||
if matches:
|
||||
new_list = [f'{item}' for item in matches]
|
||||
jg = '$$$'.join(new_list)
|
||||
return jg
|
||||
|
||||
def homeContent(self, filter):
|
||||
result = {}
|
||||
result = {"class": [{"type_id": "1", "type_name": "剧场"},
|
||||
{"type_id": "3", "type_name": "新剧"},
|
||||
{"type_id": "2", "type_name": "热播"},
|
||||
{"type_id": "7", "type_name": "星选"},
|
||||
{"type_id": "5", "type_name": "阳光"}],
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def homeVideoContent(self):
|
||||
videos = []
|
||||
|
||||
url= f'{xurl}/v1/theater/home_page?theater_class_id=1&class2_id=4&page_num=1&page_size=24'
|
||||
detail = requests.get(url=url, headers=headerx)
|
||||
detail.encoding = "utf-8"
|
||||
if detail.status_code == 200:
|
||||
data = detail.json()
|
||||
|
||||
for vod in data['data']['list']:
|
||||
|
||||
name = vod['theater']['title']
|
||||
|
||||
id = vod['theater']['id']
|
||||
|
||||
pic = vod['theater']['cover_url']
|
||||
|
||||
remark = vod['theater']['play_amount_str']
|
||||
|
||||
video = {
|
||||
"vod_id": id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark
|
||||
}
|
||||
videos.append(video)
|
||||
|
||||
result = {'list': videos}
|
||||
return result
|
||||
|
||||
def categoryContent(self, cid, pg, filter, ext):
|
||||
result = {}
|
||||
videos = []
|
||||
|
||||
url = f'{xurl}/v1/theater/home_page?theater_class_id={cid}&page_num={pg}&page_size=24'
|
||||
detail = requests.get(url=url,headers=headerx)
|
||||
detail.encoding = "utf-8"
|
||||
if detail.status_code == 200:
|
||||
data = detail.json()
|
||||
|
||||
for vod in data['data']['list']:
|
||||
|
||||
name = vod['theater']['title']
|
||||
|
||||
id = vod['theater']['id']
|
||||
|
||||
pic = vod['theater']['cover_url']
|
||||
|
||||
remark = vod['theater']['theme']
|
||||
|
||||
video = {
|
||||
"vod_id": id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark
|
||||
}
|
||||
videos.append(video)
|
||||
|
||||
result = {'list': videos}
|
||||
result['page'] = pg
|
||||
result['pagecount'] = 9999
|
||||
result['limit'] = 90
|
||||
result['total'] = 999999
|
||||
return result
|
||||
|
||||
def detailContent(self, ids):
|
||||
did = ids[0]
|
||||
result = {}
|
||||
videos = []
|
||||
xianlu = ''
|
||||
bofang = ''
|
||||
|
||||
url = f'{xurl}/v2/theater_parent/detail?theater_parent_id={did}'
|
||||
detail = requests.get(url=url, headers=headerx)
|
||||
detail.encoding = "utf-8"
|
||||
if detail.status_code == 200:
|
||||
data = detail.json()
|
||||
|
||||
url = 'https://fs-im-kefu.7moor-fs1.com/ly/4d2c3f00-7d4c-11e5-af15-41bf63ae4ea0/1732707176882/jiduo.txt'
|
||||
response = requests.get(url)
|
||||
response.encoding = 'utf-8'
|
||||
code = response.text
|
||||
name = self.extract_middle_text(code, "s1='", "'", 0)
|
||||
Jumps = self.extract_middle_text(code, "s2='", "'", 0)
|
||||
|
||||
content = '剧情:' + data['data']['introduction']
|
||||
|
||||
area = data['data']['desc_tags'][0]
|
||||
|
||||
remarks = data['data']['filing']
|
||||
|
||||
# 修复剧集只有一集的问题 - 检查theaters数据是否存在且不为空
|
||||
if 'theaters' in data['data'] and data['data']['theaters']:
|
||||
for sou in data['data']['theaters']:
|
||||
id = sou['son_video_url']
|
||||
name = sou['num']
|
||||
bofang = bofang + str(name) + '$' + id + '#'
|
||||
|
||||
bofang = bofang[:-1] if bofang.endswith('#') else bofang
|
||||
xianlu = '星芽'
|
||||
else:
|
||||
# 如果没有theaters数据,检查是否有单个视频URL
|
||||
if 'video_url' in data['data'] and data['data']['video_url']:
|
||||
bofang = '1$' + data['data']['video_url']
|
||||
xianlu = '星芽'
|
||||
else:
|
||||
bofang = Jumps
|
||||
xianlu = '1'
|
||||
|
||||
videos.append({
|
||||
"vod_id": did,
|
||||
"vod_content": content,
|
||||
"vod_remarks": remarks,
|
||||
"vod_area": area,
|
||||
"vod_play_from": xianlu,
|
||||
"vod_play_url": bofang
|
||||
})
|
||||
|
||||
result['list'] = videos
|
||||
return result
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
|
||||
result = {}
|
||||
result["parse"] = 0
|
||||
result["playUrl"] = ''
|
||||
result["url"] = id
|
||||
result["header"] = headers
|
||||
return result
|
||||
|
||||
def searchContentPage(self, key, quick, page):
|
||||
result = {}
|
||||
videos = []
|
||||
|
||||
payload = {
|
||||
"text": key
|
||||
}
|
||||
|
||||
url = f"{xurl}/v3/search"
|
||||
detail = requests.post(url=url, headers=headerx, json=payload)
|
||||
if detail.status_code == 200:
|
||||
detail.encoding = "utf-8"
|
||||
data = detail.json()
|
||||
|
||||
for vod in data['data']['theater']['search_data']:
|
||||
|
||||
name = vod['title']
|
||||
|
||||
id = vod['id']
|
||||
|
||||
pic = vod['cover_url']
|
||||
|
||||
remark = vod['score_str']
|
||||
|
||||
video = {
|
||||
"vod_id": id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark
|
||||
}
|
||||
videos.append(video)
|
||||
|
||||
result['list'] = videos
|
||||
result['page'] = page
|
||||
result['pagecount'] = 9999
|
||||
result['limit'] = 90
|
||||
result['total'] = 999999
|
||||
return result
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
return self.searchContentPage(key, quick, '1')
|
||||
|
||||
def localProxy(self, params):
|
||||
if params['type'] == "m3u8":
|
||||
return self.proxyM3u8(params)
|
||||
elif params['type'] == "media":
|
||||
return self.proxyMedia(params)
|
||||
elif params['type'] == "ts":
|
||||
return self.proxyTs(params)
|
||||
return None
|
||||
@@ -0,0 +1,509 @@
|
||||
# coding=utf-8
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
from functools import wraps
|
||||
from urllib.parse import quote, unquote, urljoin, urlparse
|
||||
|
||||
from lxml import etree
|
||||
|
||||
sys.path.append("..")
|
||||
from base.spider import Spider as BaseSpider
|
||||
|
||||
|
||||
LOG_TAG = "[威视TV_DEBUG]"
|
||||
|
||||
|
||||
def trace_interface(function):
|
||||
@wraps(function)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
interface = function.__name__
|
||||
self._debug(
|
||||
"接口开始",
|
||||
interface=interface,
|
||||
arguments=self._summarize_arguments(interface, args, kwargs),
|
||||
)
|
||||
try:
|
||||
result = function(self, *args, **kwargs)
|
||||
except Exception as error:
|
||||
self._debug(
|
||||
"接口异常",
|
||||
interface=interface,
|
||||
error_type=type(error).__name__,
|
||||
error=str(error),
|
||||
)
|
||||
raise
|
||||
self._debug("接口完成", interface=interface, **self._summarize_result(interface, result))
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class Spider(BaseSpider):
|
||||
DEFAULT_HOST = "https://weishitv.xyz"
|
||||
UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/126.0.0.0 Safari/537.36"
|
||||
)
|
||||
CLASSES = [
|
||||
{"type_id": "1", "type_name": "电影"},
|
||||
{"type_id": "2", "type_name": "剧集"},
|
||||
{"type_id": "3", "type_name": "综艺"},
|
||||
{"type_id": "4", "type_name": "动漫"},
|
||||
]
|
||||
|
||||
@trace_interface
|
||||
def getName(self):
|
||||
return "威视TV"
|
||||
|
||||
@trace_interface
|
||||
def init(self, extend=""):
|
||||
candidate = extend
|
||||
try:
|
||||
if isinstance(extend, str) and extend.strip().startswith("{"):
|
||||
candidate = json.loads(extend)
|
||||
if isinstance(candidate, dict):
|
||||
candidate = candidate.get("host") or candidate.get("url") or candidate.get("site") or ""
|
||||
except Exception as error:
|
||||
self._debug("配置解析失败", error_type=type(error).__name__, error=str(error))
|
||||
candidate = ""
|
||||
value = str(candidate or "").strip().rstrip("/")
|
||||
self.host = value if re.match(r"^https?://", value, re.I) else self.DEFAULT_HOST
|
||||
self.headers = {
|
||||
"User-Agent": self.UA,
|
||||
"Referer": self.host + "/",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
}
|
||||
self.session = requests.Session()
|
||||
self.session.trust_env = False
|
||||
self.session.headers.update(self.headers)
|
||||
|
||||
@trace_interface
|
||||
def homeContent(self, filter):
|
||||
result = {"class": list(self.CLASSES)}
|
||||
if filter:
|
||||
result["filters"] = {}
|
||||
return result
|
||||
|
||||
@trace_interface
|
||||
def homeVideoContent(self):
|
||||
return {"list": self._parse_list(self._get(self.host + "/"))[:60]}
|
||||
|
||||
@trace_interface
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
page = self._page(pg)
|
||||
html = self._get(f"{self.host}/index.php/vod/show/id/{quote(str(tid))}/page/{page}.html")
|
||||
items = self._parse_list(html)
|
||||
pagecount = self._page_count(html, page + 1 if items else page)
|
||||
return {
|
||||
"list": items,
|
||||
"page": page,
|
||||
"pagecount": pagecount,
|
||||
"limit": len(items),
|
||||
"total": pagecount * max(1, len(items)),
|
||||
}
|
||||
|
||||
@trace_interface
|
||||
def detailContent(self, ids):
|
||||
if not ids:
|
||||
return {"list": []}
|
||||
vod = self._parse_detail(str(ids[0]), self._get(self._absolute(ids[0])))
|
||||
return {"list": [vod] if vod else []}
|
||||
|
||||
@trace_interface
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
page = self._page(pg)
|
||||
suffix = f"&page={page}" if page > 1 else ""
|
||||
html = self._get(f"{self.host}/index.php/vod/search.html?wd={quote(str(key))}{suffix}")
|
||||
items = self._parse_list(html)
|
||||
return {
|
||||
"list": items,
|
||||
"page": page,
|
||||
"pagecount": self._page_count(html, page + 1 if items else page),
|
||||
}
|
||||
|
||||
@trace_interface
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
value = str(id or "")
|
||||
if self._is_media(value):
|
||||
return {"parse": 0, "url": value, "header": self._player_headers()}
|
||||
url = self._decode_player(self._get(self._absolute(value)))
|
||||
if not url:
|
||||
return {"parse": 1, "url": "", "header": self._player_headers()}
|
||||
return {"parse": 0 if self._is_media(url) else 1, "url": url, "header": self._player_headers()}
|
||||
|
||||
def _get(self, url):
|
||||
self._debug("HTTP请求", method="GET", url=self._safe_url(url))
|
||||
try:
|
||||
response = self.session.get(url, timeout=15)
|
||||
response.encoding = "utf-8"
|
||||
text = response.text or ""
|
||||
status = getattr(response, "status_code", 200)
|
||||
final_url = str(getattr(response, "url", "") or url)
|
||||
response_headers = getattr(response, "headers", {}) or {}
|
||||
title = self._page_title(text)
|
||||
self._debug(
|
||||
"HTTP响应",
|
||||
status=status,
|
||||
final_url=self._safe_url(final_url),
|
||||
content_length=len(text),
|
||||
content_type=str(response_headers.get("Content-Type") or ""),
|
||||
title=title,
|
||||
page_kind=self._page_kind(text, title),
|
||||
)
|
||||
if status >= 400:
|
||||
self._debug("HTTP状态异常", status=status, url=self._safe_url(final_url))
|
||||
return ""
|
||||
return text
|
||||
except Exception as error:
|
||||
self._debug(
|
||||
"HTTP异常",
|
||||
url=self._safe_url(url),
|
||||
error_type=type(error).__name__,
|
||||
error=str(error),
|
||||
)
|
||||
return ""
|
||||
|
||||
def _absolute(self, value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return self.host + "/"
|
||||
if text.startswith("//"):
|
||||
return "https:" + text
|
||||
if re.match(r"^https?://", text, re.I):
|
||||
parsed = urlparse(text)
|
||||
if "/index.php/" in parsed.path:
|
||||
return self.host + parsed.path + (("?" + parsed.query) if parsed.query else "")
|
||||
return text
|
||||
return urljoin(self.host + "/", text)
|
||||
|
||||
def _relative(self, value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if re.match(r"^https?://", text, re.I):
|
||||
parsed = urlparse(text)
|
||||
return parsed.path + (("?" + parsed.query) if parsed.query else "")
|
||||
return text if text.startswith("/") else "/" + text
|
||||
|
||||
def _parse_list(self, html):
|
||||
doc = self._html(html)
|
||||
raw_list_markers = len(re.findall(r"public-list-box", html or "", re.I))
|
||||
raw_detail_markers = len(re.findall(r"vod/detail", html or "", re.I))
|
||||
skipped = {
|
||||
"missing_link": 0,
|
||||
"missing_id": 0,
|
||||
"missing_name": 0,
|
||||
"duplicate": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
if doc is None:
|
||||
self._debug(
|
||||
"列表解析",
|
||||
html_parse_ok=False,
|
||||
raw_list_markers=raw_list_markers,
|
||||
raw_detail_markers=raw_detail_markers,
|
||||
containers=0,
|
||||
detail_links=0,
|
||||
parsed=0,
|
||||
skipped=skipped,
|
||||
samples=[],
|
||||
html_length=len(html or ""),
|
||||
)
|
||||
return []
|
||||
items = []
|
||||
seen = set()
|
||||
boxes = doc.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' public-list-box ')]")
|
||||
detail_links = len(doc.xpath("//a[contains(@href, '/index.php/vod/detail/')]"))
|
||||
for box in boxes:
|
||||
try:
|
||||
links = box.xpath(".//a[contains(concat(' ', normalize-space(@class), ' '), ' public-list-exp ') and contains(@href, '/index.php/vod/detail/')]")
|
||||
if not links:
|
||||
skipped["missing_link"] += 1
|
||||
continue
|
||||
link = links[0]
|
||||
vod_id = self._relative(link.get("href"))
|
||||
if not vod_id:
|
||||
skipped["missing_id"] += 1
|
||||
continue
|
||||
name = (link.get("title") or "").strip()
|
||||
if not name:
|
||||
alt = link.xpath("string(.//img[1]/@alt)").strip()
|
||||
name = re.sub(r"封面图$", "", alt).strip()
|
||||
if not name:
|
||||
skipped["missing_name"] += 1
|
||||
continue
|
||||
if vod_id in seen:
|
||||
skipped["duplicate"] += 1
|
||||
continue
|
||||
seen.add(vod_id)
|
||||
pic = link.xpath("string(.//img[1]/@data-src)") or link.xpath("string(.//img[1]/@data-original)") or link.xpath("string(.//img[1]/@src)")
|
||||
pic = "" if str(pic).startswith("data:image/") else self._absolute(pic)
|
||||
remarks = self._first_text(box, ".//*[contains(concat(' ', normalize-space(@class), ' '), ' public-list-prb ')]")
|
||||
if not remarks:
|
||||
remarks = self._first_text(box, ".//*[contains(concat(' ', normalize-space(@class), ' '), ' public-prt ')]")
|
||||
items.append({"vod_id": vod_id, "vod_name": name, "vod_pic": pic, "vod_remarks": remarks})
|
||||
except Exception as error:
|
||||
skipped["errors"] += 1
|
||||
self._debug(
|
||||
"列表条目解析失败",
|
||||
error_type=type(error).__name__,
|
||||
error=str(error),
|
||||
)
|
||||
self._debug(
|
||||
"列表解析",
|
||||
html_parse_ok=True,
|
||||
raw_list_markers=raw_list_markers,
|
||||
raw_detail_markers=raw_detail_markers,
|
||||
containers=len(boxes),
|
||||
detail_links=detail_links,
|
||||
parsed=len(items),
|
||||
skipped=skipped,
|
||||
samples=[item["vod_name"] for item in items[:3]],
|
||||
html_length=len(html or ""),
|
||||
)
|
||||
return items
|
||||
|
||||
def _parse_detail(self, vod_id, html):
|
||||
doc = self._html(html)
|
||||
if doc is None:
|
||||
return None
|
||||
name = self._first_text(doc, "//*[contains(concat(' ', normalize-space(@class), ' '), ' this-desc-title ')]")
|
||||
if not name:
|
||||
return None
|
||||
style = doc.xpath("string((//*[contains(concat(' ', normalize-space(@class), ' '), ' this-pic-bj ')])[1]/@style)")
|
||||
match = re.search(r"url\(['\"]?([^'\")]+)", style or "", re.I)
|
||||
pic = self._absolute(match.group(1)) if match else ""
|
||||
info = [self._clean("".join(node.itertext())) for node in doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' this-desc-info ')]//span")]
|
||||
info = [item for item in info if item]
|
||||
year = next((item for item in info if re.match(r"^(19|20)\d{2}$", item)), "")
|
||||
area = next((item for item in info if item != year and not re.search(r"更新|集|完结|分$", item)), "")
|
||||
remarks = next((item for item in info if re.search(r"更新|集|完结|HD|正片", item)), "")
|
||||
type_name = " / ".join(filter(None, [self._clean("".join(node.itertext())) for node in doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' this-desc-tags ')]//span")]))
|
||||
director = ""
|
||||
actor = ""
|
||||
for node in doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' this-info ')]"):
|
||||
label = self._first_text(node, ".//strong").replace(" ", "")
|
||||
value = ",".join(filter(None, [self._clean("".join(item.itertext())) for item in node.xpath(".//a")]))
|
||||
if "导演" in label:
|
||||
director = value
|
||||
if "演员" in label or "主演" in label:
|
||||
actor = value
|
||||
content = self._first_text(doc, "//*[@id='height_limit']//*[contains(concat(' ', normalize-space(@class), ' '), ' text ')]")
|
||||
content = re.sub(r"^\s*简介[::]?\s*", "", content).strip()
|
||||
line_nodes = doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' anthology-tab ')]//*[contains(concat(' ', normalize-space(@class), ' '), ' line-btn ')]")
|
||||
blocks = doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' anthology-list ')]//*[contains(concat(' ', normalize-space(@class), ' '), ' anthology-list-box ')]")
|
||||
play_from = []
|
||||
play_url = []
|
||||
for index in range(min(len(line_nodes), len(blocks))):
|
||||
line_texts = line_nodes[index].xpath(".//text()[not(ancestor::span[contains(concat(' ', normalize-space(@class), ' '), ' badge ')])]")
|
||||
line_name = self._clean("".join(line_texts)) or f"线路{index + 1}"
|
||||
episodes = []
|
||||
episode_nodes = blocks[index].xpath(".//a[contains(concat(' ', normalize-space(@class), ' '), ' episode-btn ') and @href]")
|
||||
for episode_index, episode in enumerate(episode_nodes):
|
||||
episode_id = self._relative(episode.get("href"))
|
||||
if episode_id:
|
||||
episode_name = self._clean("".join(episode.itertext())) or f"第{episode_index + 1}集"
|
||||
episodes.append(f"{episode_name}${episode_id}")
|
||||
if episodes:
|
||||
play_from.append(line_name)
|
||||
play_url.append("#".join(episodes))
|
||||
return {
|
||||
"vod_id": self._relative(vod_id),
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remarks,
|
||||
"vod_year": year,
|
||||
"vod_area": area,
|
||||
"type_name": type_name,
|
||||
"vod_director": director,
|
||||
"vod_actor": actor,
|
||||
"vod_content": content,
|
||||
"vod_play_from": "$$$".join(play_from),
|
||||
"vod_play_url": "$$$".join(play_url),
|
||||
}
|
||||
|
||||
def _decode_player(self, html):
|
||||
match = re.search(r"var\s+player_aaaa\s*=\s*(\{[\s\S]*?\})\s*</script>", html or "", re.I)
|
||||
if not match:
|
||||
return ""
|
||||
try:
|
||||
player = json.loads(match.group(1))
|
||||
value = str(player.get("url") or "")
|
||||
encrypt = int(player.get("encrypt") or 0)
|
||||
if encrypt == 1:
|
||||
value = unquote(value)
|
||||
elif encrypt == 2:
|
||||
value = unquote(base64.b64decode(value).decode("utf-8"))
|
||||
return value if re.match(r"^https?://", value, re.I) else ""
|
||||
except Exception as error:
|
||||
self._debug("播放数据解析失败", error_type=type(error).__name__, error=str(error))
|
||||
return ""
|
||||
|
||||
def _player_headers(self):
|
||||
return {"User-Agent": self.UA, "Referer": self.host + "/"}
|
||||
|
||||
@staticmethod
|
||||
def _preview(value, limit=180):
|
||||
try:
|
||||
text = json.dumps(value, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
text = str(value)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text if len(text) <= limit else text[:limit] + "..."
|
||||
|
||||
def _debug(self, event, **fields):
|
||||
try:
|
||||
payload = {"event": event}
|
||||
payload.update(fields)
|
||||
self.log(f"{LOG_TAG} {json.dumps(payload, ensure_ascii=False, default=str)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _summarize_arguments(self, interface, args, kwargs):
|
||||
if interface == "playerContent":
|
||||
value = args[1] if len(args) > 1 else kwargs.get("id")
|
||||
flags = args[2] if len(args) > 2 else kwargs.get("vipFlags")
|
||||
return {
|
||||
"flag": self._preview(args[0] if args else kwargs.get("flag"), 60),
|
||||
"id": self._resource_hint(value),
|
||||
"vip_flags_count": len(flags) if isinstance(flags, (list, tuple, set)) else 0,
|
||||
}
|
||||
if interface == "isVideoFormat":
|
||||
value = args[0] if args else kwargs.get("url")
|
||||
return {"url": self._resource_hint(value)}
|
||||
if interface == "init":
|
||||
value = args[0] if args else kwargs.get("extend", "")
|
||||
return {"extend": self._preview(value, 120)}
|
||||
return self._preview({"args": args, "kwargs": kwargs})
|
||||
|
||||
def _resource_hint(self, value):
|
||||
text = str(value or "").strip()
|
||||
parsed = urlparse(text)
|
||||
if parsed.scheme in ("http", "https") and parsed.netloc:
|
||||
return {"kind": "url", "host": parsed.netloc, "media": bool(self._is_media(text))}
|
||||
return {"kind": "path", "value": self._preview(text, 120)}
|
||||
|
||||
def _safe_url(self, value):
|
||||
text = str(value or "").strip()
|
||||
try:
|
||||
parsed = urlparse(text)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return self._preview(text, 180)
|
||||
if self._is_media(text):
|
||||
return f"{parsed.scheme}://{parsed.netloc}/<media>"
|
||||
query = "?<redacted>" if parsed.query else ""
|
||||
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}{query}"
|
||||
except Exception:
|
||||
return "<invalid-url>"
|
||||
|
||||
def _summarize_result(self, interface, result):
|
||||
summary = {"result_type": type(result).__name__}
|
||||
if not isinstance(result, dict):
|
||||
summary["result"] = self._preview(result)
|
||||
return summary
|
||||
items = result.get("list")
|
||||
classes = result.get("class")
|
||||
if isinstance(items, list):
|
||||
summary["list_count"] = len(items)
|
||||
summary["samples"] = [
|
||||
str(item.get("vod_name") or "")
|
||||
for item in items[:3]
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
if isinstance(classes, list):
|
||||
summary["class_count"] = len(classes)
|
||||
for key in ("page", "pagecount", "limit", "total", "parse"):
|
||||
if key in result:
|
||||
summary[key] = result[key]
|
||||
if interface == "playerContent":
|
||||
value = str(result.get("url") or "")
|
||||
summary["has_url"] = bool(value)
|
||||
summary["media"] = self._is_media(value)
|
||||
summary["url_host"] = urlparse(value).netloc if value else ""
|
||||
return summary
|
||||
|
||||
@classmethod
|
||||
def _page_title(cls, html):
|
||||
match = re.search(r"<title[^>]*>([\s\S]*?)</title>", html or "", re.I)
|
||||
if not match:
|
||||
return ""
|
||||
return cls._clean(re.sub(r"<[^>]+>", "", match.group(1)))
|
||||
|
||||
@staticmethod
|
||||
def _page_kind(html, title):
|
||||
text = f"{title}\n{str(html or '')[:50000]}".lower()
|
||||
markers = (
|
||||
"just a moment",
|
||||
"cf-chl-",
|
||||
"captcha",
|
||||
"人机验证",
|
||||
"访问验证",
|
||||
"安全验证",
|
||||
)
|
||||
if any(marker in text for marker in markers):
|
||||
return "challenge"
|
||||
return "html" if html else "empty"
|
||||
|
||||
def _html(self, html):
|
||||
try:
|
||||
if not html:
|
||||
return None
|
||||
payload = html.encode("utf-8") if isinstance(html, str) else html
|
||||
parser = etree.HTMLParser(encoding="utf-8", recover=True)
|
||||
return etree.HTML(payload, parser=parser)
|
||||
except Exception as error:
|
||||
self._debug(
|
||||
"HTML解析失败",
|
||||
error_type=type(error).__name__,
|
||||
error=str(error),
|
||||
html_length=len(html or ""),
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _clean(value):
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
def _first_text(self, node, xpath):
|
||||
values = node.xpath(xpath)
|
||||
if not values:
|
||||
return ""
|
||||
value = values[0]
|
||||
return self._clean("".join(value.itertext()) if hasattr(value, "itertext") else value)
|
||||
|
||||
@staticmethod
|
||||
def _page(value):
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def _page_count(html, fallback):
|
||||
pages = [int(value) for value in re.findall(r"/page/(\d+)\.html", html or "")]
|
||||
return max([int(fallback)] + pages)
|
||||
|
||||
@staticmethod
|
||||
def _is_media(url):
|
||||
return re.match(r"^https?://", str(url or ""), re.I) is not None and re.search(r"\.(m3u8|mp4|flv|mkv|ts|mpd)(?:[?#]|$)", str(url), re.I) is not None
|
||||
|
||||
@trace_interface
|
||||
def isVideoFormat(self, url):
|
||||
return False
|
||||
|
||||
@trace_interface
|
||||
def manualVideoCheck(self):
|
||||
return False
|
||||
|
||||
@trace_interface
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
@trace_interface
|
||||
def localProxy(self, param):
|
||||
return None
|
||||
@@ -0,0 +1,1078 @@
|
||||
# 蜜桃视频 类型爬虫
|
||||
# 网站: https://www.nht966hht.vip:9527
|
||||
# API: AES-128-CBC (ZeroPadding) + MD5 签名加密
|
||||
|
||||
# coding=utf-8
|
||||
# !/usr/bin/python
|
||||
|
||||
import sys
|
||||
sys.path.append('..')
|
||||
|
||||
from base.spider import BaseSpider
|
||||
import requests
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
import re
|
||||
import os
|
||||
import string
|
||||
import random
|
||||
import threading
|
||||
from urllib.parse import quote, unquote
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
# ============================================================
|
||||
# 站点配置(多站点备用)
|
||||
# ============================================================
|
||||
SITES = [
|
||||
{'name': 'nht966', 'host': 'https://www.nht966hht.vip:9527'},
|
||||
{'name': 'httre666', 'host': 'https://www.newhttestre666.cc'},
|
||||
]
|
||||
|
||||
# ============================================================
|
||||
# 加密常量(从 JS bundle 中提取)
|
||||
# ============================================================
|
||||
SIGN_KEY = 'opum3_Loily$SV^6H'
|
||||
BUNDLE_ID = 'com.ht9.web20.video'
|
||||
BRAND_ID = 'hongtao'
|
||||
VERSION = '1.0.0'
|
||||
PROJECT_ID = '1'
|
||||
|
||||
PROXY_TYPE = 'mitao_img'
|
||||
|
||||
|
||||
class Spider(BaseSpider):
|
||||
|
||||
# ---- 基础信息 ----
|
||||
def getName(self):
|
||||
return "蜜桃视频"
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
return url and ('.mp4' in url or '.m3u8' in url or '.ts' in url)
|
||||
|
||||
def manualVideoCheck(self):
|
||||
return False
|
||||
|
||||
# ---- 类变量 ----
|
||||
filterable = True
|
||||
searchable = True
|
||||
host = SITES[0]['host']
|
||||
session = requests.Session()
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"lang": "cn",
|
||||
"deviceType": "H5-android",
|
||||
}
|
||||
|
||||
# 测速缓存
|
||||
_speed_cache_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.mitao_cache.json')
|
||||
_speed_cache_ttl = 1800
|
||||
_lock = threading.Lock()
|
||||
_speed_test_done = False
|
||||
|
||||
# 会话状态
|
||||
_user_id = ''
|
||||
_session_id = ''
|
||||
_device_id = ''
|
||||
_session_inited = False
|
||||
|
||||
# 分类缓存 (从 initH5_1 typeTitleList)
|
||||
_categories = []
|
||||
|
||||
# 视频类型列表 (从 appConfig videoTypeList,用于构建筛选)
|
||||
_video_type_list = []
|
||||
|
||||
# 会话缓存(避免重复 deviceLogin 触发 429 限流)
|
||||
_session_cache_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.mitao_session.json')
|
||||
_session_cache_ttl = 1800 # 30 分钟
|
||||
|
||||
# ============================================================
|
||||
# 多站点测速
|
||||
# ============================================================
|
||||
def _get_cached_site(self):
|
||||
try:
|
||||
if os.path.exists(self._speed_cache_file):
|
||||
with open(self._speed_cache_file, 'r') as f:
|
||||
data = json.loads(f.read())
|
||||
age = time.time() - data.get('ts', 0)
|
||||
host = data.get('host', '')
|
||||
if age < self._speed_cache_ttl and host:
|
||||
return host, True
|
||||
except Exception:
|
||||
pass
|
||||
return '', False
|
||||
|
||||
def _save_cached_site(self, host):
|
||||
try:
|
||||
with open(self._speed_cache_file, 'w') as f:
|
||||
f.write(json.dumps({'host': host, 'ts': time.time()}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _test_site_speed(self, site, results):
|
||||
try:
|
||||
start = time.time()
|
||||
r = requests.get(site['host'], headers=self.headers, timeout=TIMEOUT, verify=False)
|
||||
elapsed = time.time() - start
|
||||
if r.status_code == 200:
|
||||
with self._lock:
|
||||
results[site['name']] = elapsed
|
||||
except Exception:
|
||||
with self._lock:
|
||||
results[site['name']] = 999
|
||||
|
||||
def _select_best_site(self):
|
||||
if self._speed_test_done:
|
||||
return
|
||||
cached_host, valid = self._get_cached_site()
|
||||
if valid:
|
||||
self.host = cached_host
|
||||
self._speed_test_done = True
|
||||
return
|
||||
|
||||
results = {}
|
||||
threads = []
|
||||
for s in SITES:
|
||||
t = threading.Thread(target=self._test_site_speed, args=(s, results))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
threads.append(t)
|
||||
for t in threads:
|
||||
t.join(1.5)
|
||||
|
||||
valid_sites = [s for s in SITES if results.get(s['name'], 999) < TIMEOUT]
|
||||
best = min(valid_sites, key=lambda x: results[x['name']])['host'] if valid_sites else SITES[0]['host']
|
||||
|
||||
self.host = best
|
||||
self._speed_test_done = True
|
||||
self._save_cached_site(best)
|
||||
|
||||
# ============================================================
|
||||
# 会话缓存(持久化到文件,避免重复 init 触发 429 限流)
|
||||
# ============================================================
|
||||
def _save_session_cache(self):
|
||||
"""将会话状态写入缓存文件"""
|
||||
try:
|
||||
data = {
|
||||
'ts': time.time(),
|
||||
'user_id': self._user_id,
|
||||
'session_id': self._session_id,
|
||||
'device_id': self._device_id,
|
||||
'categories': self._categories,
|
||||
'video_type_list': self._video_type_list,
|
||||
}
|
||||
with open(self._session_cache_file, 'w') as f:
|
||||
f.write(json.dumps(data, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_session_cache(self):
|
||||
"""从缓存文件恢复会话状态,返回 True 表示缓存有效"""
|
||||
try:
|
||||
if not os.path.exists(self._session_cache_file):
|
||||
return False
|
||||
with open(self._session_cache_file, 'r') as f:
|
||||
data = json.loads(f.read())
|
||||
age = time.time() - data.get('ts', 0)
|
||||
if age >= self._session_cache_ttl:
|
||||
return False
|
||||
self._user_id = data.get('user_id', '')
|
||||
self._session_id = data.get('session_id', '')
|
||||
self._device_id = data.get('device_id', '')
|
||||
self._categories = data.get('categories', [])
|
||||
self._video_type_list = data.get('video_type_list', [])
|
||||
# 关键字段缺失视为缓存无效, 避免无认证请求被服务器拒绝
|
||||
if not self._user_id or not self._session_id:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ============================================================
|
||||
# AES 加解密(匹配 CryptoJS ZeroPadding)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _zero_pad(data, block_size=16):
|
||||
pad_len = block_size - (len(data) % block_size)
|
||||
if pad_len == block_size:
|
||||
return data
|
||||
return data + b'\x00' * pad_len
|
||||
|
||||
@staticmethod
|
||||
def _zero_unpad(data):
|
||||
return data.rstrip(b'\x00')
|
||||
|
||||
def _gen_key(self, timestamp):
|
||||
"""生成 AES-128 密钥: timestamp后6位 + signKey前4 + bundleId前6"""
|
||||
ts = str(timestamp)
|
||||
return ts[-6:] + SIGN_KEY[:4] + BUNDLE_ID[:6]
|
||||
|
||||
def _gen_iv(self):
|
||||
"""生成 AES-128 IV: bundleId后6 + signKey后4 + deviceId前6"""
|
||||
return BUNDLE_ID[-6:] + SIGN_KEY[-4:] + self._device_id[:6]
|
||||
|
||||
def _aes_encrypt(self, plaintext, key_str, iv_str):
|
||||
"""AES-128-CBC 加密 (ZeroPadding, 输出 Base64)"""
|
||||
key = key_str.encode('utf-8')
|
||||
iv = iv_str.encode('utf-8')
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
data = plaintext.encode('utf-8')
|
||||
padded = self._zero_pad(data)
|
||||
encrypted = cipher.encrypt(padded)
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
|
||||
def _aes_decrypt(self, ciphertext_b64, key_str, iv_str):
|
||||
"""AES-128-CBC 解密 (ZeroPadding, 输入 Base64)"""
|
||||
key = key_str.encode('utf-8')
|
||||
iv = iv_str.encode('utf-8')
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
# 移除空白字符(匹配 JS 端 replace(/\s/g,""))
|
||||
cleaned = re.sub(r'\s', '', ciphertext_b64)
|
||||
encrypted = base64.b64decode(cleaned)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
unpadded = self._zero_unpad(decrypted)
|
||||
return unpadded.decode('utf-8', errors='replace')
|
||||
|
||||
def _generate_sign(self, params, api_path):
|
||||
"""MD5 签名: 参数值排序拼接 + signKey + API路径 → MD5 大写"""
|
||||
sorted_keys = sorted(params.keys())
|
||||
concat = ''
|
||||
for k in sorted_keys:
|
||||
concat += str(params[k])
|
||||
raw = concat + SIGN_KEY + api_path
|
||||
return hashlib.md5(raw.encode('utf-8')).hexdigest().upper()
|
||||
|
||||
# ============================================================
|
||||
# 客户端 deviceId 生成(匹配 JS: "H5-" + 随机串)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _generate_device_id():
|
||||
"""生成 H5 设备 ID,格式: H5- + 32位随机小写hex"""
|
||||
rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))
|
||||
return 'H5-' + rand
|
||||
|
||||
# ============================================================
|
||||
# 通用请求 params (Ne)
|
||||
# ============================================================
|
||||
def _common_params(self):
|
||||
# channelId2 = window.location.host (含端口,如 www.nht950hht.vip:9527)
|
||||
hostname = self.host.replace('https://', '').replace('http://', '')
|
||||
return {
|
||||
'timezone': 'Asia/Karachi',
|
||||
'version': VERSION,
|
||||
'channelId': 67, # 必须是整数! JS: __xyz_cid_ = 67, JSON.stringify 后为 67 而非 "67"
|
||||
'channelId2': hostname,
|
||||
'brandId': BRAND_ID,
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# API 请求(支持加密/明文双模式)
|
||||
# ============================================================
|
||||
def _api_request(self, endpoint, params=None, skip_encrypt=False, _t=None):
|
||||
"""
|
||||
发送 AES 加密 API 请求
|
||||
endpoint: e.g. '/ht/content/homeH5'
|
||||
params: 请求参数 dict
|
||||
skip_encrypt: True = 发送明文 JSON (调试用, 部分 init 端点不需加密)
|
||||
_t: 可选, 复用外部时间戳 (initH5_1/2 共用)
|
||||
"""
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
# 毫秒时间戳 (支持外部传入, 匹配浏览器 initH5_1/2 共用 t 的行为)
|
||||
timestamp = str(_t) if _t else str(int(time.time() * 1000))
|
||||
key_str = self._gen_key(timestamp)
|
||||
iv_str = self._gen_iv()
|
||||
|
||||
# 构建完整参数: Ne() + {t} + 业务参数
|
||||
full_params = self._common_params()
|
||||
full_params['t'] = timestamp
|
||||
full_params.update(params)
|
||||
|
||||
# 签名: ze(params, endpoint) = MD5(sorted_values + signKey + path).upper()
|
||||
full_params['sign'] = self._generate_sign(full_params, endpoint)
|
||||
|
||||
api_url = self.host + endpoint
|
||||
headers = dict(self.headers)
|
||||
headers['t'] = timestamp
|
||||
|
||||
if self._user_id:
|
||||
headers['userId'] = self._user_id
|
||||
if self._session_id:
|
||||
headers['sessionId'] = self._session_id
|
||||
|
||||
# 必填请求头 (JS Ve 拦截器会设置这些)
|
||||
headers['deviceId'] = self._device_id or ''
|
||||
headers['bundleId'] = BUNDLE_ID
|
||||
|
||||
# 明文或加密
|
||||
if skip_encrypt:
|
||||
body = json.dumps(full_params, ensure_ascii=False, separators=(',', ':'))
|
||||
headers['Content-Type'] = 'application/json'
|
||||
headers['encrypt'] = 'false'
|
||||
else:
|
||||
plain = json.dumps(full_params, ensure_ascii=False, separators=(',', ':'))
|
||||
body = self._aes_encrypt(plain, key_str, iv_str)
|
||||
headers['Content-Type'] = 'text/plain'
|
||||
headers['encrypt'] = 'true'
|
||||
|
||||
try:
|
||||
r = self.session.post(api_url, data=body,
|
||||
headers=headers, timeout=TIMEOUT, verify=False)
|
||||
|
||||
resp = r.json()
|
||||
|
||||
# code=10000 表示成功,解密响应 data(仅加密请求需解密)
|
||||
if resp.get('code') == 10000 and isinstance(resp.get('data'), str) and resp['data']:
|
||||
try:
|
||||
decrypted = self._aes_decrypt(resp['data'], key_str, iv_str)
|
||||
resp['data'] = json.loads(decrypted)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return resp
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return None
|
||||
except requests.exceptions.ConnectionError:
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ============================================================
|
||||
# 会话初始化(匹配 JS 端流程)
|
||||
# ============================================================
|
||||
def _ensure_session(self):
|
||||
"""
|
||||
初始化会话: 优先从文件缓存恢复 → 否则 appConfig → 生成 deviceId → initH5_1 → initH5_2 → deviceLogin
|
||||
真实浏览器流程: appConfig 最先调,initH5_1/2 共用同一个 t 时间戳
|
||||
缓存策略: 避免 T3 新建实例时重复 deviceLogin 触发 429 限流
|
||||
"""
|
||||
if self._session_inited:
|
||||
return
|
||||
|
||||
# 优先从缓存恢复(跳过整个 init 流程,避免 429)
|
||||
if self._load_session_cache():
|
||||
self._session_inited = True
|
||||
# 旧缓存可能没有 video_type_list,补一次 appConfig 请求
|
||||
if not self._video_type_list:
|
||||
appcfg = self._api_request('/ht/users/appConfig')
|
||||
if appcfg and appcfg.get('code') == 10000:
|
||||
ac_data = appcfg.get('data', {})
|
||||
if isinstance(ac_data, dict) and ac_data.get('appConfig'):
|
||||
ac_cfg = ac_data['appConfig']
|
||||
if isinstance(ac_cfg, dict) and ac_cfg.get('videoTypeList'):
|
||||
self._video_type_list = ac_cfg['videoTypeList']
|
||||
return
|
||||
|
||||
# 0. 生成 deviceId (JS 端 $.getDeviceId() 在页面加载时就执行)
|
||||
if not self._device_id:
|
||||
self._device_id = self._generate_device_id()
|
||||
|
||||
# 0.5 appConfig — 真实浏览器第一个调的就是它,获取 videoTypeList 供筛选
|
||||
appcfg = self._api_request('/ht/users/appConfig')
|
||||
if appcfg and appcfg.get('code') == 10000:
|
||||
ac_data = appcfg.get('data', {})
|
||||
if isinstance(ac_data, dict) and ac_data.get('appConfig'):
|
||||
ac_cfg = ac_data['appConfig']
|
||||
if isinstance(ac_cfg, dict) and ac_cfg.get('videoTypeList'):
|
||||
self._video_type_list = ac_cfg['videoTypeList']
|
||||
|
||||
# 1. initH5_1 + initH5_2 共用一个 t (匹配浏览器行为)
|
||||
shared_t = int(time.time() * 1000)
|
||||
resp1 = self._api_request('/ht/users/initH5_1', _t=shared_t)
|
||||
|
||||
if resp1 and resp1.get('code') == 10000:
|
||||
data = resp1.get('data', {})
|
||||
if data.get('deviceId'):
|
||||
self._device_id = data['deviceId']
|
||||
# 保存分类列表供 homeContent 使用
|
||||
if data.get('typeTitleList'):
|
||||
self._categories = data['typeTitleList']
|
||||
|
||||
# 2. initH5_2 (复用 shared_t)
|
||||
self._api_request('/ht/users/initH5_2', _t=shared_t)
|
||||
|
||||
# 3. deviceLogin → 获取 userId / sessionId
|
||||
resp = self._api_request('/ht/users/deviceLogin', {
|
||||
'bundleId': BUNDLE_ID,
|
||||
'brandId': BRAND_ID,
|
||||
'projectId': PROJECT_ID,
|
||||
})
|
||||
if resp and resp.get('code') == 10000:
|
||||
data = resp.get('data', {})
|
||||
self._user_id = data.get('userId', '')
|
||||
self._session_id = data.get('sessionId', '')
|
||||
|
||||
self._session_inited = True
|
||||
self._save_session_cache()
|
||||
|
||||
# ============================================================
|
||||
# 图片代理
|
||||
# ============================================================
|
||||
def get_proxy_image_url(self, img_url):
|
||||
if not img_url:
|
||||
return ''
|
||||
base_proxy = self.getProxyUrl()
|
||||
if not base_proxy:
|
||||
base_proxy = 'http://127.0.0.1:9980/proxy?do=py'
|
||||
return base_proxy + '&type=' + PROXY_TYPE + '&url=' + quote(img_url, safe='')
|
||||
|
||||
def _fmt_duration(self, seconds):
|
||||
try:
|
||||
s = int(seconds or 0)
|
||||
except (TypeError, ValueError):
|
||||
return ''
|
||||
if s <= 0:
|
||||
return ''
|
||||
m, s = divmod(s, 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
# ============================================================
|
||||
# 初始化
|
||||
# ============================================================
|
||||
def init(self, extend=""):
|
||||
cached_host, valid = self._get_cached_site()
|
||||
if valid:
|
||||
self.host = cached_host
|
||||
self._speed_test_done = True
|
||||
|
||||
# ============================================================
|
||||
# 首页
|
||||
# ============================================================
|
||||
# T3 首页统一入口: 同时返回分类列表 + 首页视频数据
|
||||
# ============================================================
|
||||
_CATEGORY_BLACKLIST = {'成人游戏', '漫画', '小说', '蜜穴女友', '一键脱衣', '春药商城', '同城交友', '吃瓜', '成人漫画'}
|
||||
|
||||
def homeContent(self, filter):
|
||||
self._select_best_site()
|
||||
self._ensure_session()
|
||||
|
||||
classes = []
|
||||
filters = {}
|
||||
|
||||
# 动态加载真实分类(来自 initH5_1 typeTitleList),过滤掉不需要的
|
||||
for cat in self._categories:
|
||||
cid = str(cat.get('contentId', ''))
|
||||
title = cat.get('title', '')
|
||||
if not cid or not title or title in self._CATEGORY_BLACKLIST:
|
||||
continue
|
||||
classes.append({'type_id': cid, 'type_name': title})
|
||||
|
||||
# ---- 构建该分类的筛选器 ----
|
||||
cat_filters = []
|
||||
|
||||
# 1. 二级分类 (videoTypeList 中 typePid == contentId 的子项)
|
||||
sub_cats = [v for v in self._video_type_list if str(v.get('typePid', '')) == cid]
|
||||
if sub_cats:
|
||||
sub_values = [{'n': '全部', 'v': ''}]
|
||||
for sc in sub_cats:
|
||||
sc_id = str(sc.get('typeId', ''))
|
||||
sc_name = sc.get('typeName', '')
|
||||
if sc_id and sc_name:
|
||||
sub_values.append({'n': sc_name, 'v': sc_id})
|
||||
if len(sub_values) > 1:
|
||||
cat_filters.append({'key': 'label', 'name': '分类', 'value': sub_values})
|
||||
|
||||
# 2. 标签 (尝试从 videoTypeList 中匹配该 contentId 对应一级类型的 tags)
|
||||
# 一级类型 typePid==0 且 typeId 可能等于 contentId
|
||||
first_level = [v for v in self._video_type_list
|
||||
if str(v.get('typePid', '')) == '0' and str(v.get('typeId', '')) == cid]
|
||||
if first_level:
|
||||
tags_str = first_level[0].get('tags', '')
|
||||
if tags_str:
|
||||
tag_list = [t.strip() for t in tags_str.split(',') if t.strip()]
|
||||
if tag_list:
|
||||
tag_values = [{'n': '全部', 'v': ''}]
|
||||
for t in tag_list:
|
||||
tag_values.append({'n': t, 'v': t})
|
||||
cat_filters.append({'key': 'tag', 'name': '标签', 'value': tag_values})
|
||||
|
||||
# 3. 排序 (JS sortList: ["最近更新","最多播放","最多收藏"] → 索引 0/1/2)
|
||||
cat_filters.append({'key': 'sort', 'name': '排序', 'value': [
|
||||
{'n': '最近更新', 'v': '0'},
|
||||
{'n': '最多播放', 'v': '1'},
|
||||
{'n': '最多收藏', 'v': '2'},
|
||||
]})
|
||||
|
||||
if cat_filters:
|
||||
filters[cid] = cat_filters
|
||||
|
||||
# ---- 添加特殊分类: 女优 (actor) ----
|
||||
classes.append({'type_id': 'actor', 'type_name': '女优'})
|
||||
|
||||
# 动态生成筛选值 (API 只接受单值精确匹配)
|
||||
_actors_filters = []
|
||||
|
||||
# 身高: 150-164cm
|
||||
_actors_filters.append({'key': 'height', 'name': '身高', 'value': [
|
||||
{'n': '身高', 'v': ''},
|
||||
] + [{'n': f'{h}cm', 'v': str(h)} for h in range(150, 165)]})
|
||||
|
||||
# 罩杯: A-G
|
||||
_actors_filters.append({'key': 'cup', 'name': '罩杯', 'value': [
|
||||
{'n': '罩杯', 'v': ''},
|
||||
] + [{'n': f'{c}罩杯', 'v': c} for c in 'ABCDEFG']})
|
||||
|
||||
# 年龄: 1976-2002 (出生年份)
|
||||
_actors_filters.append({'key': 'birthday', 'name': '年龄', 'value': [
|
||||
{'n': '年龄', 'v': ''},
|
||||
] + [{'n': f'{y}年', 'v': str(y)} for y in range(2002, 1975, -1)]})
|
||||
|
||||
# 出道: 2001-2025
|
||||
_actors_filters.append({'key': 'debut', 'name': '出道', 'value': [
|
||||
{'n': '出道', 'v': ''},
|
||||
] + [{'n': f'{y}年', 'v': str(y)} for y in range(2025, 2000, -1)]})
|
||||
|
||||
filters['actor'] = _actors_filters
|
||||
|
||||
# ---- 添加特殊分类: 专题 (topic) ----
|
||||
classes.append({'type_id': 'topic', 'type_name': '专题'})
|
||||
|
||||
# 同时返回首页推荐视频列表 (兼容 T3 统一返回模式)
|
||||
home_videos = self.categoryContent('home', 1, '', {})
|
||||
return {
|
||||
'class': classes,
|
||||
'filters': filters,
|
||||
'type': '影视',
|
||||
'list': home_videos.get('list', []),
|
||||
'page': home_videos.get('page', 1),
|
||||
'pagecount': home_videos.get('pagecount', 1),
|
||||
'limit': home_videos.get('limit', 0),
|
||||
'total': home_videos.get('total', 0),
|
||||
}
|
||||
|
||||
def homeVideoContent(self, tid, pg, filter, extend):
|
||||
return self.categoryContent(tid or 'home', pg, filter, extend)
|
||||
|
||||
# ============================================================
|
||||
# 分类列表
|
||||
# ============================================================
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
tid = str(tid)
|
||||
pg = int(pg)
|
||||
|
||||
self._select_best_site()
|
||||
self._ensure_session()
|
||||
|
||||
vod_list = []
|
||||
|
||||
# ---- @ folder 模式: 点击文件夹 → 获取视频列表 ----
|
||||
if '@' in tid:
|
||||
real_tid = tid.replace('@', '')
|
||||
if real_tid.startswith('actor_'):
|
||||
actor_id = real_tid[len('actor_'):]
|
||||
|
||||
# 先查演员名
|
||||
detail_resp = self._api_request('/ht/content/queryActorDetail', {
|
||||
'actorId': actor_id,
|
||||
})
|
||||
actor_name = ''
|
||||
if detail_resp and detail_resp.get('code') == 10000:
|
||||
detail_data = detail_resp.get('data', {})
|
||||
actor_info = (detail_data.get('actorDetail') or detail_data or {})
|
||||
actor_name = (actor_info.get('actorName') or actor_info.get('actor_name') or '')
|
||||
|
||||
# 用演员名搜索视频
|
||||
if actor_name:
|
||||
resp = self._api_request('/ht/content/search', {
|
||||
'keywords': actor_name,
|
||||
'pageNo': str(pg - 1),
|
||||
'pageSize': '20',
|
||||
})
|
||||
else:
|
||||
# 降级: 用 actorId 尝试 queryTypeVideosH5
|
||||
resp = self._api_request('/ht/content/queryTypeVideosH5', {
|
||||
'actorId': actor_id,
|
||||
'pageNo': str(pg - 1),
|
||||
'pageSize': '20',
|
||||
'type': '1',
|
||||
})
|
||||
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
data = resp.get('data', {})
|
||||
vod_list = self._extract_videos_from_data(data)
|
||||
total_page = int(data.get('totalPage') or data.get('total_page') or 1)
|
||||
return {'list': vod_list, 'page': pg, 'pagecount': max(total_page, 1),
|
||||
'limit': len(vod_list), 'total': max(total_page, 1) * 20}
|
||||
|
||||
elif real_tid.startswith('topic_'):
|
||||
topic_id = real_tid[len('topic_'):]
|
||||
resp = self._api_request('/ht/content/queryOriTopicVideos', {
|
||||
'topicId': topic_id,
|
||||
'pageNo': str(pg - 1),
|
||||
'pageSize': '20',
|
||||
})
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
data = resp.get('data', {})
|
||||
vod_list = self._extract_videos_from_data(data)
|
||||
total_page = int(data.get('totalPage') or data.get('total_page') or 1)
|
||||
return {'list': vod_list, 'page': pg, 'pagecount': max(total_page, 1),
|
||||
'limit': len(vod_list), 'total': max(total_page, 1) * 20}
|
||||
|
||||
else:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
# ---- 女优列表 (folder 模式) ----
|
||||
if tid == 'actor':
|
||||
# 构建 API 参数, 映射 extend 中的筛选 key → API 参数名
|
||||
api_params = {
|
||||
'pageNo': str(pg - 1),
|
||||
'pageSize': '20',
|
||||
}
|
||||
if isinstance(extend, dict):
|
||||
_actor_filter_map = {
|
||||
'height': 'actorHeight',
|
||||
'cup': 'cupSize',
|
||||
'birthday': 'actorBirthday',
|
||||
'debut': 'actorDebut',
|
||||
}
|
||||
for ek, ak in _actor_filter_map.items():
|
||||
val = extend.get(ek, '')
|
||||
if val:
|
||||
api_params[ak] = val
|
||||
|
||||
resp = self._api_request('/ht/content/getActors', api_params)
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
data = resp.get('data', {})
|
||||
vod_list = self._parse_actor_list(data)
|
||||
total_page = int(data.get('totalPage') or 1)
|
||||
return {'list': vod_list, 'page': pg, 'pagecount': total_page,
|
||||
'limit': len(vod_list), 'total': total_page * 20}
|
||||
|
||||
# ---- 专题列表 (folder 模式) ----
|
||||
if tid == 'topic':
|
||||
resp = self._api_request('/ht/content/getOriTopicList', {
|
||||
'pageNo': str(pg - 1),
|
||||
'pageSize': '20',
|
||||
})
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
data = resp.get('data', {})
|
||||
vod_list = self._parse_topic_list(data)
|
||||
return {'list': vod_list, 'page': pg, 'pagecount': 50, 'limit': len(vod_list),
|
||||
'total': len(vod_list) * 50}
|
||||
|
||||
if tid in ('home', 'new', 'hot'):
|
||||
# 首页/最新/热门 → 使用 queryTypeVideosH5
|
||||
# homeH5 端点始终返回 20001,改用已验证通的 queryTypeVideosH5
|
||||
sort_map = {'home': '1', 'new': '1', 'hot': '2'}
|
||||
resp = self._api_request('/ht/content/queryTypeVideosH5', {
|
||||
'pageNo': str(pg - 1),
|
||||
'pageSize': '20',
|
||||
'sort': sort_map.get(tid, '1'),
|
||||
'type': '1',
|
||||
})
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
data = resp.get('data', {})
|
||||
|
||||
items = (data.get('typeVideoList') or data.get('list') or data.get('data') or data.get('videoList') or [])
|
||||
|
||||
if isinstance(items, list):
|
||||
for v in items:
|
||||
parsed = self._parse_video(v)
|
||||
if parsed:
|
||||
vod_list.append(parsed)
|
||||
|
||||
else:
|
||||
# 数值分类 (contentId) → queryTypeVideosH5
|
||||
# T3 通过 extend dict 传递筛选和排序参数
|
||||
# extend: {'label': '子分类id', 'tag': '标签名', 'sort': '排序值'}
|
||||
api_params = {
|
||||
'pageNo': str(pg - 1),
|
||||
'pageSize': '20',
|
||||
'typeId': tid, # 按分类过滤(queryTypeVideosH5 → typeId)
|
||||
'type': '1', # 媒体类型 1=视频(home 分支也带,缺少会导致 API 返回默认列表)
|
||||
}
|
||||
if isinstance(extend, dict):
|
||||
for key in ('label', 'tag', 'sort'):
|
||||
val = extend.get(key, '')
|
||||
if val:
|
||||
api_params[key] = val
|
||||
|
||||
resp = self._api_request('/ht/content/queryTypeVideosH5', api_params)
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
data = resp.get('data', {})
|
||||
items = (data.get('typeVideoList') or data.get('list') or data.get('data') or data.get('videoList') or [])
|
||||
|
||||
if isinstance(items, list):
|
||||
for v in items:
|
||||
parsed = self._parse_video(v)
|
||||
if parsed:
|
||||
vod_list.append(parsed)
|
||||
|
||||
# 使用 API 返回的真实 totalPage(pageSize 固定 20)
|
||||
total_page = int(data.get('totalPage') or 1)
|
||||
return {
|
||||
'list': vod_list,
|
||||
'page': pg,
|
||||
'pagecount': total_page,
|
||||
'limit': len(vod_list),
|
||||
'total': total_page * 20,
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 辅助: 从 data 提取视频列表
|
||||
# ============================================================
|
||||
def _extract_videos_from_data(self, data):
|
||||
"""从响应 data 中提取视频列表(多种格式兼容)"""
|
||||
# data 可能是 dict 或 list
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif not isinstance(data, dict):
|
||||
return []
|
||||
else:
|
||||
items = (data.get('videoList') or data.get('list') or data.get('data')
|
||||
or data.get('videos') or data.get('typeVideoList')
|
||||
or data.get('topicVideoIdList') or data.get('searchList')
|
||||
or data.get('contentList') or data.get('records')
|
||||
or data.get('pageData') or [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [p for v in items if (p := self._parse_video(v))]
|
||||
|
||||
# ============================================================
|
||||
# 辅助: 从 dict item 中尝试获取字段值(多种命名兼容)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _try_get(item, *keys):
|
||||
"""依次尝试多个字段名, 返回第一个非空值"""
|
||||
for k in keys:
|
||||
v = item.get(k)
|
||||
if v is not None and v != '':
|
||||
return v
|
||||
return ''
|
||||
|
||||
# ============================================================
|
||||
# 解析女优列表(getActors API 响应 → folder list, vod_id + '@')
|
||||
# ============================================================
|
||||
def _parse_actor_list(self, data):
|
||||
"""解析 getActors 返回的演员列表,生成带 @ 后缀的 folder 条目"""
|
||||
# data 可能是 dict 或 list
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif not isinstance(data, dict):
|
||||
return []
|
||||
else:
|
||||
items = (data.get('actorList') or data.get('actors') or data.get('list')
|
||||
or data.get('data') or [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
results = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
actor_id = str(self._try_get(item,
|
||||
'actorId', 'contentId', 'id', 'artId', 'actor_id', 'userId'))
|
||||
actor_name = str(self._try_get(item,
|
||||
'actorName', 'name', 'title', 'artName', 'actor_name', 'actor'))
|
||||
actor_img = str(self._try_get(item,
|
||||
'actorPic', 'actorImg', 'img', 'avatar', 'cover',
|
||||
'imageUrl', 'headImg', 'head', 'photo', 'image', 'pic', 'actor_img'))
|
||||
actor_count = str(self._try_get(item,
|
||||
'videoCount', 'contentCount', 'count', 'totalCount',
|
||||
'total', 'video_count'))
|
||||
|
||||
if not actor_id:
|
||||
continue
|
||||
if actor_id in seen:
|
||||
continue
|
||||
seen.add(actor_id)
|
||||
|
||||
# 兜底: 无图时用 favicon 保证 item 可见
|
||||
if not actor_img:
|
||||
actor_img = self.host + '/favicon.ico'
|
||||
remarks = f'{actor_count}部' if actor_count else ''
|
||||
results.append({
|
||||
'vod_id': 'actor_' + actor_id + '@',
|
||||
'vod_name': actor_name or ('演员' + actor_id),
|
||||
'vod_pic': self.get_proxy_image_url(actor_img),
|
||||
'vod_tag': 'folder',
|
||||
'vod_remarks': remarks,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
# ============================================================
|
||||
# 解析专题列表(getOriTopicList API 响应 → folder list, vod_id + '@')
|
||||
# ============================================================
|
||||
def _parse_topic_list(self, data):
|
||||
"""解析 getOriTopicList 返回的专题列表,生成带 @ 后缀的 folder 条目"""
|
||||
# data 可能是 dict 或 list
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif not isinstance(data, dict):
|
||||
return []
|
||||
else:
|
||||
items = (data.get('topicList') or data.get('oriTopicList') or data.get('list')
|
||||
or data.get('data') or data.get('topics') or [])
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
results = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
topic_id = str(self._try_get(item,
|
||||
'topicId', 'id', 'contentId', 'oriTopicId', 'topic_id'))
|
||||
topic_name = str(self._try_get(item,
|
||||
'topicName', 'name', 'title', 'oriTopicName', 'topic_name', 'topic'))
|
||||
topic_img = str(self._try_get(item,
|
||||
'topicPic', 'topicImg', 'img', 'cover', 'imageUrl', 'pic',
|
||||
'thumb', 'image', 'topic_img', 'oriTopicImg'))
|
||||
topic_count = str(self._try_get(item,
|
||||
'videoCount', 'count', 'contentCount', 'totalCount',
|
||||
'total', 'video_count'))
|
||||
|
||||
if not topic_id:
|
||||
continue
|
||||
if topic_id in seen:
|
||||
continue
|
||||
seen.add(topic_id)
|
||||
|
||||
# 兜底: 无图时用 favicon 保证 item 可见
|
||||
if not topic_img:
|
||||
topic_img = self.host + '/favicon.ico'
|
||||
remarks = f'{topic_count}部' if topic_count else ''
|
||||
results.append({
|
||||
'vod_id': 'topic_' + topic_id + '@',
|
||||
'vod_name': topic_name or ('专题' + topic_id),
|
||||
'vod_pic': self.get_proxy_image_url(topic_img),
|
||||
'vod_tag': 'folder',
|
||||
'vod_remarks': remarks,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
# ============================================================
|
||||
# 解析视频条目
|
||||
# ============================================================
|
||||
def _parse_video(self, item):
|
||||
# 过滤广告 (contentType=3, 带 jumpScheme 跳转链接)
|
||||
if item.get('contentType') != 1:
|
||||
return None
|
||||
|
||||
vid = str(item.get('contentId') or item.get('id') or item.get('videoId') or '')
|
||||
title = item.get('title') or item.get('name') or item.get('videoTitle') or ''
|
||||
pic = item.get('img') or item.get('cover') or item.get('coverUrl') or item.get('pic') or item.get('imageUrl') or ''
|
||||
remarks = item.get('duration') or item.get('playCount') or item.get('remark') or ''
|
||||
|
||||
# 时长格式化
|
||||
if remarks and str(remarks).isdigit():
|
||||
remarks = self._fmt_duration(remarks)
|
||||
|
||||
return {
|
||||
'vod_id': vid,
|
||||
'vod_name': title,
|
||||
'vod_pic': self.get_proxy_image_url(pic) if pic else '',
|
||||
'vod_remarks': str(remarks) if remarks else '',
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 详情页
|
||||
# ============================================================
|
||||
def detailContent(self, ids):
|
||||
did = ids[0] if isinstance(ids, list) else ids
|
||||
|
||||
self._select_best_site()
|
||||
self._ensure_session()
|
||||
|
||||
resp = self._api_request('/ht/content/detail', {'contentId': str(did)})
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': []}
|
||||
|
||||
detail = resp.get('data', {})
|
||||
|
||||
if not detail:
|
||||
return {'list': []}
|
||||
|
||||
# 兼容多种字段名
|
||||
title = (detail.get('title') or detail.get('name') or
|
||||
detail.get('videoTitle') or '未知标题')
|
||||
pic = (detail.get('cover') or detail.get('coverUrl') or
|
||||
detail.get('img') or detail.get('imageUrl') or '')
|
||||
desc = detail.get('description') or detail.get('desc') or detail.get('intro') or ''
|
||||
duration = detail.get('duration', 0)
|
||||
actor = detail.get('actor') or detail.get('actors') or ''
|
||||
|
||||
# 播放地址: videoUrl / playUrl / m3u8
|
||||
play_url = (detail.get('videoUrl') or detail.get('playUrl') or
|
||||
detail.get('url') or detail.get('m3u8Url') or
|
||||
detail.get('sl') or '')
|
||||
|
||||
vod_play_url = '播放$' + str(did)
|
||||
if play_url:
|
||||
vod_play_url = '播放$' + play_url
|
||||
|
||||
return {'list': [{
|
||||
'vod_id': str(did),
|
||||
'vod_name': title,
|
||||
'vod_pic': self.get_proxy_image_url(pic) if pic else '',
|
||||
'vod_actor': str(actor) if actor else '',
|
||||
'vod_director': '',
|
||||
'vod_content': desc,
|
||||
'vod_year': '',
|
||||
'vod_area': '',
|
||||
'vod_remarks': self._fmt_duration(duration),
|
||||
'vod_play_from': '蜜桃视频',
|
||||
'vod_play_url': vod_play_url,
|
||||
'type': 'video',
|
||||
}]}
|
||||
|
||||
# ============================================================
|
||||
# 搜索
|
||||
# ============================================================
|
||||
def searchContent(self, key, quick, pg=1):
|
||||
self._select_best_site()
|
||||
self._ensure_session()
|
||||
|
||||
pg = int(pg)
|
||||
resp = self._api_request('/ht/content/search', {
|
||||
'keywords': key,
|
||||
'pageNo': pg - 1,
|
||||
'pageSize': 20,
|
||||
})
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
data = resp.get('data', {})
|
||||
|
||||
# 兼容多种 data 形态:list / dict
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
total_n = len(data)
|
||||
elif isinstance(data, dict):
|
||||
items = (data.get('searchList')
|
||||
or data.get('list')
|
||||
or data.get('data')
|
||||
or data.get('videoList')
|
||||
or data.get('records')
|
||||
or data.get('resultList')
|
||||
or data.get('content')
|
||||
or [])
|
||||
total_n = data.get('total') or data.get('totalCount') or data.get('totalNum') or 0
|
||||
else:
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
if not isinstance(items, list):
|
||||
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
|
||||
|
||||
vod_list = [p for v in items if (p := self._parse_video(v))]
|
||||
total_page = int(data.get('totalPage') or 1) if isinstance(data, dict) else max(1, len(vod_list) // 20)
|
||||
return {
|
||||
'list': vod_list,
|
||||
'page': pg,
|
||||
'pagecount': total_page,
|
||||
'limit': len(vod_list),
|
||||
'total': total_page * 20,
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 播放解析
|
||||
# ============================================================
|
||||
def playerContent(self, flag, id, vipFlags=None):
|
||||
url = id.split('$')[-1]
|
||||
|
||||
# 如果已经是完整 URL
|
||||
if url.startswith('http'):
|
||||
return {
|
||||
'parse': 0,
|
||||
'url': url,
|
||||
'jx': 0,
|
||||
'header': {
|
||||
'User-Agent': self.headers['User-Agent'],
|
||||
'Referer': self.host + '/',
|
||||
},
|
||||
}
|
||||
|
||||
# 否则作为 videoId 重新获取
|
||||
self._select_best_site()
|
||||
self._ensure_session()
|
||||
|
||||
resp = self._api_request('/ht/content/detail', {'contentId': url})
|
||||
if not resp or resp.get('code') != 10000:
|
||||
return {'parse': 0, 'url': '', 'jx': 0}
|
||||
|
||||
detail = resp.get('data', {})
|
||||
play_url = (detail.get('videoUrl') or detail.get('playUrl') or
|
||||
detail.get('url') or detail.get('m3u8Url') or
|
||||
detail.get('sl') or '')
|
||||
|
||||
return {
|
||||
'parse': 0,
|
||||
'url': play_url,
|
||||
'jx': 0,
|
||||
'header': {
|
||||
'User-Agent': self.headers['User-Agent'],
|
||||
'Referer': self.host + '/',
|
||||
},
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 图片代理
|
||||
# ============================================================
|
||||
def localProxy(self, params):
|
||||
try:
|
||||
if params.get('type') != PROXY_TYPE:
|
||||
return [404, 'text/plain', 'not found']
|
||||
|
||||
img_url = params.get('url', '')
|
||||
if not img_url:
|
||||
return [400, 'text/plain', 'missing url']
|
||||
|
||||
img_url = unquote(img_url)
|
||||
|
||||
r = requests.get(img_url, headers={
|
||||
'User-Agent': self.headers['User-Agent'],
|
||||
'Referer': self.host + '/',
|
||||
}, timeout=TIMEOUT, verify=False)
|
||||
|
||||
if r.status_code != 200:
|
||||
return [404, 'text/plain', 'image not found']
|
||||
|
||||
data = r.content
|
||||
|
||||
# 尝试 XOR 0x88 解密 (蜜桃图片防盗链, _xfile.jpg 全部 XOR)
|
||||
if data[:2] != b'\xff\xd8' and data[:4] != b'\x89PNG' \
|
||||
and not (data[:4] == b'RIFF' and data[8:12] == b'WEBP'):
|
||||
decoded = bytes(b ^ 0x88 for b in data)
|
||||
if decoded[:2] == b'\xff\xd8' or decoded[:4] == b'\x89PNG' \
|
||||
or (decoded[:4] == b'RIFF' and decoded[8:12] == b'WEBP'):
|
||||
data = decoded
|
||||
|
||||
if data[:2] == b'\xff\xd8':
|
||||
return [200, 'image/jpeg', data, {'Content-Length': str(len(data))}]
|
||||
elif data[:4] == b'\x89PNG':
|
||||
return [200, 'image/png', data, {'Content-Length': str(len(data))}]
|
||||
elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||
return [200, 'image/webp', data, {'Content-Length': str(len(data))}]
|
||||
else:
|
||||
mime = r.headers.get('Content-Type', 'image/jpeg')
|
||||
if mime.startswith('image/'):
|
||||
return [200, mime, data, {'Content-Length': str(len(data))}]
|
||||
return [404, 'text/plain', 'invalid image format']
|
||||
except Exception:
|
||||
return [500, 'text/plain', 'proxy error']
|
||||
@@ -0,0 +1,464 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import sys
|
||||
import hashlib
|
||||
import time
|
||||
import requests
|
||||
import re
|
||||
import json
|
||||
sys.path.append('..')
|
||||
from base.spider import Spider
|
||||
|
||||
|
||||
class Spider(Spider):
|
||||
def getName(self):
|
||||
return "Aidianying"
|
||||
|
||||
def init(self, extend):
|
||||
self.home_url = 'https://m.sdzhgt.com/'
|
||||
self.ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
self.error_url = "https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/mp4/xgplayer-demo-720p.mp4"
|
||||
|
||||
def getDependence(self):
|
||||
return []
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def homeContent(self, filter):
|
||||
return {
|
||||
'class': [{'type_id': '1', 'type_name': '电影'},
|
||||
{'type_id': '2', 'type_name': '电视剧'},
|
||||
{'type_id': '3', 'type_name': '综艺'},
|
||||
{'type_id': '4', 'type_name': '动漫'}],
|
||||
'filters': {
|
||||
'1': [
|
||||
{'key': 'type',
|
||||
'name': '类型',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '喜剧', 'v': '/type/22'},
|
||||
{'n': '动作', 'v': '/type/23'},
|
||||
{'n': '科幻', 'v': '/type/30'},
|
||||
{'n': '爱情', 'v': '/type/26'},
|
||||
{'n': '悬疑', 'v': '/type/27'},
|
||||
{'n': '奇幻', 'v': '/type/87'},
|
||||
{'n': '剧情', 'v': '/type/37'},
|
||||
{'n': '恐怖', 'v': '/type/36'},
|
||||
{'n': '犯罪', 'v': '/type/35'},
|
||||
{'n': '动画', 'v': '/type/33'},
|
||||
{'n': '惊悚', 'v': '/type/34'},
|
||||
{'n': '战争', 'v': '/type/25'},
|
||||
{'n': '冒险', 'v': '/type/31'},
|
||||
{'n': '灾难', 'v': '/type/81'},
|
||||
{'n': '伦理', 'v': '/type/83'},
|
||||
{'n': '其他', 'v': '/type/43'}]},
|
||||
{'key': 'area',
|
||||
'name': '地区',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '中国大陆', 'v': '/area/中国大陆'},
|
||||
{'n': '中国香港', 'v': '/area/中国香港'},
|
||||
{'n': '中国台湾', 'v': '/area/中国台湾'},
|
||||
{'n': '美国', 'v': '/area/美国'},
|
||||
{'n': '日本', 'v': '/area/日本'},
|
||||
{'n': '韩国', 'v': '/area/韩国'},
|
||||
{'n': '印度', 'v': '/area/印度'},
|
||||
{'n': '泰国', 'v': '/area/泰国'},
|
||||
{'n': '其他', 'v': '/area/其他'}]},
|
||||
{'key': 'year',
|
||||
'name': '年份',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '2026', 'v': '/year/2026'},
|
||||
{'n': '2025', 'v': '/year/2025'},
|
||||
{'n': '2024', 'v': '/year/2024'},
|
||||
{'n': '2023', 'v': '/year/2023'},
|
||||
{'n': '2022', 'v': '/year/2022'},
|
||||
{'n': '2021', 'v': '/year/2021'},
|
||||
{'n': '2020', 'v': '/year/2020'},
|
||||
{'n': '2019', 'v': '/year/2019'},
|
||||
{'n': '2018', 'v': '/year/2018'},
|
||||
{'n': '2017', 'v': '/year/2017'},
|
||||
{'n': '2016', 'v': '/year/2016'},
|
||||
{'n': '2015', 'v': '/year/2015'},
|
||||
{'n': '2014', 'v': '/year/2014'},
|
||||
{'n': '2013', 'v': '/year/2013'},
|
||||
{'n': '2012', 'v': '/year/2012'},
|
||||
{'n': '2011', 'v': '/year/2011'},
|
||||
{'n': '2010', 'v': '/year/2010'},
|
||||
{'n': '2009~2000', 'v': '/year/2009~2000'}]},
|
||||
{'key': 'lang',
|
||||
'name': '语言',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '国语', 'v': '/lang/国语'},
|
||||
{'n': '英语', 'v': '/lang/英语'},
|
||||
{'n': '粤语', 'v': '/lang/粤语'},
|
||||
{'n': '韩语', 'v': '/lang/韩语'},
|
||||
{'n': '日语', 'v': '/lang/日语'},
|
||||
{'n': '其他', 'v': '/lang/其他'}]},
|
||||
{'key': 'by',
|
||||
'name': '排序',
|
||||
'value': [{'n': '上映时间', 'v': '/sortType/1/sortOrder/0'},
|
||||
{'n': '人气高低', 'v': '/sortType/3/sortOrder/0'},
|
||||
{'n': '评分高低', 'v': '/sortType/4/sortOrder/0'}]}
|
||||
],
|
||||
'2': [
|
||||
{'key': 'type',
|
||||
'name': '类型',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '国产剧', 'v': '/type/14'},
|
||||
{'n': '欧美剧', 'v': '/type/15'},
|
||||
{'n': '港台剧', 'v': '/type/16'},
|
||||
{'n': '日韩剧', 'v': '/type/62'},
|
||||
{'n': '其他剧', 'v': '/type/68'}]},
|
||||
{'key': 'class',
|
||||
'name': '剧情',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '古装', 'v': '/class/古装'},
|
||||
{'n': '战争', 'v': '/class/战争'},
|
||||
{'n': '喜剧', 'v': '/class/喜剧'},
|
||||
{'n': '家庭', 'v': '/class/家庭'},
|
||||
{'n': '犯罪', 'v': '/class/犯罪'},
|
||||
{'n': '动作', 'v': '/class/动作'},
|
||||
{'n': '奇幻', 'v': '/class/奇幻'},
|
||||
{'n': '剧情', 'v': '/class/剧情'},
|
||||
{'n': '历史', 'v': '/class/历史'},
|
||||
{'n': '短片', 'v': '/class/短片'}]},
|
||||
{'key': 'area',
|
||||
'name': '地区',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '中国大陆', 'v': '/area/中国大陆'},
|
||||
{'n': '中国香港', 'v': '/area/中国香港'},
|
||||
{'n': '中国台湾', 'v': '/area/中国台湾'},
|
||||
{'n': '日本', 'v': '/area/日本'},
|
||||
{'n': '韩国', 'v': '/area/韩国'},
|
||||
{'n': '美国', 'v': '/area/美国'},
|
||||
{'n': '泰国', 'v': '/area/泰国'},
|
||||
{'n': '其他', 'v': '/area/其他'}]},
|
||||
{'key': 'year',
|
||||
'name': '时间',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '2026', 'v': '/year/2026'},
|
||||
{'n': '2025', 'v': '/year/2025'},
|
||||
{'n': '2024', 'v': '/year/2024'},
|
||||
{'n': '2023', 'v': '/year/2023'},
|
||||
{'n': '2022', 'v': '/year/2022'},
|
||||
{'n': '2021', 'v': '/year/2021'},
|
||||
{'n': '2020', 'v': '/year/2020'},
|
||||
{'n': '2019', 'v': '/year/2019'},
|
||||
{'n': '2018', 'v': '/year/2018'},
|
||||
{'n': '2017', 'v': '/year/2017'},
|
||||
{'n': '2016', 'v': '/year/2016'},
|
||||
{'n': '2015', 'v': '/year/2015'},
|
||||
{'n': '2014', 'v': '/year/2014'},
|
||||
{'n': '2013', 'v': '/year/2013'},
|
||||
{'n': '2012', 'v': '/year/2012'},
|
||||
{'n': '2011', 'v': '/year/2011'},
|
||||
{'n': '2010', 'v': '/year/2010'}]},
|
||||
{'key': 'lang',
|
||||
'name': '语言',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '普通话', 'v': '/lang/普通话'},
|
||||
{'n': '英语', 'v': '/lang/英语'},
|
||||
{'n': '粤语', 'v': '/lang/粤语'},
|
||||
{'n': '韩语', 'v': '/lang/韩语'},
|
||||
{'n': '日语', 'v': '/lang/日语'},
|
||||
{'n': '泰语', 'v': '/lang/泰语'},
|
||||
{'n': '其他', 'v': '/lang/其他'}, ]},
|
||||
{'key': 'by',
|
||||
'name': '排序',
|
||||
'value': [{'n': '最近更新', 'v': '/sortType/1/sortOrder/0'},
|
||||
{'n': '添加时间', 'v': '/sortType/2/sortOrder/0'},
|
||||
{'n': '人气高低', 'v': '/sortType/3/sortOrder/0'},
|
||||
{'n': '评分高低', 'v': '/sortType/4/sortOrder/0'}]}
|
||||
],
|
||||
'3': [
|
||||
{'key': 'type',
|
||||
'name': '类型',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '国产综艺', 'v': '/type/69'},
|
||||
{'n': '港台综艺', 'v': '/type/70'},
|
||||
{'n': '日韩综艺', 'v': '/type/72'},
|
||||
{'n': '欧美综艺', 'v': '/type/73'}]},
|
||||
{'key': 'class',
|
||||
'name': '剧情',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '真人秀', 'v': '/class/真人秀'},
|
||||
{'n': '音乐', 'v': '/class/音乐'},
|
||||
{'n': '脱口秀', 'v': '/class/脱口秀'}]},
|
||||
{'key': 'area',
|
||||
'name': '地区',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '中国大陆', 'v': '/area/中国大陆'},
|
||||
{'n': '中国香港', 'v': '/area/中国香港'},
|
||||
{'n': '中国台湾', 'v': '/area/中国台湾'},
|
||||
{'n': '日本', 'v': '/area/日本'},
|
||||
{'n': '韩国', 'v': '/area/韩国'},
|
||||
{'n': '美国', 'v': '/area/美国'},
|
||||
{'n': '其他', 'v': '/area/其他'}]},
|
||||
{'key': 'year',
|
||||
'name': '时间',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '2024', 'v': '/year/2024'},
|
||||
{'n': '2023', 'v': '/year/2023'},
|
||||
{'n': '2022', 'v': '/year/2022'},
|
||||
{'n': '2021', 'v': '/year/2021'},
|
||||
{'n': '2020', 'v': '/year/2020'}]},
|
||||
{'key': 'lang',
|
||||
'name': '语言',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '国语', 'v': '/lang/国语'},
|
||||
{'n': '英语', 'v': '/lang/英语'},
|
||||
{'n': '粤语', 'v': '/lang/粤语'},
|
||||
{'n': '韩语', 'v': '/lang/韩语'},
|
||||
{'n': '日语', 'v': '/lang/日语'},
|
||||
{'n': '其他', 'v': '/lang/其他'}, ]},
|
||||
{'key': 'by',
|
||||
'name': '排序',
|
||||
'value': [{'n': '最近更新', 'v': '/sortType/1/sortOrder/0'},
|
||||
{'n': '添加时间', 'v': '/sortType/2/sortOrder/0'},
|
||||
{'n': '人气高低', 'v': '/sortType/3/sortOrder/0'},
|
||||
{'n': '评分高低', 'v': '/sortType/4/sortOrder/0'}]}
|
||||
],
|
||||
'4': [
|
||||
{'key': 'type',
|
||||
'name': '类型',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '国产动漫', 'v': '/type/75'},
|
||||
{'n': '日韩动漫', 'v': '/type/76'},
|
||||
{'n': '欧美动漫', 'v': '/type/77'}]},
|
||||
{'key': 'class',
|
||||
'name': '剧情',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '喜剧', 'v': '/class/喜剧'},
|
||||
{'n': '科幻', 'v': '/class/科幻'},
|
||||
{'n': '热血', 'v': '/class/热血'},
|
||||
{'n': '冒险', 'v': '/class/冒险'},
|
||||
{'n': '动作', 'v': '/class/动作'},
|
||||
{'n': '运动', 'v': '/class/运动'},
|
||||
{'n': '战争', 'v': '/class/战争'},
|
||||
{'n': '儿童', 'v': '/class/儿童'}]},
|
||||
{'key': 'area',
|
||||
'name': '地区',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '中国大陆', 'v': '/area/中国大陆'},
|
||||
{'n': '日本', 'v': '/area/日本'},
|
||||
{'n': '美国', 'v': '/area/美国'},
|
||||
{'n': '其他', 'v': '/area/其他'}]},
|
||||
{'key': 'year',
|
||||
'name': '时间',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '2024', 'v': '/year/2024'},
|
||||
{'n': '2023', 'v': '/year/2023'},
|
||||
{'n': '2022', 'v': '/year/2022'},
|
||||
{'n': '2021', 'v': '/year/2021'},
|
||||
{'n': '2020', 'v': '/year/2020'},
|
||||
{'n': '2019', 'v': '/year/2019'},
|
||||
{'n': '2018', 'v': '/year/2018'},
|
||||
{'n': '2017', 'v': '/year/2017'},
|
||||
{'n': '2016', 'v': '/year/2016'},
|
||||
{'n': '2015', 'v': '/year/2015'},
|
||||
{'n': '2014', 'v': '/year/2014'},
|
||||
{'n': '2013', 'v': '/year/2013'},
|
||||
{'n': '2012', 'v': '/year/2012'},
|
||||
{'n': '2011', 'v': '/year/2011'},
|
||||
{'n': '2010', 'v': '/year/2010'}]},
|
||||
{'key': 'lang',
|
||||
'name': '语言',
|
||||
'value': [{'n': '全部', 'v': ''},
|
||||
{'n': '国语', 'v': '/lang/国语'},
|
||||
{'n': '英语', 'v': '/lang/英语'},
|
||||
{'n': '日语', 'v': '/lang/日语'},
|
||||
{'n': '其他', 'v': '/lang/其他'}]},
|
||||
{'key': 'by',
|
||||
'name': '排序',
|
||||
'value': [{'n': '最近更新', 'v': '/sortType/1/sortOrder/0'},
|
||||
{'n': '添加时间', 'v': '/sortType/2/sortOrder/0'},
|
||||
{'n': '人气高低', 'v': '/sortType/3/sortOrder/0'},
|
||||
{'n': '评分高低', 'v': '/sortType/4/sortOrder/0'}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
def homeVideoContent(self):
|
||||
video_list = []
|
||||
t = str(int(time.time() * 1000))
|
||||
# t = '1723292093234'
|
||||
data = f'key=cb808529bae6b6be45ecfab29a4889bc&t={t}'
|
||||
data_md5 = hashlib.md5(data.encode()).hexdigest()
|
||||
data_sha1 = hashlib.sha1(data_md5.encode()).hexdigest()
|
||||
h = {
|
||||
"User-Agent": self.ua,
|
||||
'referer': self.home_url, 't': t, 'sign': data_sha1}
|
||||
try:
|
||||
res = requests.get(f'{self.home_url}/api/mw-movie/anonymous/home/hotSearch', headers=h)
|
||||
data_list = res.json()['data']
|
||||
for i in data_list:
|
||||
video_list.append(
|
||||
{
|
||||
'vod_id': i['vodId'],
|
||||
'vod_name': i['vodName'],
|
||||
'vod_pic': i['vodPic'],
|
||||
'vod_remarks': i['vodVersion'] if i['typeId1'] == 1 else i['vodRemarks']
|
||||
}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return {
|
||||
'list': [],
|
||||
'parse': 0,
|
||||
'jx': 0
|
||||
}
|
||||
|
||||
return {
|
||||
'list': video_list,
|
||||
'parse': 0,
|
||||
'jx': 0
|
||||
}
|
||||
|
||||
def categoryContent(self, cid, page, filter, ext):
|
||||
t = cid
|
||||
_type = ext.get('type') if ext.get('type') else ''
|
||||
__class = ext.get('class') if ext.get('class') else ''
|
||||
_area = ext.get('area') if ext.get('area') else ''
|
||||
_year = ext.get('year') if ext.get('year') else ''
|
||||
_lang = ext.get('lang') if ext.get('lang') else ''
|
||||
_by = ext.get('by') if ext.get('by') else ''
|
||||
video_list = []
|
||||
h = {
|
||||
"User-Agent": self.ua,
|
||||
'referer': self.home_url,
|
||||
}
|
||||
try:
|
||||
res = requests.get(
|
||||
f'{self.home_url}/vod/show/id/{t}{_type}{__class}{_area}{_year}{_lang}{_by}/page/{page}',
|
||||
headers=h)
|
||||
aa = re.findall(r'\\"list\\":(.*?)}}}]', res.text)
|
||||
if not aa:
|
||||
return {'list': [], 'parse': 0, 'jx': 0}
|
||||
bb = aa[0].replace('\\"', '"')
|
||||
data_list = json.loads(bb)
|
||||
for i in data_list:
|
||||
video_list.append(
|
||||
{
|
||||
'vod_id': i['vodId'],
|
||||
'vod_name': i['vodName'],
|
||||
'vod_pic': i['vodPic'],
|
||||
'vod_remarks': i['vodVersion'] if i['typeId1'] == 1 else i['vodRemarks']
|
||||
}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return {'list': [], 'msg': e}
|
||||
return {'list': video_list, 'parse': 0, 'jx': 0}
|
||||
|
||||
def detailContent(self, did):
|
||||
ids = did[0]
|
||||
video_list = []
|
||||
t = str(int(time.time() * 1000))
|
||||
# t = '1723292093234'
|
||||
data = f'id={ids}&key=cb808529bae6b6be45ecfab29a4889bc&t={t}'
|
||||
data_md5 = hashlib.md5(data.encode()).hexdigest()
|
||||
data_sha1 = hashlib.sha1(data_md5.encode()).hexdigest()
|
||||
h = {
|
||||
"User-Agent": self.ua,
|
||||
'referer': self.home_url,
|
||||
't': t, 'sign': data_sha1
|
||||
}
|
||||
try:
|
||||
res = requests.get(f'{self.home_url}/api/mw-movie/anonymous/video/detail?id={ids}', headers=h)
|
||||
data = res.json()['data']
|
||||
play_list = data['episodeList']
|
||||
vod_play_url = []
|
||||
for i in play_list:
|
||||
name = i['name']
|
||||
url = ids + '/' + str(i['nid'])
|
||||
vod_play_url.append(name + '$' + url)
|
||||
|
||||
video_list.append(
|
||||
{
|
||||
'type_name': data['typeName'],
|
||||
'vod_id': ids,
|
||||
'vod_name': data['vodName'],
|
||||
'vod_remarks': data['vodRemarks'],
|
||||
'vod_year': data['vodYear'],
|
||||
'vod_area': data['vodArea'],
|
||||
'vod_actor': data['vodActor'],
|
||||
'vod_director': data['vodDirector'],
|
||||
'vod_content': data['vodContent'],
|
||||
'vod_play_from': '老僧酿酒',
|
||||
'vod_play_url': '#'.join(vod_play_url)
|
||||
|
||||
}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return {'list': [], 'msg': e}
|
||||
return {"list": video_list, 'parse': 0, 'jx': 0}
|
||||
|
||||
def searchContent(self, key, quick, page='1'):
|
||||
wd = key
|
||||
video_list = []
|
||||
t = str(int(time.time() * 1000))
|
||||
data = f'keyword={wd}&pageNum={page}&pageSize=12&key=cb808529bae6b6be45ecfab29a4889bc&t={t}'
|
||||
data_md5 = hashlib.md5(data.encode()).hexdigest()
|
||||
data_sha1 = hashlib.sha1(data_md5.encode()).hexdigest()
|
||||
h = {
|
||||
"User-Agent": self.ua,
|
||||
'referer': self.home_url,
|
||||
't': t, 'sign': data_sha1
|
||||
}
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.home_url}/api/mw-movie/anonymous/video/searchByWord?keyword={wd}&pageNum={page}&pageSize=12',
|
||||
headers=h,
|
||||
)
|
||||
data_list = response.json()['data']['result']['list']
|
||||
for i in data_list:
|
||||
video_list.append(
|
||||
{
|
||||
'vod_id': i['vodId'],
|
||||
'vod_name': i['vodName'],
|
||||
'vod_pic': i['vodPic'],
|
||||
'vod_remarks': i['vodVersion'] if i['typeId1'] == 1 else i['vodRemarks']
|
||||
}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return {'list': [], 'msg': e}
|
||||
return {'list': video_list, 'parse': 0, 'jx': 0}
|
||||
|
||||
def playerContent(self, flag, pid, vipFlags):
|
||||
url = pid
|
||||
play_url = self.error_url
|
||||
data = url.split('/')
|
||||
_id = data[0]
|
||||
_nid = data[1]
|
||||
t = str(int(time.time() * 1000))
|
||||
# t = '1723292093234'
|
||||
data = f'id={_id}&nid={_nid}&key=cb808529bae6b6be45ecfab29a4889bc&t={t}'
|
||||
data_md5 = hashlib.md5(data.encode()).hexdigest()
|
||||
data_sha1 = hashlib.sha1(data_md5.encode()).hexdigest()
|
||||
h = {
|
||||
"User-Agent": self.ua,
|
||||
'referer': self.home_url,
|
||||
't': t, 'sign': data_sha1
|
||||
}
|
||||
h2 = {
|
||||
"User-Agent": self.ua,
|
||||
}
|
||||
try:
|
||||
res = requests.get(
|
||||
f'{self.home_url}/api/mw-movie/anonymous/v2/video/episode/url?id={_id}&nid={_nid}',
|
||||
headers=h)
|
||||
play_url = res.json()['data']['list'][0]['url']
|
||||
except requests.RequestException as e:
|
||||
return {"url": play_url, "header": h2, "parse": 0, "jx": 0}
|
||||
|
||||
return {"url": play_url, "header": h2, "parse": 0, "jx": 0}
|
||||
|
||||
def localProxy(self, params):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
return '正在Destroy'
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
Reference in New Issue
Block a user