# -*- coding: utf-8 -*- # 本资源来源于互联网公开渠道,仅可用于个人学习爬虫技术。 # 严禁将其用于任何商业用途,下载后请于 24 小时内删除,搜索结果均来自源站,本人不承担任何责任。 import re,time,json,urllib3 from base.spider import Spider urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class Spider(Spider): def __init__(self): super().__init__() self.base_url = self.device_material = self.base_headers = None self.jx_prefix = [] # 初始化存放解析前缀列表 def init(self, extend=''): try: ext = json.loads(extend.rstrip()) doh, pkg, ver, md5 = ext['doh'], ext['pkg'], ext['ver'], ext['md5'] if not (doh and pkg and ver and bool(re.fullmatch(r'[0-9A-F]{32}', md5))): raise ValueError self.device_material = f"{pkg}|{ver}|{md5}" jx_prefix_ext = ext.get('jxPrefix', '') self.jx_prefix = [p.strip() for p in jx_prefix_ext.split(',') if p.strip()] self.base_headers = { 'User-Agent': f"Dalvik/2.1.0 (Linux; U; Android 17; Pixel 10 Build/TQ3A.260701.001); cycdm-android/{ver}", 'Accept': "application/json,application/protobuf", 'Accept-Encoding': "gzip", 'accept-charset': "UTF-8" } # 通过 DoH 获取最新域名,并进行多域名 Ping 优选 # 1. 发起 DoH 请求获取动态域名 data = self.fetch('https://doh.pub/dns-query', params={'name':doh,'type':"txt"}, headers={'User-Agent':'okhttp/5.1.0','Accept':'application/dns-json'}, verify=False, timeout=5).json() domains = [] if 'Answer' in data: for answer in data['Answer']: # 16 代表 TXT 记录 if answer.get('type') == 16: # 提取 data 数据并去除首尾的转义引号 txt_data = answer.get('data', '').strip('"').strip("'") # 兼容多域名存在的情况 (处理可能的逗号或空格分隔) for d in txt_data.replace(',', ' ').split(): if d.startswith('http'): domains.append(d) # 去除重复域名,保持列表唯一性 domains = list(set(domains)) # 2. 检查是否存在多域名,并进行 Ping 优选 if domains: for domain in domains: # 如果该域名 Ping 测试通过,则将其设为基准 URL 并跳出循环 if self._ping_domain(domain): self.base_url = domain # print(f"初始化成功,当前优选域名: {self.base_url}") break except Exception: self.base_url = '' # print(f"Init 获取或校验域名失败") def _ping_domain(self, domain): # 域名连通性测试方法 (带有动态签名) try: # Ping 根域名,由于无需特定 path,传入空字符串即可 path = "" clean_params = {} # 调用原类自带的签名方法,生成时间戳参数与 Auth Header final_params, auth_header = self.get_signature_headers(path, clean_params) headers = {**self.base_headers, 'x-cyc-auth': auth_header } # 设置 3 秒短超时,用于快速筛除失效节点 res = self.fetch(domain, params=final_params, headers=headers, verify=False, timeout=3) # 如果请求响应正常,说明域名存活且签名算法匹配 if res.status_code == 200: return True except Exception: pass return False def homeContent(self, filter): if not self.base_url: return None data_dict = self.request_api("/index/nav") classes, filters = [], {} categories = self._ensure_list(data_dict.get(3, [])) for cat in categories: if not isinstance(cat, dict): continue type_id = str(cat.get(1, '')) raw_type_name = cat.get(2, '') if isinstance(raw_type_name, dict) or not raw_type_name: continue type_name = str(raw_type_name) if not type_id: continue classes.append({'type_id': type_id, 'type_name': type_name}) # 解析筛选条件 filter_configs = [] raw_filters = cat.get(3, {}) if isinstance(raw_filters, dict): field_map = {1: ('class', '类型'), 2: ('area', '地区'), 3: ('lang', '语言'), 4: ('year', '年份')} for f_num, (f_key, f_name) in field_map.items(): val_str = raw_filters.get(f_num, '') if not val_str or not isinstance(val_str, str): continue values_list = [{"n": "全部", "v": ""}] for item in val_str.split(','): if item.strip(): values_list.append({"n": item.strip(), "v": item.strip()}) filter_configs.append({"key": f_key, "name": f_name, "init": "", "value": values_list}) # 固定排序筛选 filter_configs.append({ "key": "order", "name": "排序", "init": "time", "value": [{"n": "最新", "v": "time"}, {"n": "热度最高", "v": "hits"}, {"n": "好评", "v": "score"}] }) filters[type_id] = filter_configs return {'class': classes, 'filters': filters} def homeVideoContent(self): if not self.base_url: return None data_dict = self.request_api("/index/video") videos = [] categories = self._ensure_list(data_dict.get(3, [])) for cat in categories: if not isinstance(cat, dict): continue cat_videos = self._ensure_list(cat.get(5, [])) for v in cat_videos: if not isinstance(v, dict): continue videos.append({ 'vod_id': str(v.get(1, '')), 'vod_name': str(v.get(2, '')), 'vod_pic': str(v.get(3, '')), 'vod_remarks': str(v.get(5, '')), 'vod_year': str(v.get(7, '')), 'type_name': str(v.get(9, '')) }) return {'list': videos} def categoryContent(self, tid, pg, filter, extend): if not self.base_url: return None params = {'tid': str(tid), 'page': str(pg), 'limit': "20", 'order': "time"} if extend: params.update({k: str(v) for k, v in extend.items()}) data_dict = self.request_api("/v2/video/query", params=params) videos, raw_list, total = [], [], 0 # 兼容 JSON 与 PB 数据体 data_body = data_dict.get('data') or data_dict.get(3) or {} if isinstance(data_body, dict): total = data_body.get('total') or data_body.get(1) or 0 raw_list = data_body.get('list') or data_body.get(2) or [] elif isinstance(data_body, list): for item in data_body: if isinstance(item, dict): raw_list.extend(self._ensure_list(item.get('list') or item.get(5) or [])) for v in self._ensure_list(raw_list): if not isinstance(v, dict): continue vod_id = v.get('vod_id') or v.get(1) or '' vod_name = v.get('name') or v.get(2) or '' if vod_id and vod_name: videos.append({ 'vod_id': str(vod_id), 'vod_name': str(vod_name), 'vod_pic': str(v.get('pic') or v.get(3) or ''), 'vod_remarks': str(v.get('remarks') or v.get(8) or v.get(5) or '') }) try: total_int = int(total) except Exception: total_int = 0 page_count = (total_int + 19) // 20 if total_int > 0 else 1 return {'list': videos, 'page': pg, 'pagecount': page_count, 'limit': 20, 'total': total_int} def searchContent(self, key, quick, pg='1'): if not self.base_url: return None params = {'text': str(key), 'pg': str(pg), 'type_id': "0", 'limit': "20"} data_dict = self.request_api("/v2/video/search", params=params) videos = [] data_body = data_dict.get('data') or data_dict.get(4) or data_dict.get(3) or [] for v in self._ensure_list(data_body): if not isinstance(v, dict): continue vod_id = v.get('vod_id') or v.get(1) or '' vod_name = v.get('name') or v.get(2) or '' if vod_id and vod_name: videos.append({ 'vod_id': str(vod_id), 'vod_name': str(vod_name), 'vod_pic': str(v.get('pic') or v.get(3) or v.get(4) or ''), 'vod_remarks': str(v.get('remarks') or v.get(8) or v.get(5) or '') }) total = data_dict.get('total') or data_dict.get(3) or data_dict.get(1) or 0 try: total_int = int(total) except Exception: total_int = 0 page_count = (total_int + 19) // 20 if total_int > 0 else 1 return {'list': videos, 'page': pg, 'pagecount': page_count} def detailContent(self, ids): if not self.base_url: return None vod_id = ids[0] data_dict = self.request_api(f"/v2/video/info/{vod_id}") data = data_dict.get('data') or data_dict.get(3) or {} shows, play_urls = [], [] play_from_list = self._ensure_list(data.get('vod_play_from') or data.get(24) or data.get(20) or []) for pf in play_from_list: if not isinstance(pf, dict): continue source_code = pf.get('code') or pf.get(1) source_name = pf.get('name') or pf.get(2) or source_code if not source_code: continue # 【修改处】在详情页直接拉取所有的剧集内容,固定 index='-1' params = {'id': str(vod_id), 'from': str(source_code), 'index': '-1'} play_data_dict = self.request_api("/video/play_url", params=params) # 取出列表数据,对应 JSON 里的 "3" raw_play_list = play_data_dict.get('data') or play_data_dict.get(3) or [] eps = [] for ep in self._ensure_list(raw_play_list): if not isinstance(ep, dict): continue ep_name = ep.get('name') or ep.get(1) or '未知' ep_url = ep.get('url') or ep.get('play_url') or ep.get(2) or '' # 获取新增加的值 3,表示是否 json 解析 ep_parse = ep.get('parse') or ep.get(3) or 0 if ep_url: # 用 @@ 分隔传递 URL 和 Parse 标识到 playerContent eps.append(f"{ep_name}${ep_url}@@{ep_parse}") if eps: shows.append(str(source_name)) play_urls.append('#'.join(eps)) video = { 'vod_id': str(vod_id), 'vod_name': str(data.get('vod_name') or data.get(3) or data.get(2) or ''), 'vod_pic': str(data.get('vod_pic') or data.get(7) or data.get(4) or ''), 'vod_remarks': str(data.get('vod_remarks') or data.get(12) or data.get(5) or ''), 'vod_year': str(data.get('vod_year') or data.get(14) or data.get(15) or ''), 'vod_area': str(data.get('vod_area') or data.get(13) or data.get(14) or ''), 'vod_actor': str(data.get('vod_actor') or data.get(8) or ''), 'vod_director': str(data.get('vod_director') or data.get(9) or ''), 'vod_content': str(data.get('vod_content') or data.get(23) or data.get(15) or ''), 'type_name': str(data.get('vod_class') or data.get(6) or ''), 'vod_play_from': '$$$'.join(shows), 'vod_play_url': '$$$'.join(play_urls) } return {'list': [video]} def playerContent(self, flag, vid, vip_flags): # 【修改处】分离从详情页传递过来的 url 与 parse_flag parts = vid.split('@@') url = parts[0] parse_flag = parts[1] if len(parts) > 1 else '0' # 匹配配置里的多规则解析前缀 matched_prefix = None for prefix in self.jx_prefix: if url.startswith(prefix): matched_prefix = prefix break # 如果获取标识值为 1,或者匹配到了 jx_prefix 前缀,执行 json 解析 if str(parse_flag) == '1' or matched_prefix: try: res = self.fetch(url, verify=False, headers={'User-Agent': 'okhttp/4.12.0'}) json_res = res.json() # 尝试获取 json 里的 url 键值,失败则抹除前缀进行直连兜底 url = json_res.get('url') or (url.replace(matched_prefix, '', 1) if matched_prefix else url) except Exception: # 解析失败时的兜底逻辑 url = url.replace(matched_prefix, '', 1) if matched_prefix else url return {'parse': 0, 'url': url, 'header': {'User-Agent': 'libmpv'}} def _ensure_list(self, obj): """确保对象返回列表格式""" if not obj: return [] return obj if isinstance(obj, list) else [obj] # 纯 Python 实现 XXHash64 def xxh64_hexdigest(self, data, seed=0): MASK64 = 0xFFFFFFFFFFFFFFFF PRIME64_1 = 0x9E3779B185EBCA87 PRIME64_2 = 0xC2B2AE3D27D4EB4F PRIME64_3 = 0x165667B19E3779F9 PRIME64_4 = 0x85EBCA77C2B2AE63 PRIME64_5 = 0x27D4EB2F165667C5 def rotl64(x, r): return ((x << r) | (x >> (64 - r))) & MASK64 def round_calc(acc, input_val): acc = (acc + (input_val * PRIME64_2) & MASK64) & MASK64 acc = rotl64(acc, 31) return (acc * PRIME64_1) & MASK64 def merge_round(acc, val): val = round_calc(0, val) acc = (acc ^ val) & MASK64 return (acc * PRIME64_1 + PRIME64_4) & MASK64 b = data if isinstance(data, bytes) else data.encode('utf-8') l = len(b) if l >= 32: v1 = (seed + PRIME64_1 + PRIME64_2) & MASK64 v2 = (seed + PRIME64_2) & MASK64 v3 = seed & MASK64 v4 = (seed - PRIME64_1) & MASK64 p = 0 limit = l - 32 while p <= limit: v1 = round_calc(v1, int.from_bytes(b[p:p + 8], 'little')) p += 8 v2 = round_calc(v2, int.from_bytes(b[p:p + 8], 'little')) p += 8 v3 = round_calc(v3, int.from_bytes(b[p:p + 8], 'little')) p += 8 v4 = round_calc(v4, int.from_bytes(b[p:p + 8], 'little')) p += 8 h64 = (rotl64(v1, 1) + rotl64(v2, 7) + rotl64(v3, 12) + rotl64(v4, 18)) & MASK64 h64 = merge_round(h64, v1) h64 = merge_round(h64, v2) h64 = merge_round(h64, v3) h64 = merge_round(h64, v4) else: h64 = (seed + PRIME64_5) & MASK64 h64 = (h64 + l) & MASK64 p = l - (l % 32) if l >= 32 else 0 while p + 8 <= l: k1 = round_calc(0, int.from_bytes(b[p:p + 8], 'little')) h64 = (h64 ^ k1) & MASK64 h64 = rotl64(h64, 27) h64 = (h64 * PRIME64_1 + PRIME64_4) & MASK64 p += 8 if p + 4 <= l: h64 = (h64 ^ ((int.from_bytes(b[p:p + 4], 'little') * PRIME64_1) & MASK64)) & MASK64 h64 = rotl64(h64, 23) h64 = (h64 * PRIME64_2 + PRIME64_3) & MASK64 p += 4 while p < l: h64 = (h64 ^ ((b[p] * PRIME64_5) & MASK64)) & MASK64 h64 = rotl64(h64, 11) h64 = (h64 * PRIME64_1) & MASK64 p += 1 h64 = (h64 ^ (h64 >> 33)) & MASK64 h64 = (h64 * PRIME64_2) & MASK64 h64 = (h64 ^ (h64 >> 29)) & MASK64 h64 = (h64 * PRIME64_3) & MASK64 h64 = (h64 ^ (h64 >> 32)) & MASK64 return f"{h64:016x}" def parse_pb_varint(self, stream, pos): result, shift = 0, 0 while pos < len(stream): byte = stream[pos] pos += 1 result |= (byte & 0x7F) << shift shift += 7 if not (byte & 0x80): break return result, pos def is_valid_pb_message(self, b): p = 0 while p < len(b): try: tag_wire, p = self.parse_pb_varint(b, p) if p > len(b) or tag_wire == 0: return False wt = tag_wire & 0x07 fn = tag_wire >> 3 if fn == 0: return False if wt == 0: _, p = self.parse_pb_varint(b, p) elif wt == 1: p += 8 elif wt == 2: length, p = self.parse_pb_varint(b, p) p += length elif wt == 5: p += 4 else: return False except Exception: return False return p == len(b) and len(b) > 0 def decode_length_delimited(self, data): if not data: return "" # 1. 优先尝试解析为 UTF-8 字符串 try: decoded_str = data.decode('utf-8') # 增加一个简单的启发式判断:如果字符串中包含大量不可打印字符,则认为它不是普通文本 # 如果是合法的文本,则直接返回,避免被误判为嵌套消息 if all(c.isprintable() or c.isspace() for c in decoded_str) and len(decoded_str) > 0: return decoded_str except UnicodeDecodeError: pass # 2. 如果不是有效的字符串,再检查是否为合法的 PB 嵌套结构 if self.is_valid_pb_message(data): try: parsed = self.parse_pb_message(data) if isinstance(parsed, dict) and len(parsed) > 0: return parsed except Exception: pass # 3. 实在不行则返回 Hex return data.hex() def parse_pb_message(self, stream): pos, msg = 0, {} while pos < len(stream): try: tag_wire, pos = self.parse_pb_varint(stream, pos) if pos > len(stream) or tag_wire == 0: break wire_type = tag_wire & 0x07 field_num = tag_wire >> 3 val = None if wire_type == 0: # Varint val, pos = self.parse_pb_varint(stream, pos) elif wire_type == 1: # 64-bit val, pos = stream[pos:pos + 8], pos + 8 elif wire_type == 2: # Length-delimited (关键点) length, pos = self.parse_pb_varint(stream, pos) # 此处调用改进后的 decode_length_delimited val = self.decode_length_delimited(stream[pos:pos + length]) pos += length elif wire_type == 5: # 32-bit val, pos = stream[pos:pos + 4], pos + 4 else: break if field_num not in msg: msg[field_num] = [] msg[field_num].append(val) except Exception: break # 展开单元素列表 for k in msg: if len(msg[k]) == 1: msg[k] = msg[k][0] return msg def get_signature_headers(self, path, business_params): timestamp = int(time.time() * 1000) full_params = dict(business_params) full_params['timestamp'] = str(timestamp) hash1 = self.xxh64_hexdigest(self.device_material, seed=0) sign_params = {str(k): [str(v)] for k, v in full_params.items()} data = { "method": "GET", "timestamp": timestamp, "path": path, "parameters": sign_params, "body": "" } json_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False) hash2 = self.xxh64_hexdigest(json_str, seed=timestamp) final_input = f"{timestamp}|{hash2}|{hash1}" final_seed = timestamp ^ 0x9E3779B185EBCA87 final_hash = self.xxh64_hexdigest(final_input, seed=final_seed) return full_params, f"v3:{timestamp}:{final_hash}" def request_api(self, path, params=None): if params is None: params = {} clean_params = {k: v for k, v in params.items() if v != ""} final_params, auth_header = self.get_signature_headers(path, clean_params) headers = { **self.base_headers, 'x-cyc-auth': auth_header } response = self.fetch(f"{self.base_url}{path}", params=final_params, verify=False, headers=headers) if 'application/protobuf' in response.headers.get('Content-Type', '').lower(): return self.parse_pb_message(response.content) try: return response.json() except Exception: return {} def getName(self): pass def isVideoFormat(self, url): pass def manualVideoCheck(self): pass def destroy(self): pass def localProxy(self, param): pass