From 23a6e841118f56784de59dd823b0b0105f680909 Mon Sep 17 00:00:00 2001 From: XC-Pro <19840+xc-pro@noreply.example.org> Date: Fri, 14 Aug 2026 11:24:46 +0200 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E8=87=B3?= =?UTF-8?q?=E3=80=8Cpy=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- py/哔哩直播.py | 329 ++++++++++++++++++++++++++++++ py/大马猴.py | 542 +++++++++++++++++++++++++++++++++++++++++++++++++ py/电影人生.py | 516 ++++++++++++++++++++++++++++++++++++++++++++++ py/达达兔.py | 308 ++++++++++++++++++++++++++++ py/采集聚合.py | 302 +++++++++++++++++++++++++++ 5 files changed, 1997 insertions(+) create mode 100644 py/哔哩直播.py create mode 100644 py/大马猴.py create mode 100644 py/电影人生.py create mode 100644 py/达达兔.py create mode 100644 py/采集聚合.py diff --git a/py/哔哩直播.py b/py/哔哩直播.py new file mode 100644 index 0000000..bcb934f --- /dev/null +++ b/py/哔哩直播.py @@ -0,0 +1,329 @@ +# 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('直播中', '') + + href = names.find('a')['href'] + id = self.extract_middle_text(href, 'bilibili.com/', '?', 0) + # 兜底:如果链接没有 ? 参数,用正则直接提取房间号 + if not id: + m = re.search(r'bilibili\.com/(\d+)', href) + if m: + id = m.group(1) + + 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 = [] + 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 = '欢迎观看哔哩直播' + + try: + setup = data['data']['playurl_info']['playurl']['stream'] + except (KeyError, TypeError): + setup = [] + + line_count = 0 + for stream in setup: + for fmt in stream.get('format', []): + for codec in fmt.get('codec', []): + base_url = codec.get('base_url', '') + url_info_list = codec.get('url_info', []) + if not base_url or not url_info_list: + continue + + # 遍历所有CDN节点,取第一个可用的 + for uinfo in url_info_list: + host = uinfo.get('host', '') + extra = uinfo.get('extra', '') + if not host or not extra: + continue + + # 处理 host 和 base_url 之间可能出现的双斜杠 + if host.endswith('/') and base_url.startswith('/'): + base_url = base_url[1:] + play_url = host + base_url + extra + + line_count += 1 + namc = f"{line_count}号线路" + bofang += f"{namc}${play_url}#" + break # 每个 codec 只取第一个可用 CDN + + if bofang: + 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 + # B站直播流必须带 Referer,否则 403 + result["header"] = { + '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', + 'Referer': 'https://live.bilibili.com/', + 'Origin': 'https://live.bilibili.com' + } + 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('直播中', '') + + href = names.find('a')['href'] + id = self.extract_middle_text(href, 'bilibili.com/', '?', 0) + # 兜底:如果链接没有 ? 参数,用正则直接提取房间号 + if not id: + m = re.search(r'bilibili\.com/(\d+)', href) + if m: + id = m.group(1) + + 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 diff --git a/py/大马猴.py b/py/大马猴.py new file mode 100644 index 0000000..565f746 --- /dev/null +++ b/py/大马猴.py @@ -0,0 +1,542 @@ +# -*- coding: utf-8 -*- +# 大马猴影视 - 整合 bubutv 线路解析逻辑,实现直接播放(无需外置解析) + +import sys +sys.path.append('..') + +import json +import re +import time +import random +import hashlib +from urllib.parse import urlencode, quote +from html.parser import HTMLParser +from base.spider import Spider + + +class _HTMLTextExtractor(HTMLParser): + def __init__(self): + super().__init__() + self._text = [] + + def handle_data(self, data): + self._text.append(data) + + def get_text(self): + return ''.join(self._text) + + +class Spider(Spider): + def __init__(self): + self.host = 'https://dmhyy.com' + self.classes = [ + {'type_id': '23', 'type_name': '电影'}, + {'type_id': '22', 'type_name': '剧集'}, + {'type_id': '24', 'type_name': '动漫'}, + {'type_id': '25', 'type_name': '综艺'}, + ] + self.web_sign = '' + self.x_client = 'YOUR_PUBLIC_CLIENT_ID' # 请替换为真实值 + self._app_device_id = '' + + def init(self, extend=''): + try: + if extend: + if isinstance(extend, dict): + ext = extend + else: + text = str(extend).strip() + ext = json.loads(text) if text.startswith('{') else {'site': text} + site = ext.get('site') or ext.get('host') or '' + if site: + self.host = str(site).split(',')[0].strip().rstrip('/') + self.web_sign = ext.get('web-sign') or ext.get('web_sign') or self.web_sign + self.x_client = ext.get('x-client') or ext.get('x_client') or self.x_client + except Exception: + pass + return None + + def _ensure_ready(self): + if not getattr(self, 'host', ''): + self.host = 'https://dmhyy.com' + self.host = self.host.rstrip('/') + + def getName(self): + return '大马猴影视' + + def destroy(self): + pass + + def isVideoFormat(self, url): + return bool(re.search(r'\.(m3u8|mp4|flv|mkv|avi)(\?|#|$|\s)', str(url or ''), re.I)) + + def manualVideoCheck(self): + return False + + def localProxy(self, param): + return None + + def _headers(self, referer=''): + self._ensure_ready() + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Referer': referer or (self.host + '/'), + 'x-client': self.x_client, + 'x-platform': 'web', + 'x-requested-with': 'XMLHttpRequest', + } + if getattr(self, 'web_sign', ''): + headers['web-sign'] = self.web_sign + return headers + + def _app_headers(self): + """生成 app 接口需要的签名头(与 bubutv 一致)""" + timestamp = str(int(time.time())) + nonce = ''.join([str(random.randint(0, 9)) for _ in range(3)]) + pkg = 'com.sunshine.tv' + ver = '4' + finger = 'SF-C3B2B41F6EFFFF9869176CF68F6790E8F07506FC88632C94B4F5F0430D5498CA' + sign_str = f"finger={finger}&id={pkg}&nonce={nonce}&sk=SK-thanks&time={timestamp}&v={ver}" + sign = hashlib.sha256(sign_str.encode()).hexdigest().upper() + + if not self._app_device_id or len(self._app_device_id) != 16: + self._app_device_id = ''.join([random.choice('0123456789abcdef') for _ in range(16)]) + + return { + 'User-Agent': 'okhttp/4.12.0', + 'Accept': 'application/json', + 'x-aid': pkg, + 'x-ave': ver, + 'x-time': timestamp, + 'x-nonc': nonce, + 'x-sign': sign, + 'x-device-id': self._app_device_id, + 'x-device-brand': 'vivo', + 'x-device-model': 'V2309A', + 'x-update-id': '0245861b-2ebf-5524-389d-f983830651ec' + } + + def _api_get(self, path, params=None, referer=''): + self._ensure_ready() + params = params or {} + qs = urlencode(params, doseq=True) + url = self.host + path + (('?' + qs) if qs else '') + try: + r = self.fetch(url, headers=self._headers(referer), timeout=12) + text = getattr(r, 'text', '') or getattr(r, 'content', b'') + if isinstance(text, bytes): + text = text.decode('utf-8', errors='ignore') + if not text: + return {} + return json.loads(text) + except Exception as e: + print('大马猴接口请求失败:', path, params, e) + return {} + + def _clean_text(self, s): + s = str(s or '') + s = re.sub(r'<[^>]+>', ' ', s) + s = s.replace(' ', ' ') + return re.sub(r'\s+', ' ', s).strip() + + def _html2text(self, html): + try: + p = _HTMLTextExtractor() + p.feed(str(html or '')) + return self._clean_text(p.get_text()) + except Exception: + return self._clean_text(html) + + def _as_list(self, data): + if isinstance(data, list): + return data + if isinstance(data, dict): + for k in ('data', 'list', 'items', 'records', 'rows', 'vod_list'): + v = data.get(k) + if isinstance(v, list): + return v + if isinstance(v, dict): + vv = self._as_list(v) + if vv: + return vv + return [] + + def _vod_item(self, item): + if not isinstance(item, dict): + return None + vid = item.get('vod_id') or item.get('id') or item.get('vodId') + name = item.get('vod_name') or item.get('name') or item.get('title') + if not vid or not name: + return None + area = item.get('vod_area', '') + cls = item.get('vod_class', '') + if isinstance(area, list): + area = ','.join([str(x) for x in area if x]) + if isinstance(cls, list): + cls = ','.join([str(x) for x in cls if x]) + return { + 'vod_id': str(vid), + 'vod_name': self._clean_text(name), + 'vod_pic': str(item.get('vod_pic') or item.get('pic') or item.get('cover') or ''), + 'vod_remarks': str(item.get('vod_remarks') or item.get('remarks') or item.get('vod_douban_score') or item.get('vod_year') or ''), + 'vod_year': str(item.get('vod_year') or ''), + 'type_name': self._clean_text(item.get('type_name') or cls or ''), + 'vod_area': self._clean_text(area), + } + + def _vod_list(self, data): + arr = self._as_list(data) + out = [] + seen = set() + for item in arr: + v = self._vod_item(item) + if not v: + continue + if v['vod_id'] in seen: + continue + seen.add(v['vod_id']) + out.append(v) + return out + + def _category_match(self, item, real_tid): + if not isinstance(item, dict): + return False + real_tid = str(real_tid) + item_tid = str(item.get('type_id') or item.get('typeId') or item.get('tid') or '') + if item_tid == real_tid: + return True + name = str(item.get('type_name') or '') + class_value = item.get('vod_class') or [] + if isinstance(class_value, list): + cls = ','.join([str(x) for x in class_value if x]) + else: + cls = str(class_value or '') + text = name + ',' + cls + if real_tid == '23': + return ('电影' in text or '动作片' in text or '剧情片' in text or '喜剧片' in text) and '电视剧' not in text + if real_tid == '22': + return any(k in text for k in ('剧集', '电视剧', '国产剧', '连续剧', '韩剧', '陆剧', '欧美剧', '日剧')) + if real_tid == '24': + return '动漫' in text or '动画' in text or '国产动漫' in text or '日韩动漫' in text + if real_tid == '25': + return '综艺' in text or '真人秀' in text + return True + + def _filter_items_by_category(self, data, real_tid): + arr = self._as_list(data) + return [x for x in arr if self._category_match(x, real_tid)] + + def homeContent(self, filter): + return {'class': self.classes} + + def homeVideoContent(self): + j = self._api_get('/api.php/web/filter/vod', { + 'type_id': '23', + 'page': '1', + 'sort': 'hits' + }, self.host + '/type/23') + return {'list': self._vod_list(j)} + + def categoryContent(self, tid, pg, filter, extend): + self._ensure_ready() + page = str(pg or '1') + sort = 'hits' + if isinstance(extend, dict): + sort = extend.get('sort') or extend.get('by') or sort + + tid_map = {'1': '23', '2': '22', '3': '24', '4': '25'} + real_tid = tid_map.get(str(tid), str(tid)) + j = self._api_get('/api.php/web/filter/vod', { + 'type_id': real_tid, + 'page': page, + 'sort': sort + }, self.host + '/type/' + real_tid) + + filtered_items = self._filter_items_by_category(j, real_tid) + + try: + cur_page = int(page) + except Exception: + cur_page = 1 + if len(filtered_items) < 8: + seen_ids = set(str(x.get('vod_id') or x.get('id') or '') for x in filtered_items if isinstance(x, dict)) + for extra_page in range(cur_page + 1, cur_page + 3): + jj = self._api_get('/api.php/web/filter/vod', { + 'type_id': real_tid, + 'page': str(extra_page), + 'sort': sort + }, self.host + '/type/' + real_tid) + for item in self._filter_items_by_category(jj, real_tid): + vid = str(item.get('vod_id') or item.get('id') or '') + if vid and vid not in seen_ids: + seen_ids.add(vid) + filtered_items.append(item) + if len(filtered_items) >= 24: + break + + videos = self._vod_list(filtered_items) + pagecount = 1 + total = len(videos) + limit = 24 + if isinstance(j, dict): + pagecount = int(j.get('pageCount') or j.get('pagecount') or (cur_page + 1 if videos else cur_page)) + total = int(j.get('total') or total) + limit = int(j.get('limit') or limit) + + return { + 'list': videos, + 'page': cur_page, + 'pagecount': pagecount, + 'limit': limit, + 'total': total + } + + def searchContent(self, key, quick, pg='1'): + self._ensure_ready() + wd = str(key or '').strip() + page = str(pg or '1') + if not wd: + return {'list': [], 'page': int(page)} + + paths = [ + ('/api.php/web/search/vod', {'wd': wd, 'page': page}), + ('/api.php/web/vod/search', {'wd': wd, 'page': page}), + ('/api.php/web/search', {'wd': wd, 'page': page}), + ('/api.php/web/filter/vod', {'keyword': wd, 'page': page, 'sort': 'hits'}), + ] + for path, params in paths: + j = self._api_get(path, params, self.host + '/search?keyword=' + quote(wd)) + videos = self._vod_list(j) + if videos: + return {'list': videos, 'page': int(page)} + return {'list': [], 'page': int(page)} + + def _first_detail(self, ids): + vid = str(ids[0] if isinstance(ids, list) else ids) + j = self._api_get('/api.php/web/vod/get_detail', {'vod_id': vid}, self.host + '/play/' + vid) + arr = self._as_list(j) + return (arr[0] if arr else {}), j + + def _aggregate_sources(self, vid): + paths = [ + '/api.php/web/internal/search_aggregate', + '/api.php/web/search_aggregate', + ] + for path in paths: + j = self._api_get(path, {'vod_id': str(vid)}, self.host + '/play/' + str(vid)) + arr = self._as_list(j) + if arr: + return arr + return [] + + # ---------- 线路构建(与 JS 版一致) ---------- + def detailContent(self, ids): + self._ensure_ready() + if not ids: + return {'list': []} + vid = str(ids[0]) + detail, raw = self._first_detail([vid]) + + if not detail: + agg = self._aggregate_sources(vid) + if agg: + detail = agg[0] + else: + return {'list': []} + + vodplayer = raw.get('vodplayer', []) if isinstance(raw, dict) else [] + + shows = [] + play_urls = [] + + # 1. 聚合接口的直链(优先,need_parse=0) + agg_sources = self._aggregate_sources(vid) + if agg_sources: + agg_sources.sort(key=lambda s: ( + 0 if re.search(r'\.(m3u8|mp4|flv)(\?|#|$|\s)', str(s.get('vod_play_url', '')), re.I) else 1, + s.get('site_name', '') + )) + for src in agg_sources[:4]: + play_url = str(src.get('vod_play_url', '')).strip() + if not play_url: + continue + need_parse = 0 # 聚合直链通常不需要解析 + site_name = src.get('site_name') or src.get('external_display_name') or '聚合线路' + encoded = f"1${site_name}@{need_parse}@{play_url}" + shows.append(site_name) + play_urls.append(encoded) + + # 2. 详情自带的线路 + pf = str(detail.get('vod_play_from', '') or '') + pu = str(detail.get('vod_play_url', '') or '') + if pf and pu: + froms = pf.split('$$$') + urls = pu.split('$$$') + for show_code, urls_str in zip(froms, urls): + need_parse = 1 + is_show = 0 + show_name = show_code + + for player in vodplayer: + if player.get('from') == show_code: + is_show = 1 + need_parse = int(player.get('decode_status', 1)) + if player.get('show', '').lower() != show_code.lower(): + show_name = f"{player['show']}\u2005({show_code})" + break + + if not is_show: + is_show = 1 + sample_url = urls_str.split('#')[0].split('$')[-1] if urls_str else '' + if sample_url.startswith('http') and self.isVideoFormat(sample_url): + need_parse = 0 + + if is_show: + episodes = [] + for url_item in urls_str.split('#'): + if '$' in url_item: + ep, raw_url = url_item.split('$', 1) + episodes.append(f"{ep}${show_code}@{need_parse}@{raw_url}") + if episodes: + play_urls.append('#'.join(episodes)) + shows.append(show_name) + + # 3. 如果没有线路,尝试用聚合接口的未处理项 + if not shows and agg_sources: + for src in agg_sources[:2]: + play_url = str(src.get('vod_play_url', '')).strip() + if play_url: + need_parse = 0 + name = src.get('site_name', '线路') + play_urls.append(f"1${name}@{need_parse}@{play_url}") + shows.append(name) + + area = detail.get('vod_area', '') + cls = detail.get('vod_class', '') + if isinstance(area, list): + area = ','.join([str(x) for x in area if x]) + if isinstance(cls, list): + cls = ','.join([str(x) for x in cls if x]) + + vod = { + 'vod_id': vid, + 'vod_name': self._clean_text(detail.get('vod_name') or ''), + 'vod_pic': str(detail.get('vod_pic') or ''), + 'vod_remarks': str(detail.get('vod_remarks') or ''), + 'type_name': self._clean_text(detail.get('type_name') or cls or ''), + 'vod_year': str(detail.get('vod_year') or ''), + 'vod_area': self._clean_text(area), + 'vod_actor': self._clean_text(detail.get('vod_actor') or ''), + 'vod_director': self._clean_text(detail.get('vod_director') or ''), + 'vod_content': self._html2text(detail.get('vod_content') or ''), + 'vod_play_from': '$$$'.join(shows), + 'vod_play_url': '$$$'.join(play_urls), + } + return {'list': [vod]} + + # ---------- 播放解析(使用 app 解码接口,实现直接播放) ---------- + def playerContent(self, flag, id, vipFlags): + self._ensure_ready() + url = str(id or '').strip() + if not url: + return {'parse': 0, 'url': ''} + + # 处理 @ 标记格式(集数$线路@need_parse@真实地址) + if '@' in url and not url.startswith('http'): + try: + parts = url.split('@', 2) + if len(parts) == 3: + play_from, need_parse, raw_url = parts + if need_parse == '0': + if self.isVideoFormat(raw_url) or raw_url.startswith('http'): + return { + 'parse': 0, 'jx': 0, + 'url': raw_url, + 'header': self._app_headers() + } + else: + return {'parse': 1, 'jx': 1, 'url': raw_url} + # need_parse == '1',尝试解码 + decoded = self._try_decode(raw_url, play_from) + if decoded: + return { + 'parse': 0, 'jx': 0, + 'url': decoded, + 'header': self._app_headers() + } + # 解码失败,若原地址可用则直接播,否则交壳解析 + if self.isVideoFormat(raw_url) or raw_url.startswith('http'): + return {'parse': 0, 'jx': 0, 'url': raw_url, + 'header': self._app_headers()} + else: + return {'parse': 1, 'jx': 1, 'url': raw_url} + except Exception: + pass + + # 旧格式直链直接播放 + if url.startswith('http') and self.isVideoFormat(url): + return { + 'parse': 0, 'jx': 0, + 'url': url, + 'header': self._app_headers() + } + + # 尝试旧版解码接口(web 方式) + decoded = self._try_decode(url, None) + if decoded: + return { + 'parse': 0, 'jx': 0, + 'url': decoded, + 'header': self._app_headers() + } + + # 腾讯/优酷等大站交给壳 + if re.search(r'(v\.qq\.com|youku\.com|iqiyi\.com|mgtv\.com|bilibili\.com)', url, re.I): + return {'parse': 1, 'jx': 1, 'url': url} + + return {'parse': 1, 'jx': 1, 'url': url} + + def _try_decode(self, raw_url, play_from=None): + """优先使用 app 解码接口,失败再尝试 web 接口""" + # ---- 1. app 解码接口(带签名)---- + if play_from: + try: + app_headers = self._app_headers() + app_url = f"{self.host}/api.php/app/decode/url/?url={quote(raw_url)}&vodFrom={play_from}" + r = self.fetch(app_url, headers=app_headers, timeout=10) + data = json.loads(r.text) if r.text else {} + link = data.get('data', '') + if isinstance(link, str) and link.startswith('http'): + return link + except Exception as e: + print(f'App解码失败: {e}') + + # ---- 2. web 解码接口(原有多参数尝试)---- + decode_tries = [ + {'url': raw_url}, + {'play_url': raw_url}, + {'vod_url': raw_url}, + ] + for params in decode_tries: + j = self._api_get('/api.php/web/decode/url', params, self.host + '/play') + link = self._extract_url(j) + if link: + return link + return None + + def _extract_url(self, resp_data): + """从解码响应中提取直链""" + if isinstance(resp_data, str): + return resp_data if resp_data.startswith('http') else None + if isinstance(resp_data, dict): + data = resp_data.get('data') + if isinstance(data, str): + return data if data.startswith('http') else None + if isinstance(data, dict): + url = data.get('url') or data.get('play_url') or data.get('playUrl') or '' + return url if url.startswith('http') else None + url = resp_data.get('url') or resp_data.get('play_url') or '' + return url if url.startswith('http') else None + return None \ No newline at end of file diff --git a/py/电影人生.py b/py/电影人生.py new file mode 100644 index 0000000..1adf890 --- /dev/null +++ b/py/电影人生.py @@ -0,0 +1,516 @@ +# -*- coding: utf-8 -*- +# by @PyramidStore AutoGen +import re +import sys +sys.path.append('..') +import json +import time +import random +import hashlib +import requests as _req +from base.spider import Spider +from urllib.parse import quote, urlencode, unquote + + +class Spider(Spider): + + LANDING_URL = 'https://dyrs.net' + HOSTS_API = 'https://dyrshd.net/api/videox/least' + + UA_LIST = [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0', + ] + + CATEGORIES = [ + ('dianying', '电影'), + ('dianshiju', '电视剧'), + ('zongyi', '综艺'), + ('dongman', '动漫'), + ('duanju', '短剧'), + ] + + CLASS_LIST = ['剧情', '喜剧', '动作', '爱情', '惊悚', '犯罪', '院线', '悬疑', '恐怖', '冒险', '奇幻', '科幻', '家庭', '战争', '古装', '历史', '传记', '武侠', '动画', '音乐'] + AREA_LIST = ['美国', '内地', '中国香港', '日本', '英国', '法国', '韩国', '加拿大', '德国', '中国台湾', '印度', '意大利', '其它地区', '西班牙', '澳大利亚', '泰国', '俄罗斯', '比利时', '丹麦', '墨西哥'] + YEAR_LIST = ['2026', '2025', '2024', '2023', '2022', '2021', '2020', '2019', '2018', '2017', '2016', '2015', '2014', '2013', '2012', '2011', '2010'] + + def init(self, extend=""): + self._host = getattr(self, '_host', None) or self._resolve_host() + self._vod_name = '' + self._last_fetch = 0 + self._sion_id = '' + self._session = _req.Session() + self._ua = random.choice(self.UA_LIST) + self._session.headers.update({ + 'User-Agent': self._ua, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + }) + self._session.verify = False + # Force fresh session to avoid stale sion_id causing 429 + self._ensure_session() + + def getName(self): + return '电影人生' + + def isVideoFormat(self, url): + return False + + def manualVideoCheck(self): + return True + + def destroy(self): + pass + + def _resolve_host(self): + try: + r = _req.get(self.HOSTS_API, headers={'User-Agent': random.choice(self.UA_LIST)}, timeout=10, verify=False) + data = r.json() + urls = data.get('urls', []) + for url in urls[:10]: + try: + host = url.rstrip('/') + r2 = _req.get(host + '/dianying.html?page=1', headers={'User-Agent': random.choice(self.UA_LIST)}, timeout=5, verify=False) + if r2.status_code == 200 and len(r2.text) > 100000 and re.search(r'data-url="[^"]+"', r2.text): + return host + except: + continue + except: + pass + return 'https://dyrs3.vip' + + def _switch_host(self): + try: + r = _req.get(self.HOSTS_API, headers={'User-Agent': random.choice(self.UA_LIST)}, timeout=10, verify=False) + data = r.json() + urls = data.get('urls', []) + for url in urls[:10]: + try: + host = url.rstrip('/') + if host == self._host: + continue + r2 = _req.get(host + '/', headers={'User-Agent': random.choice(self.UA_LIST)}, timeout=5, verify=False) + if r2.status_code == 200: + self._host = host + self._sion_id = '' + self._session.cookies.clear() + self._ensure_session() + return True + except: + continue + except: + pass + return False + + def _ensure_session(self): + if not self._sion_id: + try: + r = self._session.get(f'{self._host}/', timeout=10) + self._sion_id = self._session.cookies.get('sion_id', '') + except: + pass + + def _solve_pow(self, html): + hash_m = re.search(r"var\s+hash\s*=\s*'([^']+)'", html) + target_m = re.search(r"var\s+target\s*=\s*'([^']+)'", html) + if not hash_m or not target_m: + return None + h, target = hash_m.group(1), target_m.group(1) + for i in range(10000000): + if hashlib.sha1((h + str(i)).encode()).hexdigest() == target: + return i + return None + + def _is_pow_page(self, text): + return 'passChallenge' in text and 'var hash' in text and 'var target' in text + + def _try_pow(self, r, url): + if self._is_pow_page(r.text): + attack_key = self._solve_pow(r.text) + if attack_key is not None: + sep = '&' if '?' in url else '?' + pow_url = f'{url}{sep}attack_key={attack_key}' + time.sleep(1) + r2 = self._session.get(pow_url, timeout=15) + r2.encoding = 'utf-8' + if r2.status_code == 200 and len(r2.text) > 1000 and not self._is_pow_page(r2.text): + return r2.text + return None + + def _fetch(self, url, retries=3): + self._ensure_session() + if self._sion_id and 'sion_id=' not in url: + sep = '&' if '?' in url else '?' + url = f'{url}{sep}sion_id={self._sion_id}' + for attempt in range(retries): + try: + elapsed = time.time() - self._last_fetch + if elapsed < 2.0: + time.sleep(2.0 - elapsed) + r = self._session.get(url, timeout=15) + self._last_fetch = time.time() + r.encoding = 'utf-8' + if r.status_code == 200 and len(r.text) > 1000: + pow_result = self._try_pow(r, url) + if pow_result: + return pow_result + if not self._is_pow_page(r.text): + return r.text + if r.status_code in (429, 200) and self._is_pow_page(r.text): + pow_result = self._try_pow(r, url) + if pow_result: + return pow_result + if r.status_code == 429: + time.sleep(3 * (attempt + 1)) + self._sion_id = '' + self._session.cookies.clear() + self._session.headers['User-Agent'] = random.choice(self.UA_LIST) + self._ensure_session() + if self._sion_id: + url = re.sub(r'sion_id=[^&]*', f'sion_id={self._sion_id}', url) if 'sion_id=' in url else f'{url}&sion_id={self._sion_id}' + continue + except (_req.exceptions.ConnectionError, _req.exceptions.Timeout): + if attempt < retries - 1: + time.sleep(2) + self._switch_host() + if self._sion_id: + url = re.sub(r'sion_id=[^&]*', f'sion_id={self._sion_id}', url) if 'sion_id=' in url else f'{url}&sion_id={self._sion_id}' + continue + except: + pass + if attempt < retries - 1: + time.sleep(3) + return None + + def _parse_list(self, html): + videos = [] + if not html: + return videos + urls = re.findall(r'data-url="([^"]+)"', html) + titles = re.findall(r']*?title="([^"]*)"[^>]*?data-url=', html) + pics = re.findall(r'data-src="([^"]+)"', html) + for i, data_url in enumerate(urls): + title = titles[i] if i < len(titles) else '' + pic = pics[i] if i < len(pics) else '' + if pic and not pic.startswith('http'): + pic = f'{self._host}{pic}' + remark = '' + year = '' + idx = html.find(f'data-url="{data_url}"') + if idx > 0: + after = html[idx:idx+1500] + # rounded shadow-sm = remark (清晰度/集数) + rem_m = re.search(r'rounded shadow-sm[^>]*>\s*([^<]+)', after) + if rem_m: + remark = rem_m.group(1).strip() + # 年份 = year + year_m = re.search(r'\s*(\d{4})\s*', after) + if year_m: + year = year_m.group(1) + videos.append({ + 'vod_id': data_url if data_url.startswith('/') else f'/{data_url}', + 'vod_name': title, + 'vod_pic': pic, + 'vod_remarks': remark, + 'vod_year': year, + }) + return videos + + def _parse_episodes(self, html, detail_base): + sources = {} + from urllib.parse import unquote, quote + + # Find source selector links: href="...origin=xxx..." (no p= param, not api/m3u8, not download) + source_map = {} + for m in re.finditer(r'href="([^"]*origin=[^"]*)"', html): + href = m.group(1).replace('&', '&') + if 'p=' in href or 'api/m3u8' in href or 'download' in href or 'dianying.html' in href: + continue + origin_m = re.search(r'origin=([^&"]+)', href) + if origin_m: + origin_val = unquote(origin_m.group(1)) + if origin_val and origin_val not in source_map: + source_map[origin_val] = href + + # Find episode links: href="...origin=xxx&p=N..." + ep_links = re.findall(r'href="([^"]*\?origin=[^"&]*&(?:amp;)?p=\d+[^"]*)"', html) + ep_links = list(dict.fromkeys(ep_links)) + + # Group episodes by origin from current page + for link in ep_links: + link = link.replace('&', '&') + origin_m = re.search(r'origin=([^&]+)', link) + if origin_m: + origin = unquote(origin_m.group(1)) + if origin not in sources: + sources[origin] = [] + sources[origin].append(link) + + # For sources not in current page, visit their selector page + for origin_val in source_map: + if origin_val not in sources: + source_url = f'{self._host}{detail_base}?origin={quote(origin_val)}' + try: + src_html = self._fetch(source_url, retries=2) + if src_html: + src_eps = re.findall(r'href="([^"]*\?origin=[^"&]*&(?:amp;)?p=\d+[^"]*)"', src_html) + src_eps = list(dict.fromkeys(src_eps)) + if src_eps: + sources[origin_val] = [e.replace('&', '&') for e in src_eps] + except Exception: + pass + time.sleep(1) + + return sources + + def homeContent(self, filter): + result = {'class': [], 'list': []} + try: + html = self._fetch(f'{self._host}/') + if html: + result['class'] = [{'type_id': cid, 'type_name': cn} for cid, cn in self.CATEGORIES] + result['list'] = self._parse_list(html) + if filter: + result['filters'] = { + cid: [ + {'key': 'class', 'name': '类型', 'value': [{'n': '全部', 'v': ''}] + [{'n': c, 'v': c} for c in self.CLASS_LIST]}, + {'key': 'area', 'name': '地区', 'value': [{'n': '全部', 'v': ''}] + [{'n': a, 'v': a} for a in self.AREA_LIST]}, + {'key': 'year', 'name': '年份', 'value': [{'n': '全部', 'v': ''}] + [{'n': y, 'v': y} for y in self.YEAR_LIST]}, + ] + for cid, cn in self.CATEGORIES + } + except: + pass + return result + + def homeVideoContent(self): + try: + html = self._fetch(f'{self._host}/') + if html: + return {'list': self._parse_list(html)} + except: + pass + return {'list': []} + + def categoryContent(self, tid, pg, filter, extend): + result = {'list': [], 'page': int(pg), 'pagecount': 9999, 'limit': 90, 'total': 0} + try: + pg = int(pg) + params = {'page': pg} + if extend: + for key in ('area', 'class', 'year'): + if extend.get(key): + params[key] = extend[key] + url = f'{self._host}/{tid}.html?{urlencode(params)}' + html = self._fetch(url) + if html: + items = self._parse_list(html) + result['list'] = items + result['limit'] = max(len(items), 1) + result['total'] = len(items) + except: + pass + return result + + def detailContent(self, ids): + result = {'list': []} + try: + url = ids[0] + if not url.startswith('http'): + url = f'{self._host}{url}' + html = self._fetch(url) + if not html: + return result + + vod_name = '' + m = re.search(r'([^<]+)', html) + if m: + vod_name = m.group(1).split('-')[0].strip() + vod_name = re.sub(r'[\u300a\u300b\u3008\u3009\u300c\u300d\u300e\u300f\uff08\uff09\(\)\[\]\{\}]', '', vod_name) + vod_name = re.sub(r'(在线观看|在线播放|免费播放|免费观看|高清播放|高清在线|完整版|全集|电视剧|电影|免费|高清|播放|观看|全集免费|在线|影院)$', '', vod_name) + vod_name = re.sub(r'[-\s]+$', '', vod_name).strip() + + vod_pic = '' + m = re.search(r'imgurl\s*[=:]\s*[\'"]([^\'"]+)[\'"]', html) + if m: + vod_pic = m.group(1) + if not vod_pic.startswith('http'): + vod_pic = f'{self._host}{vod_pic}' + + vod_content = '' + m = re.search(r'<meta[^>]*name="description"[^>]*content="([^"]*)"', html) + if m: + vod_content = m.group(1).strip() + + vod_year = '' + m = re.search(r'year\s*[=:]\s*[\'"](\d{4})[\'"]', html) + if m: + vod_year = m.group(1) + + vod_actor = '' + m = re.search(r'"actor"\s*:\s*\[(.*?)\]', html) + if m: + actors = re.findall(r'"name"\s*:\s*"([^"]+)"', m.group(1)) + vod_actor = ','.join(actors[:10]) + + vod_director = '' + m = re.search(r'"director"\s*:\s*\{[^}]*"name"\s*:\s*"([^"]*)"', html) + if m: + vod_director = m.group(1) + + vod = { + 'vod_id': ids[0], + 'vod_name': vod_name, + 'vod_pic': vod_pic, + 'vod_content': vod_content, + 'vod_year': vod_year, + 'vod_actor': vod_actor, + 'vod_director': vod_director, + 'vod_play_from': '', + 'vod_play_url': '', + } + + play_sources = {} + + # Method 1: Parse episode links from detail page + detail_base = re.search(r'(\/[^?]+\.html)', ids[0]) + if detail_base: + episodes = self._parse_episodes(html, detail_base.group(1)) + if episodes: + play_sources = episodes + + # Method 2: Try xg_video_player_doc.aa (single episode) + if not play_sources: + aa_m = re.search(r'xg_video_player_doc\s*=\s*\{[^}]*aa:\s*JSON\.parse\([\'"](\{[^}]+\})[\'"]\)', html, re.DOTALL) + if aa_m: + raw = aa_m.group(1).replace('\\u0022', '"').replace('\\u0026', '&') + raw = re.sub(r'\\(.)', r'\1', raw) + try: + aa = json.loads(raw) + origin = aa.get('origin', '默认') + play_url = aa.get('url', '') + if play_url: + if not play_url.startswith('http'): + play_url = f'{self._host}{play_url}' + play_sources[origin] = [play_url] + except: + pass + + # Method 3: Try videoid fallback + if not play_sources: + m = re.search(r'videoid\s*[=:]\s*[\'"]([a-f0-9]+)[\'"]', html) + if m: + vid = m.group(1) + play_sources['超级线路'] = [f'{self._host}/api/m3u8?origin=%E8%B6%85%E7%BA%A7%E7%BA%BF%E8%B7%AF&url={vid}'] + + if play_sources: + play_from = [] + play_urls = [] + for src_name, src_urls in play_sources.items(): + play_from.append(src_name) + eps = [] + for i, u in enumerate(src_urls, 1): + full_url = u if u.startswith('http') else f'{self._host}{u}' + eps.append(f'第{i}集${full_url}') + play_urls.append('#'.join(eps)) + vod['vod_play_from'] = '$$$'.join(play_from) + vod['vod_play_url'] = '$$$'.join(play_urls) + + self._vod_name = vod_name + result['list'] = [vod] + except: + pass + return result + + def searchContent(self, key, quick, pg="1"): + result = {'list': [], 'page': int(pg), 'pagecount': 1, 'limit': 90, 'total': 0} + try: + html = self._fetch(f'{self._host}/search.html?keyword={quote(key)}') + if html: + result['list'] = self._parse_list(html) + if result['list']: + result['pagecount'] = 9999 + result['total'] = 999999 + except: + pass + return result + + def playerContent(self, flag, id, vipFlags): + try: + url = id + if not url.startswith('http'): + url = f'{self._host}{url}' + # For episode URLs with origin, fetch the page to get m3u8 + if 'origin=' in url and 'api/m3u8' not in url: + html = self._fetch(url, retries=3) + if html: + m3u8_url = self._extract_m3u8(html) + if m3u8_url: + danmaku = self._build_danmaku(url) + return {'parse': 0, 'url': m3u8_url, 'header': {'Referer': f'{self._host}/'}, 'danmaku': danmaku} + # Retry with fresh session + self._sion_id = '' + self._session.cookies.clear() + self._ensure_session() + if self._sion_id: + url = re.sub(r'sion_id=[^&]*', f'sion_id={self._sion_id}', url) if 'sion_id=' in url else f'{url}&sion_id={self._sion_id}' + html = self._fetch(url, retries=2) + if html: + m3u8_url = self._extract_m3u8(html) + if m3u8_url: + danmaku = self._build_danmaku(url) + return {'parse': 0, 'url': m3u8_url, 'header': {'Referer': f'{self._host}/'}, 'danmaku': danmaku} + # Direct m3u8 URL + html = self._fetch(url, retries=2) + if html and ('#EXTM3U' in html or 'mpegurl' in html.lower()): + danmaku = self._build_danmaku(url) + return {'parse': 0, 'url': url, 'header': {'Referer': f'{self._host}/'}, 'danmaku': danmaku} + except: + pass + return {'parse': 1, 'url': id, 'header': {'Referer': f'{self._host}/'}, 'danmaku': ''} + + def _extract_m3u8(self, html): + aa_m = re.search(r'xg_video_player_doc\s*=\s*\{[^}]*aa:\s*JSON\.parse\([\'"](\{[^}]+\})[\'"]\)', html, re.DOTALL) + if aa_m: + raw = aa_m.group(1).replace('\\u0022', '"').replace('\\u0026', '&') + raw = re.sub(r'\\(.)', r'\1', raw) + try: + aa = json.loads(raw) + m3u8_url = aa.get('url', '') + if m3u8_url: + if not m3u8_url.startswith('http'): + m3u8_url = f'{self._host}{m3u8_url}' + return m3u8_url + except: + pass + return None + + def _build_danmaku(self, url): + if not self._vod_name: + return '' + name = self._vod_name + name = re.sub(r'[\u300a\u300b\u3008\u3009\u300c\u300d\u300e\u300f\uff08\uff09\(\)\[\]\{\}]', '', name) + name = re.sub(r'(在线观看|在线播放|免费播放|免费观看|高清播放|高清在线|完整版|全集|电视剧|电影|免费|高清|完整版|播放|观看|全集免费|在线|影院)$', '', name) + name = re.sub(r'[-\s]+$', '', name).strip() + if not name: + return '' + ep_name = '' + if 'p=' in url: + p_m = re.search(r'p=(\d+)', url) + if p_m: + ep_name = str(int(p_m.group(1)) + 1) + return f'http://127.0.0.1:9978/proxy?do=appdanmu&vodName={quote(name)}&vodIndex={ep_name}' + + def localProxy(self, param): + return [200, {}, ''] diff --git a/py/达达兔.py b/py/达达兔.py new file mode 100644 index 0000000..00c2d46 --- /dev/null +++ b/py/达达兔.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- +import re +from urllib.parse import quote +from base.spider import Spider + + +class Spider(Spider): + + host = 'https://www.stonelodgeacademy.com' + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9', + } + + def init(self, extend=""): + pass + + def getName(self): + return '达达兔影院' + + def isVideoFormat(self, url): + return False + + def manualVideoCheck(self): + return True + + def destroy(self): + pass + + def homeContent(self, filter): + result = {'class': [], 'list': []} + try: + html = self._fetch(self.host + '/') + if not html: + return result + for m in re.finditer(r'<nav[^>]*class="[^"]*nav-menu[^"]*"[^>]*>(.*?)</nav>', html, re.DOTALL): + for a in re.finditer(r'<a[^>]*href="/(\w+)/?"[^>]*class="[^"]*nav-item[^"]*"[^>]*>([^<]*)</a>', m.group(1)): + result['class'].append({'type_id': a.group(1), 'type_name': a.group(2).strip()}) + result['list'] = self._parse_list(html) + except Exception as e: + print(f'homeContent error: {e}') + return result + + def homeVideoContent(self): + try: + html = self._fetch(self.host + '/') + if html: + return {'list': self._parse_list(html)} + except Exception as e: + print(f'homeVideoContent error: {e}') + return {'list': []} + + def categoryContent(self, tid, pg, filter, extend): + result = {'list': [], 'page': pg, 'pagecount': 1, 'limit': 90, 'total': 0} + try: + pg = int(pg) + url = f'{self.host}/{tid}/' + if pg > 1: + url += f'?page={pg}' + html = self._fetch(url) + if html: + result['list'] = self._parse_list(html) + result['page'] = str(pg) + result['pagecount'] = 9999 + result['total'] = 999999 + except Exception as e: + print(f'categoryContent error: {e}') + return result + + def detailContent(self, ids): + result = {'list': []} + try: + url = ids[0] + if not url.startswith('http'): + url = self.host + url + html = self._fetch(url) + if not html: + return result + vod = { + 'vod_id': ids[0], + 'vod_name': '', + 'vod_pic': '', + 'vod_content': '', + 'vod_year': '', + 'vod_area': '', + 'vod_director': '', + 'vod_actor': '', + 'vod_play_from': '默认', + 'vod_play_url': '', + } + m = re.search(r'<h1[^>]*>([^<]*)</h1>', html) + if m: + vod['vod_name'] = m.group(1).strip() + m = re.search(r'<div[^>]*class="[^"]*detail-poster[^"]*"[^>]*>.*?<img[^>]*src="([^"]*)"', html, re.DOTALL) + if m: + vod['vod_pic'] = m.group(1) + else: + m = re.search(r'<meta\s+property="og:image"\s+content="([^"]*)"', html) + if m: + vod['vod_pic'] = m.group(1) + m = re.search(r'<p[^>]*class="[^"]*detail-desc[^"]*"[^>]*>([^<]*)</p>', html) + if m: + vod['vod_content'] = m.group(1).strip() + m = re.search(r'导演[::]\s*([^<\n]*)', html) + if m: + vod['vod_director'] = re.sub(r'<[^>]+>', '', m.group(1)).strip() + m = re.search(r'主演[::]\s*([^<\n]*)', html) + if m: + vod['vod_actor'] = re.sub(r'<[^>]+>', '', m.group(1)).strip() + m = re.search(r'年份[::]\s*<a[^>]*>([^<]*)</a>', html) + if m: + vod['vod_year'] = m.group(1).strip() + m = re.search(r'地区[::]\s*<a[^>]*>([^<]*)</a>', html) + if m: + vod['vod_area'] = m.group(1).strip() + play_from = [] + play_url = [] + for src_m in re.finditer(r'<div[^>]*class="[^"]*play-source[^"]*"[^>]*>(.*?)</div>\s*</div>', html, re.DOTALL): + block = src_m.group(1) + name_m = re.search(r'<div[^>]*class="[^"]*play-source-name[^"]*"[^>]*>([^<]*)</div>', block) + src_name = name_m.group(1).strip() if name_m else '默认' + eps = [] + for ep_m in re.finditer(r'<a[^>]*href="(/[^"]*)"[^>]*class="[^"]*play-item[^"]*"[^>]*>([^<]*)</a>', block): + eps.append(f'{ep_m.group(2).strip()}${ep_m.group(1)}') + if eps: + play_from.append(src_name) + play_url.append('#'.join(eps)) + if play_from: + vod['vod_play_from'] = '$$$'.join(play_from) + vod['vod_play_url'] = '$$$'.join(play_url) + ep_map = {} + for src_idx, url_str in enumerate(play_url): + for ep in url_str.split('#'): + if '$' in ep: + ep_name, ep_url = ep.split('$', 1) + if ep_url: + ep_map[ep_url] = ep_name + self._vod_episode_map = ep_map + self._vod_name = vod['vod_name'] + result['list'] = [vod] + except Exception as e: + print(f'detailContent error: {e}') + return result + + def searchContent(self, key, quick, pg="1"): + result = {'list': [], 'page': pg, 'pagecount': 1, 'limit': 90, 'total': 0} + try: + html = self._fetch(f'{self.host}/search/?keyword={quote(key)}') + if html: + result['list'] = self._parse_list(html) + result['pagecount'] = 9999 + result['total'] = 999999 + except Exception as e: + print(f'searchContent error: {e}') + return result + + def playerContent(self, flag, id, vipFlags): + try: + url = id + if not url.startswith('http'): + url = self.host + url + html = self._fetch(url) + if html: + m = re.search(r'<meta\s+property="og:video"\s+content="([^"]*)"', html) + if m: + vod_name = getattr(self, '_vod_name', '') + ep_map = getattr(self, '_vod_episode_map', {}) + ep_name = ep_map.get(id, '') + danmaku = self._build_danmaku_url(vod_name, ep_name) + return {'parse': 0, 'url': m.group(1), 'header': self.headers, 'danmaku': danmaku} + m = re.search(r'<source\s+src="([^"]*)"', html) + if m: + vod_name = getattr(self, '_vod_name', '') + ep_map = getattr(self, '_vod_episode_map', {}) + ep_name = ep_map.get(id, '') + danmaku = self._build_danmaku_url(vod_name, ep_name) + return {'parse': 0, 'url': m.group(1), 'header': self.headers, 'danmaku': danmaku} + except Exception as e: + print(f'playerContent error: {e}') + return {'parse': 1, 'url': id, 'header': self.headers} + + def localProxy(self, param): + return [200, {}, ""] + + def _parse_list(self, html): + videos = [] + if not html: + return videos + for m in re.finditer( + r'<div[^>]*class="[^"]*\bvideo-card\b[^"]*"[^>]*>' + r'.*?<a[^>]*href="(/[^"]*)"[^>]*class="[^"]*\bvideo-thumb\b[^"]*"[^>]*>', + html, re.DOTALL + ): + block = html[m.start():m.start() + 600] + href = m.group(1) + pic = '' + pic_m = re.search(r'data-src="([^"]*)"', block) + if pic_m: + pic = pic_m.group(1) + else: + pic_m = re.search(r'src="([^"]*)"', block) + if pic_m: + pic = pic_m.group(1) + name = '' + alt_m = re.search(r'alt="([^"]*)"', block) + if alt_m: + name = alt_m.group(1) + if not name: + title_m = re.search(r'<h[23][^>]*class="[^"]*\bvideo-title\b[^"]*"[^>]*>([^<]*)</h[23]>', block) + if title_m: + name = title_m.group(1).strip() + remark = '' + ep = re.search(r'<span[^>]*class="[^"]*\bvideo-episode\b[^"]*"[^>]*>([^<]*)</span>', block) + if ep: + remark = ep.group(1).strip() + else: + tag = re.search(r'<span[^>]*class="[^"]*\bvideo-tag\b[^"]*"[^>]*>([^<]*)</span>', block) + if tag: + remark = tag.group(1).strip() + year = '' + meta_m = re.search(r'<p[^>]*class="[^"]*\bvideo-meta\b[^"]*"[^>]*>([^<]*)</p>', block) + if meta_m: + ym = re.search(r'(\d{4})', meta_m.group(1)) + if ym: + year = ym.group(1) + videos.append({ + 'vod_id': href, + 'vod_name': name, + 'vod_pic': pic if pic.startswith('http') else '', + 'vod_remarks': remark, + 'vod_year': year, + }) + return videos + + def _fetch(self, url): + import time + self._rate_limit() + for i in range(3): + try: + r = self.fetch(url, headers=self.headers, timeout=15) + if r.status_code == 200 and len(r.text) > 100: + return r.text + time.sleep(2) + except: + time.sleep(2) + return None + + def _rate_limit(self): + import time + now = time.time() + gap = now - getattr(self, '_last_request_time', 0) + if gap < 1.5: + time.sleep(1.5 - gap) + self._last_request_time = time.time() + + def _build_danmaku_url(self, vod_name='', ep_name=''): + idx = self._parse_episode_index(ep_name) + params = [] + if vod_name: + params.append(f'vodName={quote(vod_name)}') + params.append(f'vodIndex={idx}') + q = '&'.join(params) + return f'http://127.0.0.1:9978/proxy?do=appdanmu&{q}' + + @staticmethod + def _parse_episode_index(name): + if not name: + return 0 + s = str(name).strip() + m = re.search(r'第\s*((?:\d+)|(?:[一二三四五六七八九十百零]+))\s*[集話话章部期]', s) + if m: + ns = m.group(1) + if ns.isdigit(): + return int(ns) + return Spider._cn_num(ns) + m = re.search(r'(?:EP|ep|第)\s*(\d+)', s) + if m: + n = int(m.group(1)) + return n if n > 0 else 0 + m = re.search(r'(\d+)', s) + if m: + n = int(m.group(1)) + return n if n > 0 else 0 + return 0 + + @staticmethod + def _cn_num(s): + cm = {'零': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, + '六': 6, '七': 7, '八': 8, '九': 9, '十': 10} + t, tmp = 0, 0 + for ch in s: + if ch == '零': + continue + if ch == '十': + t += (tmp or 1) * 10 + tmp = 0 + elif ch == '百': + t += (tmp or 1) * 100 + tmp = 0 + elif ch == '千': + t += (tmp or 1) * 1000 + tmp = 0 + else: + tmp = cm.get(ch, 0) + t += tmp + return t if t else 0 diff --git a/py/采集聚合.py b/py/采集聚合.py new file mode 100644 index 0000000..72678bb --- /dev/null +++ b/py/采集聚合.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +# 专属全网聚合 Python版 +# 适配常见 Cat/TVBox Python Spider +#本地py适配 😂 + +import json +import requests +from concurrent.futures import ThreadPoolExecutor, as_completed +from base.spider import Spider + + +class Spider(Spider): + sources = { + 's1': {'name': '🎬电影天堂', 'api': 'http://caiji.dyttzyapi.com/api.php/provide/vod/from/dyttm3u8/at/json'}, + 's2': {'name': '💧无水印', 'api': 'https://api.wsyzy.net/api.php/provide/vod'}, + 's3': {'name': '🧸量子', 'api': 'https://cj.lziapi.com/api.php/provide/vod'}, + 's4': {'name': '📺1080资源', 'api': 'https://api.1080zyku.com/inc/api_mac10.php'}, + 's5': {'name': '🔥155资源', 'api': 'https://155api.com/api.php/provide/vod'}, + 's6': {'name': '📺天涯', 'api': 'https://tyyszy.com/api.php/provide/vod'}, + 's7': {'name': '📺暴风', 'api': 'https://bfzyapi.com/api.php/provide/vod'}, + 's8': {'name': '⚡索尼闪电', 'api': 'https://xsd.sdzyapi.com/api.php/provide/vod'}, + 's9': {'name': '📺索尼', 'api': 'https://suoniapi.com/api.php/provide/vod'}, + 's10': {'name': '📺红牛', 'api': 'https://www.hongniuzy2.com/api.php/provide/vod'}, + 's11': {'name': '📺茅台', 'api': 'https://caiji.maotaizy.cc/api.php/provide/vod'}, + 's12': {'name': '🐯虎牙', 'api': 'https://www.huyaapi.com/api.php/provide/vod'}, + 's13': {'name': '📺豆瓣', 'api': 'https://caiji.dbzy.tv/api.php/provide/vod'}, + 's14': {'name': '📺豆瓣2', 'api': 'https://dbzy.tv/api.php/provide/vod'}, + 's15': {'name': '📺豪华', 'api': 'https://hhzyapi.com/api.php/provide/vod'}, + 's16': {'name': '📺CK资源', 'api': 'https://ckzy.me/api.php/provide/vod'}, + 's17': {'name': '📺U酷', 'api': 'https://api.ukuapi.com/api.php/provide/vod'}, + 's18': {'name': '📺ikun', 'api': 'https://ikunzyapi.com/api.php/provide/vod'}, + 's19': {'name': '📺无尽', 'api': 'https://api.wujinapi.cc/api.php/provide/vod'}, + 's20': {'name': '🌕光速', 'api': 'https://api.guangsuapi.com/api.php/provide/vod'}, + 's21': {'name': '📺卧龙', 'api': 'https://collect.wolongzyw.com/api.php/provide/vod'}, + 's22': {'name': '📺新浪', 'api': 'https://api.xinlangapi.com/xinlangapi.php/provide/vod'}, + 's23': {'name': '📺旺旺', 'api': 'https://api.wwzy.tv/api.php/provide/vod'}, + 's24': {'name': '📺最大', 'api': 'https://api.zuidapi.com/api.php/provide/vod'}, + 's25': {'name': '🌸樱花', 'api': 'https://m3u8.apiyhzy.com/api.php/provide/vod'}, + 's26': {'name': '🐮牛牛', 'api': 'https://api.niuniuzy.me/api.php/provide/vod'}, + 's27': {'name': '☁️百度云', 'api': 'https://api.apibdzy.com/api.php/provide/vod'}, + 's28': {'name': '🏎速播', 'api': 'https://subocaiji.com/api.php/provide/vod'}, + 's29': {'name': '🦅金鹰', 'api': 'https://jinyingzy.com/api.php/provide/vod'}, + 's30': {'name': '⚡闪电', 'api': 'https://sdzyapi.com/api.php/provide/vod'}, + 's31': {'name': '👑非凡', 'api': 'https://cj.ffzyapi.com/api.php/provide/vod'}, + 's32': {'name': '🍃飘零', 'api': 'https://p2100.net/api.php/provide/vod'}, + 's33': {'name': '🐾魔爪', 'api': 'https://mozhuazy.com/api.php/provide/vod'}, + 's34': {'name': '📺魔都', 'api': 'https://www.mdzyapi.com/api.php/provide/vod'}, + } + + headers = { + "User-Agent": "Mozilla/5.0" + } + + def getName(self): + return "影视+专属全网聚合" + + def init(self, extend=""): + pass + + def fetch(self, url, timeout=8): + try: + r = requests.get( + url, + headers=self.headers, + timeout=timeout, + verify=False + ) + return r.text + except Exception: + return "" + + def clean_item(self, item, source_key, source_name, is_detail=False): + item = dict(item) + + if not is_detail: + item["vod_id"] = f"{source_key}@@{item.get('vod_id', '')}" + + remarks = item.get("vod_remarks", "") + item["vod_remarks"] = f"{source_name} | {remarks}" + + if item.get("vod_play_from"): + froms = item["vod_play_from"].split("$$$") + froms = [f"{source_name}-{x}" for x in froms] + item["vod_play_from"] = "$$$".join(froms) + + item.pop("vod_down_from", None) + item.pop("vod_down_url", None) + + return item + + def homeContent(self, filter): + classes = [] + filters = {} + + def load_class(key, source): + url = f"{source['api']}?ac=list" + html = self.fetch(url, 4) + + try: + data = json.loads(html) + except: + data = {} + + vals = [{"n": "全部(最新)", "v": ""}] + + for c in data.get("class", []): + vals.append({ + "n": c.get("type_name", ""), + "v": c.get("type_id", "") + }) + + return key, vals + + with ThreadPoolExecutor(max_workers=16) as executor: + futures = [] + + for key, source in self.sources.items(): + classes.append({ + "type_id": key, + "type_name": source["name"] + }) + + futures.append(executor.submit(load_class, key, source)) + + for future in as_completed(futures): + try: + key, vals = future.result() + + filters[key] = [{ + "key": "cateId", + "name": "分类", + "value": vals + }] + except: + pass + + return { + "class": classes, + "filters": filters, + "list": [] + } + + def categoryContent(self, tid, pg, filter, extend): + if tid not in self.sources: + return {"list": []} + + source = self.sources[tid] + + cate_id = "" + if isinstance(extend, dict): + cate_id = extend.get("cateId", "") + + url = f"{source['api']}?ac=detail&pg={pg}" + + if cate_id: + url += f"&t={cate_id}" + + html = self.fetch(url) + + try: + data = json.loads(html) + except: + data = {} + + result = [] + + for item in data.get("list", []): + result.append( + self.clean_item( + item, + tid, + source["name"], + False + ) + ) + + return { + "list": result, + "page": data.get("page", pg), + "pagecount": data.get("pagecount", 1), + "limit": data.get("limit", 20), + "total": data.get("total", len(result)) + } + + def detailContent(self, ids): + if isinstance(ids, list): + ids = ids[0] + + if "@@" not in ids: + return {"list": []} + + source_key, real_id = ids.split("@@", 1) + + if source_key not in self.sources: + return {"list": []} + + source = self.sources[source_key] + + url = f"{source['api']}?ac=detail&ids={real_id}" + + html = self.fetch(url) + + try: + data = json.loads(html) + except: + data = {} + + result = [] + + for item in data.get("list", []): + cleaned = self.clean_item( + item, + source_key, + source["name"], + True + ) + + cleaned["vod_id"] = ids + + result.append(cleaned) + + return {"list": result} + + def search_one(self, source_key, source, keyword, pg): + url = f"{source['api']}?ac=detail&wd={keyword}&pg={pg}" + + html = self.fetch(url, 6) + + try: + data = json.loads(html) + except: + data = {} + + result = [] + + for item in data.get("list", []): + result.append( + self.clean_item( + item, + source_key, + source["name"], + False + ) + ) + + return { + "list": result, + "pagecount": data.get("pagecount", 1) + } + + def searchContent(self, key, quick=False, pg=1): + result = [] + max_page = 1 + + with ThreadPoolExecutor(max_workers=20) as executor: + futures = [] + + for source_key, source in self.sources.items(): + futures.append( + executor.submit( + self.search_one, + source_key, + source, + key, + pg + ) + ) + + for future in as_completed(futures): + try: + data = future.result() + + result.extend(data["list"]) + + if data["pagecount"] > max_page: + max_page = data["pagecount"] + + except: + pass + + return { + "list": result, + "page": pg, + "pagecount": max_page, + "limit": 40, + "total": 9999 + } + + def playerContent(self, flag, id, vipFlags): + return { + "parse": 0, + "playUrl": "", + "url": id, + "header": self.headers + } + + def localProxy(self, param): + return [200, "text/plain", "ok"] + + +if __name__ == "__main__": + Spider().run() \ No newline at end of file