350 lines
15 KiB
Python
350 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
# 本资源来源于互联网公开渠道,仅可用于个人学习爬虫技术。
|
|
# 严禁将其用于任何商业用途,下载后请于 24 小时内删除,搜索结果均来自源站,本人不承担任何责任。
|
|
#junyouyun
|
|
|
|
import sys
|
|
import json
|
|
import time
|
|
import base64
|
|
import hashlib
|
|
import urllib3
|
|
import concurrent.futures
|
|
from urllib.parse import quote
|
|
from base.spider import Spider
|
|
from Crypto.PublicKey import RSA
|
|
from Crypto.Cipher import PKCS1_v1_5
|
|
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
sys.path.append('..')
|
|
|
|
class Spider(Spider):
|
|
host, userid, episode_list = '', '', []
|
|
|
|
# ---------- 加密与签名相关常量 ----------
|
|
PUB_KEY_B64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoYt0BP77U+DM08BiI/QbSRIfxijXo85BTPqIM1Ow8BNwhLETzRIZ+dEwdWDbydG/PspgBAfRpGaYVdJYtvaC2JnoO8+Ik6qMWojfEJxSFLa0Pb0A892tun4gsxoEMjcreZ+YGyaBxAfqX0BSMfdrOgIYaZQjYrw9TRLlUT31QoQIDAQAB"
|
|
APP_SIGN_SHA1 = "09a8dc51639a31801af5f6418caebfabc695eb24"
|
|
DEVICE_ID = "2d590b9842d064a1"
|
|
|
|
# RSA 私钥(用于解密响应)
|
|
PRIV_KEY_B64 = """MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCquQQ5r6+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=="""
|
|
|
|
headers = {
|
|
'User-Agent': "okhttp/4.12.0",
|
|
'Connection': "Keep-Alive",
|
|
'Accept-Encoding': "gzip",
|
|
'Content-Type': "application/json;charset=UTF-8",
|
|
'Cache-Control': "no-cache",
|
|
'token': "",
|
|
'deviceId': DEVICE_ID,
|
|
'client': "app",
|
|
'deviceType': "Android"
|
|
}
|
|
|
|
# ---------- RSA 加密 ----------
|
|
def rsa_encrypt(self, data: str) -> str:
|
|
key = RSA.import_key(base64.b64decode(self.PUB_KEY_B64))
|
|
cipher = PKCS1_v1_5.new(key)
|
|
encrypted = cipher.encrypt(data.encode('utf-8'))
|
|
return base64.b64encode(encrypted).decode('utf-8')
|
|
|
|
# ---------- RSA 解密(支持分块) ----------
|
|
def rsa_decrypt(self, encrypted_b64: str) -> str:
|
|
key = RSA.import_key(base64.b64decode(self.PRIV_KEY_B64))
|
|
cipher = PKCS1_v1_5.new(key)
|
|
encrypted_bytes = base64.b64decode(encrypted_b64)
|
|
block_size = 256
|
|
decrypted_parts = []
|
|
for i in range(0, len(encrypted_bytes), block_size):
|
|
block = encrypted_bytes[i:i+block_size]
|
|
decrypted_parts.append(cipher.decrypt(block, None))
|
|
return b''.join(decrypted_parts).decode('utf-8')
|
|
|
|
# ---------- 构建签名参数 ----------
|
|
def build_params_string(self, episode_id="", episode_index="", vid="", player_id="", type_id="", user_id=""):
|
|
return (f"episodeId{episode_id}"
|
|
f"episodeIndex{episode_index}"
|
|
f"id{vid}"
|
|
f"playerId{player_id}"
|
|
f"source0"
|
|
f"typeId{type_id}"
|
|
f"userId{user_id}")
|
|
|
|
def generate_sign(self, timestamp: str, params_str: str, device_id: str) -> str:
|
|
raw = f"SaltLSFBTimestamp{timestamp}Params{params_str}ClientappDeviceId{device_id}"
|
|
b64 = base64.b64encode(raw.encode('utf-8')).decode('utf-8')
|
|
md5 = hashlib.md5(b64.encode('utf-8')).hexdigest().upper()
|
|
return md5
|
|
|
|
def build_encrypted_headers(self, body_json: str, params_str: str) -> dict:
|
|
timestamp = str(int(time.time()))
|
|
encrypted_key = self.rsa_encrypt(body_json)
|
|
snjm = self.rsa_encrypt("113")
|
|
appsign = self.rsa_encrypt(self.APP_SIGN_SHA1)
|
|
sign = self.generate_sign(timestamp, params_str, self.DEVICE_ID)
|
|
|
|
headers = {
|
|
"snjm": snjm,
|
|
"appsign": appsign,
|
|
"timestamp": timestamp,
|
|
"sign": sign,
|
|
"deviceId": self.DEVICE_ID,
|
|
"token": self.headers.get('token', ''),
|
|
"client": "app",
|
|
"deviceType": "Android",
|
|
"Content-Type": "application/json;charset=UTF-8",
|
|
"Cache-Control": "no-cache",
|
|
"User-Agent": "okhttp/4.12.0"
|
|
}
|
|
return headers, {"key": encrypted_key}
|
|
|
|
# ---------- 原有接口(保持不变) ----------
|
|
def init(self, extend=''):
|
|
self.headers['deviceId'] = self.DEVICE_ID
|
|
self.host = 'http://qkys.qukanwh.com'
|
|
response = self.fetch(f'{self.host}/api/v1/app/user/visitorInfo', headers=self.headers).json()
|
|
self.userid = response['data']['id']
|
|
token = response['data']['token']
|
|
self.headers['token'] = token
|
|
|
|
def homeContent(self, filter):
|
|
response = self.post(f'{self.host}/api/v1/app/screen/screenType', headers=self.headers).json()
|
|
data = response['data']
|
|
classes = []
|
|
for i in data:
|
|
classes.append({'type_id': i['id'], 'type_name': i['name']})
|
|
return {'class': classes}
|
|
|
|
def homeVideoContent(self):
|
|
response = self.post(f'{self.host}/api/v1/app/recommend/recommendList', headers=self.headers).json()
|
|
data = response['data']
|
|
videos = []
|
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
future_to_id = {
|
|
executor.submit(
|
|
self.post,
|
|
f'{self.host}/api/v1/app/recommend/recommendSubList',
|
|
data=json.dumps({
|
|
"condition": item['id'],
|
|
"pageNum": 1,
|
|
"pageSize": 6
|
|
}),
|
|
headers=self.headers
|
|
): item['id'] for item in data
|
|
}
|
|
for future in concurrent.futures.as_completed(future_to_id):
|
|
try:
|
|
response = future.result().json()
|
|
for video in response['data']['records']:
|
|
videos.append({
|
|
"vod_id": video['id'],
|
|
"vod_name": video['name'],
|
|
"vod_pic": video['cover']
|
|
})
|
|
except Exception as e:
|
|
print(f"Request failed for item {future_to_id[future]}: {str(e)}")
|
|
return {'list': videos}
|
|
|
|
def categoryContent(self, tid, pg, filter, extend):
|
|
payload = {
|
|
"condition": {
|
|
"classify": "",
|
|
"region": "",
|
|
"sreecnTypeEnum": "NEWEST",
|
|
"typeId": tid,
|
|
"year": ""
|
|
},
|
|
"pageNum": pg,
|
|
"pageSize": 40
|
|
}
|
|
response = self.post(f'{self.host}/api/v1/app/screen/screenMovie', data=json.dumps(payload), headers=self.headers).json()
|
|
videos = []
|
|
for i in response['data']['records']:
|
|
videos.append({
|
|
"vod_id": i['id'],
|
|
"vod_name": i['name'],
|
|
"vod_pic": i['cover'],
|
|
"vod_remarks": i['area'],
|
|
"vod_year": i['year']
|
|
})
|
|
return {'list': videos, 'page': pg}
|
|
|
|
def searchContent(self, key, quick, pg='1'):
|
|
payload = {
|
|
"condition": {
|
|
"value": key
|
|
},
|
|
"pageNum": pg,
|
|
"pageSize": 40
|
|
}
|
|
response = self.post(f'{self.host}/api/v1/app/search/searchMovie', data=json.dumps(payload), headers=self.headers).json()
|
|
videos = []
|
|
for i in response['data']['records']:
|
|
videos.append({
|
|
'vod_id': i['id'],
|
|
'vod_name': i['name'],
|
|
'vod_pic': i['cover'],
|
|
'vod_remarks': i['area'],
|
|
'vod_year': i['year'],
|
|
'vod_area': i['area'],
|
|
'vod_content': i['desc']
|
|
})
|
|
return {'list': videos, 'page': pg}
|
|
|
|
# ---------- 详情页(已集成解密) ----------
|
|
def detailContent(self, ids):
|
|
type_id = "M15" # 注意:原脚本写死为 M17,可根据需要修改
|
|
vid = ids[0]
|
|
body = {
|
|
"id": vid,
|
|
"source": 0,
|
|
"typeId": type_id,
|
|
"userId": self.userid,
|
|
"episodeId": "",
|
|
"episodeIndex": "",
|
|
"playerId": ""
|
|
}
|
|
body_json = json.dumps(body, separators=(',', ':'))
|
|
params_str = self.build_params_string(
|
|
episode_id="",
|
|
episode_index="",
|
|
vid=str(vid),
|
|
player_id="",
|
|
type_id=type_id,
|
|
user_id=str(self.userid)
|
|
)
|
|
headers, payload = self.build_encrypted_headers(body_json, params_str)
|
|
|
|
# 发送加密请求
|
|
resp_raw = self.post(f'{self.host}/api/v1/app/play/movieDetails', data=json.dumps(payload), headers=headers).json()
|
|
encrypted_data = resp_raw.get('data')
|
|
if not encrypted_data:
|
|
raise Exception("响应中 data 为空")
|
|
# 解密 data 字段
|
|
decrypted_json_str = self.rsa_decrypt(encrypted_data)
|
|
data = json.loads(decrypted_json_str)
|
|
|
|
# 后续处理与原脚本相同
|
|
currentplayerid = data['playerId']
|
|
play_urls = []
|
|
play_url = []
|
|
show = []
|
|
for i in data['episodeList']:
|
|
play_url.append(f"{i['episode']}${ids[0]}@{currentplayerid}@{i['id']}@episode")
|
|
play_urls.append('#'.join(play_url))
|
|
moviePlayerList = data['moviePlayerList']
|
|
for i2 in moviePlayerList:
|
|
if i2['id'] == currentplayerid:
|
|
show.append(i2['moviePlayerName'])
|
|
for j in moviePlayerList:
|
|
playerid = j['id']
|
|
episodeTotal = j.get('episodeTotal')
|
|
if playerid == currentplayerid or episodeTotal is None:
|
|
continue
|
|
play_url = []
|
|
for k in range(1, episodeTotal + 1):
|
|
play_url.append(f"第{k}集${k}@{playerid}@{ids[0]}@virtual")
|
|
play_urls.append('#'.join(play_url))
|
|
if j['moviePlayerName'] not in show:
|
|
show.append(j['moviePlayerName'])
|
|
|
|
# 获取简介(此接口可能无需加密,保持原样)
|
|
payload_desc = {
|
|
"id": ids[0],
|
|
"typeId": type_id
|
|
}
|
|
response_desc = self.post(f'{self.host}/api/v1/app/play/movieDesc', data=json.dumps(payload_desc), headers=self.headers).json()
|
|
data2 = response_desc['data']
|
|
|
|
video = {
|
|
'vod_id': data2['id'],
|
|
'vod_name': data2['name'],
|
|
'vod_pic': data2['cover'],
|
|
'vod_content': data2['introduce'],
|
|
'vod_year': data2['year'],
|
|
'vod_area': data2['area'],
|
|
'vod_remarks': '',
|
|
'vod_score': data2['score'],
|
|
'type_name': data2['classify'],
|
|
'vod_director': data2['director'],
|
|
'vod_actor': data2['star'],
|
|
'vod_play_from': '$$$'.join(show),
|
|
'vod_play_url': '$$$'.join(play_urls)
|
|
}
|
|
return {'list': [video]}
|
|
|
|
# ---------- 播放页(已集成解密) ----------
|
|
def playerContent(self, flag, id, vipflags):
|
|
param, playerid, param2, param3 = id.split('@')
|
|
if param3 == 'virtual':
|
|
payload = {
|
|
"episodeIndex": str(int(param) - 1),
|
|
"id": int(param2),
|
|
"playerId": playerid,
|
|
"source": 0,
|
|
"typeId": "M15",
|
|
"userId": self.userid,
|
|
"episodeId": ""
|
|
}
|
|
else:
|
|
payload = {
|
|
"episodeId": param2,
|
|
"id": int(param),
|
|
"playerId": playerid,
|
|
"source": 0,
|
|
"typeId": "M15",
|
|
"userId": self.userid,
|
|
"episodeIndex": ""
|
|
}
|
|
body_json = json.dumps(payload, separators=(',', ':'))
|
|
print(body_json)
|
|
params_str = self.build_params_string(
|
|
episode_id=payload.get("episodeId", ""),
|
|
episode_index=payload.get("episodeIndex", ""),
|
|
vid=str(payload["id"]),
|
|
player_id=payload["playerId"],
|
|
type_id=payload["typeId"],
|
|
user_id=str(payload["userId"])
|
|
)
|
|
print(params_str)
|
|
headers, encrypted_payload = self.build_encrypted_headers(body_json, params_str)
|
|
print(headers)
|
|
print(encrypted_payload)
|
|
# 获取播放信息(加密响应)
|
|
resp_raw = self.post(f'{self.host}/api/v1/app/play/movieDetails', data=json.dumps(encrypted_payload), headers=headers).json()
|
|
encrypted_data = resp_raw.get('data')
|
|
if not encrypted_data:
|
|
raise Exception("响应中 data 为空")
|
|
decrypted_json_str = self.rsa_decrypt(encrypted_data)
|
|
data = json.loads(decrypted_json_str)
|
|
print(data)
|
|
parse_url = data['url']
|
|
playerid = data['playerId']
|
|
|
|
# 调用分析接口(注:analysisMovieUrl 的响应可能也是加密的,但原脚本直接取 data,这里暂不做额外解密)
|
|
analysis_body = {
|
|
"playerUrl": parse_url,
|
|
"playerId": playerid
|
|
}
|
|
analysis_json = json.dumps(analysis_body, separators=(',', ':'))
|
|
# analysisMovieUrl 接口的参数拼接?理论上也需要签名,但原脚本是 GET 方式,为了兼容,我们沿用原脚本的 GET 方式
|
|
# 原脚本使用 fetch GET 带参数,并未加密。这里也采用 GET 方式,不使用加密 headers
|
|
resp_analysis = self.fetch(f"{self.host}/api/v1/app/play/analysisMovieUrl?playerUrl={quote(parse_url,safe='')}&playerId={playerid}", headers=self.headers).json()
|
|
url = resp_analysis.get('data')
|
|
|
|
return {'jx': '0', 'parse': '0', 'url': url, 'header': {'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'}}
|
|
|
|
def getName(self):
|
|
pass
|
|
|
|
def isVideoFormat(self, url):
|
|
pass
|
|
|
|
def manualVideoCheck(self):
|
|
pass
|
|
|
|
def destroy(self):
|
|
pass
|
|
|
|
def localProxy(self, param):
|
|
pass |