Files
sg/py/大马猴.py
2026-08-14 11:24:46 +02:00

542 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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('&nbsp;', ' ')
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