333 lines
11 KiB
Python
333 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
# @Author : 星河
|
|
# @Time : 2026.03.17 18:34
|
|
# @file : symx.py
|
|
# 山有木兮 (symx.club) 爬虫 - OK影视/TVBox Python版本
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
import hmac
|
|
import hashlib
|
|
import urllib.parse
|
|
|
|
sys.path.append('..')
|
|
|
|
from base.spider import Spider as BaseSpider
|
|
|
|
class Spider(BaseSpider):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.name = "山有木兮"
|
|
self.host = "https://film.symx.club"
|
|
# XOR解密密钥(用于解密system/config中的安全参数)
|
|
self.xor_key = "0x1A2B3C4D5E6F7A8B9C"
|
|
# 签名消息前缀
|
|
self.sign_prefix = "symx_"
|
|
# 签名参数(从config解密得到,默认值)
|
|
self.sign_header_name = "X-Sign"
|
|
self.sign_order = "pts" # 默认顺序: path+timestamp+secret
|
|
self.sign_key = "symx"
|
|
|
|
def getName(self):
|
|
return self.name
|
|
|
|
def init(self, extend='{}'):
|
|
try:
|
|
ext = json.loads(extend)
|
|
if 'host' in ext:
|
|
self.host = ext['host'].rstrip('/')
|
|
except:
|
|
pass
|
|
# 获取安全配置
|
|
self.fetch_security_config()
|
|
|
|
|
|
|
|
def xor_decode(self, hex_str):
|
|
"""XOR解密hex编码的字符串"""
|
|
if not hex_str:
|
|
return ""
|
|
result = []
|
|
for i in range(0, len(hex_str), 2):
|
|
val = int(hex_str[i:i + 2], 16)
|
|
val ^= ord(self.xor_key[(i // 2) % len(self.xor_key)])
|
|
result.append(chr(val))
|
|
return ''.join(result)
|
|
|
|
def generate_timestamp(self):
|
|
"""生成带校验位的时间戳"""
|
|
ts = str(int(time.time() * 1000))
|
|
prefix = ts[:-1]
|
|
total = sum(int(c) for c in prefix)
|
|
return prefix + str(total % 10)
|
|
|
|
def generate_sign(self, api_path, timestamp):
|
|
"""生成HMAC-SHA256签名 - 核心算法"""
|
|
try:
|
|
secret = self.sign_prefix + self.sign_key
|
|
parts = {
|
|
'p': api_path,
|
|
't': timestamp,
|
|
's': secret
|
|
}
|
|
# 按sign_order顺序拼接
|
|
raw = ''.join(parts.get(c, '') for c in self.sign_order)
|
|
# 字符替换: 1->i, 0->o, 5->s
|
|
msg = raw.replace('1', 'i').replace('0', 'o').replace('5', 's')
|
|
# HMAC-SHA256
|
|
mac = hmac.new(self.sign_key.encode('utf-8'), msg.encode('utf-8'), hashlib.sha256)
|
|
return mac.hexdigest()
|
|
except:
|
|
return ""
|
|
|
|
def get_headers(self):
|
|
"""获取基础请求头"""
|
|
return {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
"Referer": self.host + "/",
|
|
"Accept": "application/json, text/plain, */*",
|
|
"X-Platform": "web"
|
|
}
|
|
|
|
def get_signed_headers(self, api_path):
|
|
"""获取带签名的请求头(用于需要鉴权的API)"""
|
|
headers = self.get_headers()
|
|
ts = self.generate_timestamp()
|
|
headers["X-Timestamp"] = ts
|
|
headers[self.sign_header_name] = self.generate_sign(api_path, ts)
|
|
return headers
|
|
|
|
def fetch_security_config(self):
|
|
"""从/api/system/config获取并解密安全参数"""
|
|
try:
|
|
import requests
|
|
resp = requests.get(self.host + "/api/system/config", headers=self.get_headers(), timeout=10)
|
|
data = resp.json()
|
|
if 'data' in data:
|
|
cfg = data['data']
|
|
raw_report_id = cfg.get('reportId', '')
|
|
raw_trace_id = cfg.get('traceId', '')
|
|
raw_session = cfg.get('session', '')
|
|
if raw_report_id:
|
|
self.sign_header_name = self.xor_decode(raw_report_id)
|
|
if raw_trace_id:
|
|
self.sign_order = self.xor_decode(raw_trace_id)
|
|
if raw_session:
|
|
self.sign_key = self.xor_decode(raw_session)
|
|
|
|
requests.get(self.host + "/api/stats/init", headers= self.get_signed_headers("/stats/init"), timeout=10)
|
|
except Exception as e:
|
|
print(f"[Symx] 获取安全配置失败: {e}")
|
|
|
|
def homeContent(self, filter):
|
|
import requests
|
|
result = {
|
|
'class': [],
|
|
'filters': {},
|
|
'list': [],
|
|
'parse': 0,
|
|
'jx': 0
|
|
}
|
|
|
|
try:
|
|
# 获取分类
|
|
cat_url = self.host + "/api/category/top"
|
|
|
|
# print(self.get_headers())
|
|
|
|
cat_resp = requests.get(cat_url, headers=self.get_headers(), timeout=10).json()
|
|
|
|
if 'data' in cat_resp:
|
|
for item in cat_resp['data']:
|
|
result['class'].append({
|
|
'type_id': str(item['id']),
|
|
'type_name': item['name']
|
|
})
|
|
|
|
# 获取首页推荐
|
|
film_url = self.host + "/api/film/category"
|
|
film_resp = requests.get(film_url, headers=self.get_headers(), timeout=10).json()
|
|
|
|
if 'data' in film_resp:
|
|
count = 0
|
|
for category in film_resp['data']:
|
|
if count >= 30:
|
|
break
|
|
if 'filmList' in category:
|
|
for film in category['filmList']:
|
|
if count >= 30:
|
|
break
|
|
result['list'].append({
|
|
'vod_id': str(film['id']),
|
|
'vod_name': film.get('name', ''),
|
|
'vod_pic': film.get('cover', ''),
|
|
'vod_remarks': film.get('doubanScore', '')
|
|
})
|
|
count += 1
|
|
except Exception as e:
|
|
print(f"[Symx] homeContent error: {e}")
|
|
|
|
return result
|
|
|
|
def categoryContent(self, cid, page, filter, ext):
|
|
import requests
|
|
result = {
|
|
'list': [],
|
|
'parse': 0,
|
|
'jx': 0
|
|
}
|
|
|
|
try:
|
|
url = f"{self.host}/api/film/category/list?area=&categoryId={cid}&language=&pageNum={page}&pageSize=15&sort=updateTime&year="
|
|
resp = requests.get(url, headers=self.get_headers(), timeout=10).json()
|
|
|
|
total = 0
|
|
if 'data' in resp:
|
|
data = resp['data']
|
|
total = data.get('total', 0)
|
|
if 'list' in data:
|
|
for item in data['list']:
|
|
result['list'].append({
|
|
'vod_id': str(item['id']),
|
|
'vod_name': item.get('name', ''),
|
|
'vod_pic': item.get('cover', ''),
|
|
'vod_remarks': item.get('updateStatus', '')
|
|
})
|
|
|
|
page_count = (total + 14) // 15
|
|
result['page'] = page
|
|
result['pagecount'] = page_count
|
|
result['limit'] = 15
|
|
result['total'] = total
|
|
except Exception as e:
|
|
print(f"[Symx] categoryContent error: {e}")
|
|
|
|
return result
|
|
|
|
def detailContent(self, did):
|
|
import requests
|
|
result = {
|
|
'list': [],
|
|
'parse': 0,
|
|
'jx': 0
|
|
}
|
|
|
|
if not did:
|
|
return result
|
|
|
|
vod_id = did[0]
|
|
|
|
try:
|
|
url = self.host + "/api/film/detail?id=" + urllib.parse.quote(vod_id)
|
|
resp = requests.get(url, headers=self.get_signed_headers("/film/detail"), timeout=10).json()
|
|
|
|
if 'data' not in resp:
|
|
return result
|
|
|
|
info = resp['data']
|
|
vod_name = info.get('name', '')
|
|
|
|
vod = {
|
|
'vod_id': vod_id,
|
|
'vod_name': vod_name,
|
|
'vod_pic': info.get('cover', ''),
|
|
'vod_year': info.get('year', ''),
|
|
'vod_area': info.get('other', ''),
|
|
'vod_actor': info.get('actor', ''),
|
|
'vod_director': info.get('director', ''),
|
|
'vod_content': info.get('blurb', ''),
|
|
'vod_remarks': f"豆瓣: {info.get('doubanScore', '')}" if info.get('doubanScore') else '',
|
|
'vod_play_from': '',
|
|
'vod_play_url': ''
|
|
}
|
|
|
|
# 解析播放列表
|
|
play_froms = []
|
|
play_urls = []
|
|
|
|
if 'playLineList' in info:
|
|
for line in info['playLineList']:
|
|
player_name = line.get('playerName', '')
|
|
play_froms.append(player_name)
|
|
|
|
episodes = []
|
|
if 'lines' in line:
|
|
for ep in line['lines']:
|
|
ep_name = ep.get('name', '')
|
|
ep_id = str(ep['id'])
|
|
episodes.append(f"{ep_name}${ep_id}")
|
|
play_urls.append('#'.join(episodes))
|
|
|
|
vod['vod_play_from'] = '$$$'.join(play_froms)
|
|
vod['vod_play_url'] = '$$$'.join(play_urls)
|
|
|
|
result['list'].append(vod)
|
|
except Exception as e:
|
|
print(f"[Symx] detailContent error: {e}")
|
|
|
|
return result
|
|
|
|
def playerContent(self, flag, pid, vipFlags):
|
|
import requests
|
|
result = {
|
|
'parse': 0,
|
|
'jx': 0,
|
|
'url': '',
|
|
'header': self.get_headers()
|
|
}
|
|
|
|
try:
|
|
url = self.host + "/api/line/play/parse?lineId=" + urllib.parse.quote(pid)
|
|
resp = requests.get(url, headers=self.get_signed_headers("/line/play/parse"), timeout=10).json()
|
|
|
|
play_url = resp.get('data', '')
|
|
if play_url:
|
|
result['url'] = play_url
|
|
else:
|
|
result['parse'] = 1
|
|
result['jx'] = 1
|
|
except Exception as e:
|
|
print(f"[Symx] playerContent error: {e}")
|
|
result['parse'] = 1
|
|
result['jx'] = 1
|
|
|
|
return result
|
|
|
|
def searchContent(self, key, quick, page='1'):
|
|
import requests
|
|
result = {
|
|
'list': [],
|
|
'parse': 0,
|
|
'jx': 0
|
|
}
|
|
|
|
try:
|
|
url = f"{self.host}/api/film/search?keyword={urllib.parse.quote(key)}&pageNum={page}&pageSize=10"
|
|
resp = requests.get(url, headers=self.get_signed_headers("/film/search"), timeout=10).json()
|
|
|
|
if 'data' in resp:
|
|
data = resp['data']
|
|
if 'list' in data:
|
|
for item in data['list']:
|
|
result['list'].append({
|
|
'vod_id': str(item['id']),
|
|
'vod_name': item.get('name', ''),
|
|
'vod_pic': item.get('cover', ''),
|
|
'vod_remarks': item.get('updateStatus', ''),
|
|
'vod_year': item.get('year', '')
|
|
})
|
|
except Exception as e:
|
|
print(f"[Symx] searchContent error: {e}")
|
|
|
|
return result
|
|
|
|
def localProxy(self, params):
|
|
return None
|
|
|
|
|
|
if __name__ == '__main__':
|
|
spider = Spider()
|
|
spider.init('{}')
|
|
print(json.dumps(spider.homeContent(True), ensure_ascii=False, indent=2)) |