Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-15 16:08:52 +00:00
parent f6d6b42364
commit 3ced67d204
12 changed files with 4920 additions and 3282 deletions
+509
View File
@@ -0,0 +1,509 @@
# coding=utf-8
import base64
import json
import re
import requests
import sys
from functools import wraps
from urllib.parse import quote, unquote, urljoin, urlparse
from lxml import etree
sys.path.append("..")
from base.spider import Spider as BaseSpider
LOG_TAG = "[威视TV_DEBUG]"
def trace_interface(function):
@wraps(function)
def wrapper(self, *args, **kwargs):
interface = function.__name__
self._debug(
"接口开始",
interface=interface,
arguments=self._summarize_arguments(interface, args, kwargs),
)
try:
result = function(self, *args, **kwargs)
except Exception as error:
self._debug(
"接口异常",
interface=interface,
error_type=type(error).__name__,
error=str(error),
)
raise
self._debug("接口完成", interface=interface, **self._summarize_result(interface, result))
return result
return wrapper
class Spider(BaseSpider):
DEFAULT_HOST = "https://weishitv.xyz"
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
)
CLASSES = [
{"type_id": "1", "type_name": "电影"},
{"type_id": "2", "type_name": "剧集"},
{"type_id": "3", "type_name": "综艺"},
{"type_id": "4", "type_name": "动漫"},
]
@trace_interface
def getName(self):
return "威视TV"
@trace_interface
def init(self, extend=""):
candidate = extend
try:
if isinstance(extend, str) and extend.strip().startswith("{"):
candidate = json.loads(extend)
if isinstance(candidate, dict):
candidate = candidate.get("host") or candidate.get("url") or candidate.get("site") or ""
except Exception as error:
self._debug("配置解析失败", error_type=type(error).__name__, error=str(error))
candidate = ""
value = str(candidate or "").strip().rstrip("/")
self.host = value if re.match(r"^https?://", value, re.I) else self.DEFAULT_HOST
self.headers = {
"User-Agent": self.UA,
"Referer": self.host + "/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
}
self.session = requests.Session()
self.session.trust_env = False
self.session.headers.update(self.headers)
@trace_interface
def homeContent(self, filter):
result = {"class": list(self.CLASSES)}
if filter:
result["filters"] = {}
return result
@trace_interface
def homeVideoContent(self):
return {"list": self._parse_list(self._get(self.host + "/"))[:60]}
@trace_interface
def categoryContent(self, tid, pg, filter, extend):
page = self._page(pg)
html = self._get(f"{self.host}/index.php/vod/show/id/{quote(str(tid))}/page/{page}.html")
items = self._parse_list(html)
pagecount = self._page_count(html, page + 1 if items else page)
return {
"list": items,
"page": page,
"pagecount": pagecount,
"limit": len(items),
"total": pagecount * max(1, len(items)),
}
@trace_interface
def detailContent(self, ids):
if not ids:
return {"list": []}
vod = self._parse_detail(str(ids[0]), self._get(self._absolute(ids[0])))
return {"list": [vod] if vod else []}
@trace_interface
def searchContent(self, key, quick, pg="1"):
page = self._page(pg)
suffix = f"&page={page}" if page > 1 else ""
html = self._get(f"{self.host}/index.php/vod/search.html?wd={quote(str(key))}{suffix}")
items = self._parse_list(html)
return {
"list": items,
"page": page,
"pagecount": self._page_count(html, page + 1 if items else page),
}
@trace_interface
def playerContent(self, flag, id, vipFlags):
value = str(id or "")
if self._is_media(value):
return {"parse": 0, "url": value, "header": self._player_headers()}
url = self._decode_player(self._get(self._absolute(value)))
if not url:
return {"parse": 1, "url": "", "header": self._player_headers()}
return {"parse": 0 if self._is_media(url) else 1, "url": url, "header": self._player_headers()}
def _get(self, url):
self._debug("HTTP请求", method="GET", url=self._safe_url(url))
try:
response = self.session.get(url, timeout=15)
response.encoding = "utf-8"
text = response.text or ""
status = getattr(response, "status_code", 200)
final_url = str(getattr(response, "url", "") or url)
response_headers = getattr(response, "headers", {}) or {}
title = self._page_title(text)
self._debug(
"HTTP响应",
status=status,
final_url=self._safe_url(final_url),
content_length=len(text),
content_type=str(response_headers.get("Content-Type") or ""),
title=title,
page_kind=self._page_kind(text, title),
)
if status >= 400:
self._debug("HTTP状态异常", status=status, url=self._safe_url(final_url))
return ""
return text
except Exception as error:
self._debug(
"HTTP异常",
url=self._safe_url(url),
error_type=type(error).__name__,
error=str(error),
)
return ""
def _absolute(self, value):
text = str(value or "").strip()
if not text:
return self.host + "/"
if text.startswith("//"):
return "https:" + text
if re.match(r"^https?://", text, re.I):
parsed = urlparse(text)
if "/index.php/" in parsed.path:
return self.host + parsed.path + (("?" + parsed.query) if parsed.query else "")
return text
return urljoin(self.host + "/", text)
def _relative(self, value):
text = str(value or "").strip()
if not text:
return ""
if re.match(r"^https?://", text, re.I):
parsed = urlparse(text)
return parsed.path + (("?" + parsed.query) if parsed.query else "")
return text if text.startswith("/") else "/" + text
def _parse_list(self, html):
doc = self._html(html)
raw_list_markers = len(re.findall(r"public-list-box", html or "", re.I))
raw_detail_markers = len(re.findall(r"vod/detail", html or "", re.I))
skipped = {
"missing_link": 0,
"missing_id": 0,
"missing_name": 0,
"duplicate": 0,
"errors": 0,
}
if doc is None:
self._debug(
"列表解析",
html_parse_ok=False,
raw_list_markers=raw_list_markers,
raw_detail_markers=raw_detail_markers,
containers=0,
detail_links=0,
parsed=0,
skipped=skipped,
samples=[],
html_length=len(html or ""),
)
return []
items = []
seen = set()
boxes = doc.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' public-list-box ')]")
detail_links = len(doc.xpath("//a[contains(@href, '/index.php/vod/detail/')]"))
for box in boxes:
try:
links = box.xpath(".//a[contains(concat(' ', normalize-space(@class), ' '), ' public-list-exp ') and contains(@href, '/index.php/vod/detail/')]")
if not links:
skipped["missing_link"] += 1
continue
link = links[0]
vod_id = self._relative(link.get("href"))
if not vod_id:
skipped["missing_id"] += 1
continue
name = (link.get("title") or "").strip()
if not name:
alt = link.xpath("string(.//img[1]/@alt)").strip()
name = re.sub(r"封面图$", "", alt).strip()
if not name:
skipped["missing_name"] += 1
continue
if vod_id in seen:
skipped["duplicate"] += 1
continue
seen.add(vod_id)
pic = link.xpath("string(.//img[1]/@data-src)") or link.xpath("string(.//img[1]/@data-original)") or link.xpath("string(.//img[1]/@src)")
pic = "" if str(pic).startswith("data:image/") else self._absolute(pic)
remarks = self._first_text(box, ".//*[contains(concat(' ', normalize-space(@class), ' '), ' public-list-prb ')]")
if not remarks:
remarks = self._first_text(box, ".//*[contains(concat(' ', normalize-space(@class), ' '), ' public-prt ')]")
items.append({"vod_id": vod_id, "vod_name": name, "vod_pic": pic, "vod_remarks": remarks})
except Exception as error:
skipped["errors"] += 1
self._debug(
"列表条目解析失败",
error_type=type(error).__name__,
error=str(error),
)
self._debug(
"列表解析",
html_parse_ok=True,
raw_list_markers=raw_list_markers,
raw_detail_markers=raw_detail_markers,
containers=len(boxes),
detail_links=detail_links,
parsed=len(items),
skipped=skipped,
samples=[item["vod_name"] for item in items[:3]],
html_length=len(html or ""),
)
return items
def _parse_detail(self, vod_id, html):
doc = self._html(html)
if doc is None:
return None
name = self._first_text(doc, "//*[contains(concat(' ', normalize-space(@class), ' '), ' this-desc-title ')]")
if not name:
return None
style = doc.xpath("string((//*[contains(concat(' ', normalize-space(@class), ' '), ' this-pic-bj ')])[1]/@style)")
match = re.search(r"url\(['\"]?([^'\")]+)", style or "", re.I)
pic = self._absolute(match.group(1)) if match else ""
info = [self._clean("".join(node.itertext())) for node in doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' this-desc-info ')]//span")]
info = [item for item in info if item]
year = next((item for item in info if re.match(r"^(19|20)\d{2}$", item)), "")
area = next((item for item in info if item != year and not re.search(r"更新|集|完结|分$", item)), "")
remarks = next((item for item in info if re.search(r"更新|集|完结|HD|正片", item)), "")
type_name = " / ".join(filter(None, [self._clean("".join(node.itertext())) for node in doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' this-desc-tags ')]//span")]))
director = ""
actor = ""
for node in doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' this-info ')]"):
label = self._first_text(node, ".//strong").replace(" ", "")
value = ",".join(filter(None, [self._clean("".join(item.itertext())) for item in node.xpath(".//a")]))
if "导演" in label:
director = value
if "演员" in label or "主演" in label:
actor = value
content = self._first_text(doc, "//*[@id='height_limit']//*[contains(concat(' ', normalize-space(@class), ' '), ' text ')]")
content = re.sub(r"^\s*简介[:]?\s*", "", content).strip()
line_nodes = doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' anthology-tab ')]//*[contains(concat(' ', normalize-space(@class), ' '), ' line-btn ')]")
blocks = doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' anthology-list ')]//*[contains(concat(' ', normalize-space(@class), ' '), ' anthology-list-box ')]")
play_from = []
play_url = []
for index in range(min(len(line_nodes), len(blocks))):
line_texts = line_nodes[index].xpath(".//text()[not(ancestor::span[contains(concat(' ', normalize-space(@class), ' '), ' badge ')])]")
line_name = self._clean("".join(line_texts)) or f"线路{index + 1}"
episodes = []
episode_nodes = blocks[index].xpath(".//a[contains(concat(' ', normalize-space(@class), ' '), ' episode-btn ') and @href]")
for episode_index, episode in enumerate(episode_nodes):
episode_id = self._relative(episode.get("href"))
if episode_id:
episode_name = self._clean("".join(episode.itertext())) or f"{episode_index + 1}"
episodes.append(f"{episode_name}${episode_id}")
if episodes:
play_from.append(line_name)
play_url.append("#".join(episodes))
return {
"vod_id": self._relative(vod_id),
"vod_name": name,
"vod_pic": pic,
"vod_remarks": remarks,
"vod_year": year,
"vod_area": area,
"type_name": type_name,
"vod_director": director,
"vod_actor": actor,
"vod_content": content,
"vod_play_from": "$$$".join(play_from),
"vod_play_url": "$$$".join(play_url),
}
def _decode_player(self, html):
match = re.search(r"var\s+player_aaaa\s*=\s*(\{[\s\S]*?\})\s*</script>", html or "", re.I)
if not match:
return ""
try:
player = json.loads(match.group(1))
value = str(player.get("url") or "")
encrypt = int(player.get("encrypt") or 0)
if encrypt == 1:
value = unquote(value)
elif encrypt == 2:
value = unquote(base64.b64decode(value).decode("utf-8"))
return value if re.match(r"^https?://", value, re.I) else ""
except Exception as error:
self._debug("播放数据解析失败", error_type=type(error).__name__, error=str(error))
return ""
def _player_headers(self):
return {"User-Agent": self.UA, "Referer": self.host + "/"}
@staticmethod
def _preview(value, limit=180):
try:
text = json.dumps(value, ensure_ascii=False, default=str)
except Exception:
text = str(value)
text = re.sub(r"\s+", " ", text).strip()
return text if len(text) <= limit else text[:limit] + "..."
def _debug(self, event, **fields):
try:
payload = {"event": event}
payload.update(fields)
self.log(f"{LOG_TAG} {json.dumps(payload, ensure_ascii=False, default=str)}")
except Exception:
pass
def _summarize_arguments(self, interface, args, kwargs):
if interface == "playerContent":
value = args[1] if len(args) > 1 else kwargs.get("id")
flags = args[2] if len(args) > 2 else kwargs.get("vipFlags")
return {
"flag": self._preview(args[0] if args else kwargs.get("flag"), 60),
"id": self._resource_hint(value),
"vip_flags_count": len(flags) if isinstance(flags, (list, tuple, set)) else 0,
}
if interface == "isVideoFormat":
value = args[0] if args else kwargs.get("url")
return {"url": self._resource_hint(value)}
if interface == "init":
value = args[0] if args else kwargs.get("extend", "")
return {"extend": self._preview(value, 120)}
return self._preview({"args": args, "kwargs": kwargs})
def _resource_hint(self, value):
text = str(value or "").strip()
parsed = urlparse(text)
if parsed.scheme in ("http", "https") and parsed.netloc:
return {"kind": "url", "host": parsed.netloc, "media": bool(self._is_media(text))}
return {"kind": "path", "value": self._preview(text, 120)}
def _safe_url(self, value):
text = str(value or "").strip()
try:
parsed = urlparse(text)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return self._preview(text, 180)
if self._is_media(text):
return f"{parsed.scheme}://{parsed.netloc}/<media>"
query = "?<redacted>" if parsed.query else ""
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}{query}"
except Exception:
return "<invalid-url>"
def _summarize_result(self, interface, result):
summary = {"result_type": type(result).__name__}
if not isinstance(result, dict):
summary["result"] = self._preview(result)
return summary
items = result.get("list")
classes = result.get("class")
if isinstance(items, list):
summary["list_count"] = len(items)
summary["samples"] = [
str(item.get("vod_name") or "")
for item in items[:3]
if isinstance(item, dict)
]
if isinstance(classes, list):
summary["class_count"] = len(classes)
for key in ("page", "pagecount", "limit", "total", "parse"):
if key in result:
summary[key] = result[key]
if interface == "playerContent":
value = str(result.get("url") or "")
summary["has_url"] = bool(value)
summary["media"] = self._is_media(value)
summary["url_host"] = urlparse(value).netloc if value else ""
return summary
@classmethod
def _page_title(cls, html):
match = re.search(r"<title[^>]*>([\s\S]*?)</title>", html or "", re.I)
if not match:
return ""
return cls._clean(re.sub(r"<[^>]+>", "", match.group(1)))
@staticmethod
def _page_kind(html, title):
text = f"{title}\n{str(html or '')[:50000]}".lower()
markers = (
"just a moment",
"cf-chl-",
"captcha",
"人机验证",
"访问验证",
"安全验证",
)
if any(marker in text for marker in markers):
return "challenge"
return "html" if html else "empty"
def _html(self, html):
try:
if not html:
return None
payload = html.encode("utf-8") if isinstance(html, str) else html
parser = etree.HTMLParser(encoding="utf-8", recover=True)
return etree.HTML(payload, parser=parser)
except Exception as error:
self._debug(
"HTML解析失败",
error_type=type(error).__name__,
error=str(error),
html_length=len(html or ""),
)
return None
@staticmethod
def _clean(value):
return re.sub(r"\s+", " ", str(value or "")).strip()
def _first_text(self, node, xpath):
values = node.xpath(xpath)
if not values:
return ""
value = values[0]
return self._clean("".join(value.itertext()) if hasattr(value, "itertext") else value)
@staticmethod
def _page(value):
try:
return max(1, int(value))
except Exception:
return 1
@staticmethod
def _page_count(html, fallback):
pages = [int(value) for value in re.findall(r"/page/(\d+)\.html", html or "")]
return max([int(fallback)] + pages)
@staticmethod
def _is_media(url):
return re.match(r"^https?://", str(url or ""), re.I) is not None and re.search(r"\.(m3u8|mp4|flv|mkv|ts|mpd)(?:[?#]|$)", str(url), re.I) is not None
@trace_interface
def isVideoFormat(self, url):
return False
@trace_interface
def manualVideoCheck(self):
return False
@trace_interface
def destroy(self):
pass
@trace_interface
def localProxy(self, param):
return None
+1078
View File
@@ -0,0 +1,1078 @@
# 蜜桃视频 类型爬虫
# 网站: https://www.nht966hht.vip:9527
# API: AES-128-CBC (ZeroPadding) + MD5 签名加密
# coding=utf-8
# !/usr/bin/python
import sys
sys.path.append('..')
from base.spider import BaseSpider
import requests
import json
import base64
import hashlib
import time
import re
import os
import string
import random
import threading
from urllib.parse import quote, unquote
from Crypto.Cipher import AES
TIMEOUT = 10
# ============================================================
# 站点配置(多站点备用)
# ============================================================
SITES = [
{'name': 'nht966', 'host': 'https://www.nht966hht.vip:9527'},
{'name': 'httre666', 'host': 'https://www.newhttestre666.cc'},
]
# ============================================================
# 加密常量(从 JS bundle 中提取)
# ============================================================
SIGN_KEY = 'opum3_Loily$SV^6H'
BUNDLE_ID = 'com.ht9.web20.video'
BRAND_ID = 'hongtao'
VERSION = '1.0.0'
PROJECT_ID = '1'
PROXY_TYPE = 'mitao_img'
class Spider(BaseSpider):
# ---- 基础信息 ----
def getName(self):
return "蜜桃视频"
def isVideoFormat(self, url):
return url and ('.mp4' in url or '.m3u8' in url or '.ts' in url)
def manualVideoCheck(self):
return False
# ---- 类变量 ----
filterable = True
searchable = True
host = SITES[0]['host']
session = requests.Session()
headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"lang": "cn",
"deviceType": "H5-android",
}
# 测速缓存
_speed_cache_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.mitao_cache.json')
_speed_cache_ttl = 1800
_lock = threading.Lock()
_speed_test_done = False
# 会话状态
_user_id = ''
_session_id = ''
_device_id = ''
_session_inited = False
# 分类缓存 (从 initH5_1 typeTitleList)
_categories = []
# 视频类型列表 (从 appConfig videoTypeList,用于构建筛选)
_video_type_list = []
# 会话缓存(避免重复 deviceLogin 触发 429 限流)
_session_cache_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.mitao_session.json')
_session_cache_ttl = 1800 # 30 分钟
# ============================================================
# 多站点测速
# ============================================================
def _get_cached_site(self):
try:
if os.path.exists(self._speed_cache_file):
with open(self._speed_cache_file, 'r') as f:
data = json.loads(f.read())
age = time.time() - data.get('ts', 0)
host = data.get('host', '')
if age < self._speed_cache_ttl and host:
return host, True
except Exception:
pass
return '', False
def _save_cached_site(self, host):
try:
with open(self._speed_cache_file, 'w') as f:
f.write(json.dumps({'host': host, 'ts': time.time()}))
except Exception:
pass
def _test_site_speed(self, site, results):
try:
start = time.time()
r = requests.get(site['host'], headers=self.headers, timeout=TIMEOUT, verify=False)
elapsed = time.time() - start
if r.status_code == 200:
with self._lock:
results[site['name']] = elapsed
except Exception:
with self._lock:
results[site['name']] = 999
def _select_best_site(self):
if self._speed_test_done:
return
cached_host, valid = self._get_cached_site()
if valid:
self.host = cached_host
self._speed_test_done = True
return
results = {}
threads = []
for s in SITES:
t = threading.Thread(target=self._test_site_speed, args=(s, results))
t.daemon = True
t.start()
threads.append(t)
for t in threads:
t.join(1.5)
valid_sites = [s for s in SITES if results.get(s['name'], 999) < TIMEOUT]
best = min(valid_sites, key=lambda x: results[x['name']])['host'] if valid_sites else SITES[0]['host']
self.host = best
self._speed_test_done = True
self._save_cached_site(best)
# ============================================================
# 会话缓存(持久化到文件,避免重复 init 触发 429 限流)
# ============================================================
def _save_session_cache(self):
"""将会话状态写入缓存文件"""
try:
data = {
'ts': time.time(),
'user_id': self._user_id,
'session_id': self._session_id,
'device_id': self._device_id,
'categories': self._categories,
'video_type_list': self._video_type_list,
}
with open(self._session_cache_file, 'w') as f:
f.write(json.dumps(data, ensure_ascii=False))
except Exception:
pass
def _load_session_cache(self):
"""从缓存文件恢复会话状态,返回 True 表示缓存有效"""
try:
if not os.path.exists(self._session_cache_file):
return False
with open(self._session_cache_file, 'r') as f:
data = json.loads(f.read())
age = time.time() - data.get('ts', 0)
if age >= self._session_cache_ttl:
return False
self._user_id = data.get('user_id', '')
self._session_id = data.get('session_id', '')
self._device_id = data.get('device_id', '')
self._categories = data.get('categories', [])
self._video_type_list = data.get('video_type_list', [])
# 关键字段缺失视为缓存无效, 避免无认证请求被服务器拒绝
if not self._user_id or not self._session_id:
return False
return True
except Exception:
return False
# ============================================================
# AES 加解密(匹配 CryptoJS ZeroPadding
# ============================================================
@staticmethod
def _zero_pad(data, block_size=16):
pad_len = block_size - (len(data) % block_size)
if pad_len == block_size:
return data
return data + b'\x00' * pad_len
@staticmethod
def _zero_unpad(data):
return data.rstrip(b'\x00')
def _gen_key(self, timestamp):
"""生成 AES-128 密钥: timestamp后6位 + signKey前4 + bundleId前6"""
ts = str(timestamp)
return ts[-6:] + SIGN_KEY[:4] + BUNDLE_ID[:6]
def _gen_iv(self):
"""生成 AES-128 IV: bundleId后6 + signKey后4 + deviceId前6"""
return BUNDLE_ID[-6:] + SIGN_KEY[-4:] + self._device_id[:6]
def _aes_encrypt(self, plaintext, key_str, iv_str):
"""AES-128-CBC 加密 (ZeroPadding, 输出 Base64)"""
key = key_str.encode('utf-8')
iv = iv_str.encode('utf-8')
cipher = AES.new(key, AES.MODE_CBC, iv)
data = plaintext.encode('utf-8')
padded = self._zero_pad(data)
encrypted = cipher.encrypt(padded)
return base64.b64encode(encrypted).decode('utf-8')
def _aes_decrypt(self, ciphertext_b64, key_str, iv_str):
"""AES-128-CBC 解密 (ZeroPadding, 输入 Base64)"""
key = key_str.encode('utf-8')
iv = iv_str.encode('utf-8')
cipher = AES.new(key, AES.MODE_CBC, iv)
# 移除空白字符(匹配 JS 端 replace(/\s/g,"")
cleaned = re.sub(r'\s', '', ciphertext_b64)
encrypted = base64.b64decode(cleaned)
decrypted = cipher.decrypt(encrypted)
unpadded = self._zero_unpad(decrypted)
return unpadded.decode('utf-8', errors='replace')
def _generate_sign(self, params, api_path):
"""MD5 签名: 参数值排序拼接 + signKey + API路径 → MD5 大写"""
sorted_keys = sorted(params.keys())
concat = ''
for k in sorted_keys:
concat += str(params[k])
raw = concat + SIGN_KEY + api_path
return hashlib.md5(raw.encode('utf-8')).hexdigest().upper()
# ============================================================
# 客户端 deviceId 生成(匹配 JS: "H5-" + 随机串)
# ============================================================
@staticmethod
def _generate_device_id():
"""生成 H5 设备 ID,格式: H5- + 32位随机小写hex"""
rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))
return 'H5-' + rand
# ============================================================
# 通用请求 params (Ne)
# ============================================================
def _common_params(self):
# channelId2 = window.location.host (含端口,如 www.nht950hht.vip:9527)
hostname = self.host.replace('https://', '').replace('http://', '')
return {
'timezone': 'Asia/Karachi',
'version': VERSION,
'channelId': 67, # 必须是整数! JS: __xyz_cid_ = 67, JSON.stringify 后为 67 而非 "67"
'channelId2': hostname,
'brandId': BRAND_ID,
}
# ============================================================
# API 请求(支持加密/明文双模式)
# ============================================================
def _api_request(self, endpoint, params=None, skip_encrypt=False, _t=None):
"""
发送 AES 加密 API 请求
endpoint: e.g. '/ht/content/homeH5'
params: 请求参数 dict
skip_encrypt: True = 发送明文 JSON (调试用, 部分 init 端点不需加密)
_t: 可选, 复用外部时间戳 (initH5_1/2 共用)
"""
if params is None:
params = {}
# 毫秒时间戳 (支持外部传入, 匹配浏览器 initH5_1/2 共用 t 的行为)
timestamp = str(_t) if _t else str(int(time.time() * 1000))
key_str = self._gen_key(timestamp)
iv_str = self._gen_iv()
# 构建完整参数: Ne() + {t} + 业务参数
full_params = self._common_params()
full_params['t'] = timestamp
full_params.update(params)
# 签名: ze(params, endpoint) = MD5(sorted_values + signKey + path).upper()
full_params['sign'] = self._generate_sign(full_params, endpoint)
api_url = self.host + endpoint
headers = dict(self.headers)
headers['t'] = timestamp
if self._user_id:
headers['userId'] = self._user_id
if self._session_id:
headers['sessionId'] = self._session_id
# 必填请求头 (JS Ve 拦截器会设置这些)
headers['deviceId'] = self._device_id or ''
headers['bundleId'] = BUNDLE_ID
# 明文或加密
if skip_encrypt:
body = json.dumps(full_params, ensure_ascii=False, separators=(',', ':'))
headers['Content-Type'] = 'application/json'
headers['encrypt'] = 'false'
else:
plain = json.dumps(full_params, ensure_ascii=False, separators=(',', ':'))
body = self._aes_encrypt(plain, key_str, iv_str)
headers['Content-Type'] = 'text/plain'
headers['encrypt'] = 'true'
try:
r = self.session.post(api_url, data=body,
headers=headers, timeout=TIMEOUT, verify=False)
resp = r.json()
# code=10000 表示成功,解密响应 data(仅加密请求需解密)
if resp.get('code') == 10000 and isinstance(resp.get('data'), str) and resp['data']:
try:
decrypted = self._aes_decrypt(resp['data'], key_str, iv_str)
resp['data'] = json.loads(decrypted)
except Exception:
pass
return resp
except requests.exceptions.Timeout:
return None
except requests.exceptions.ConnectionError:
return None
except json.JSONDecodeError:
return None
except Exception:
return None
# ============================================================
# 会话初始化(匹配 JS 端流程)
# ============================================================
def _ensure_session(self):
"""
初始化会话: 优先从文件缓存恢复 → 否则 appConfig → 生成 deviceId → initH5_1 → initH5_2 → deviceLogin
真实浏览器流程: appConfig 最先调,initH5_1/2 共用同一个 t 时间戳
缓存策略: 避免 T3 新建实例时重复 deviceLogin 触发 429 限流
"""
if self._session_inited:
return
# 优先从缓存恢复(跳过整个 init 流程,避免 429)
if self._load_session_cache():
self._session_inited = True
# 旧缓存可能没有 video_type_list,补一次 appConfig 请求
if not self._video_type_list:
appcfg = self._api_request('/ht/users/appConfig')
if appcfg and appcfg.get('code') == 10000:
ac_data = appcfg.get('data', {})
if isinstance(ac_data, dict) and ac_data.get('appConfig'):
ac_cfg = ac_data['appConfig']
if isinstance(ac_cfg, dict) and ac_cfg.get('videoTypeList'):
self._video_type_list = ac_cfg['videoTypeList']
return
# 0. 生成 deviceId (JS 端 $.getDeviceId() 在页面加载时就执行)
if not self._device_id:
self._device_id = self._generate_device_id()
# 0.5 appConfig — 真实浏览器第一个调的就是它,获取 videoTypeList 供筛选
appcfg = self._api_request('/ht/users/appConfig')
if appcfg and appcfg.get('code') == 10000:
ac_data = appcfg.get('data', {})
if isinstance(ac_data, dict) and ac_data.get('appConfig'):
ac_cfg = ac_data['appConfig']
if isinstance(ac_cfg, dict) and ac_cfg.get('videoTypeList'):
self._video_type_list = ac_cfg['videoTypeList']
# 1. initH5_1 + initH5_2 共用一个 t (匹配浏览器行为)
shared_t = int(time.time() * 1000)
resp1 = self._api_request('/ht/users/initH5_1', _t=shared_t)
if resp1 and resp1.get('code') == 10000:
data = resp1.get('data', {})
if data.get('deviceId'):
self._device_id = data['deviceId']
# 保存分类列表供 homeContent 使用
if data.get('typeTitleList'):
self._categories = data['typeTitleList']
# 2. initH5_2 (复用 shared_t)
self._api_request('/ht/users/initH5_2', _t=shared_t)
# 3. deviceLogin → 获取 userId / sessionId
resp = self._api_request('/ht/users/deviceLogin', {
'bundleId': BUNDLE_ID,
'brandId': BRAND_ID,
'projectId': PROJECT_ID,
})
if resp and resp.get('code') == 10000:
data = resp.get('data', {})
self._user_id = data.get('userId', '')
self._session_id = data.get('sessionId', '')
self._session_inited = True
self._save_session_cache()
# ============================================================
# 图片代理
# ============================================================
def get_proxy_image_url(self, img_url):
if not img_url:
return ''
base_proxy = self.getProxyUrl()
if not base_proxy:
base_proxy = 'http://127.0.0.1:9980/proxy?do=py'
return base_proxy + '&type=' + PROXY_TYPE + '&url=' + quote(img_url, safe='')
def _fmt_duration(self, seconds):
try:
s = int(seconds or 0)
except (TypeError, ValueError):
return ''
if s <= 0:
return ''
m, s = divmod(s, 60)
return f"{m}:{s:02d}"
# ============================================================
# 初始化
# ============================================================
def init(self, extend=""):
cached_host, valid = self._get_cached_site()
if valid:
self.host = cached_host
self._speed_test_done = True
# ============================================================
# 首页
# ============================================================
# T3 首页统一入口: 同时返回分类列表 + 首页视频数据
# ============================================================
_CATEGORY_BLACKLIST = {'成人游戏', '漫画', '小说', '蜜穴女友', '一键脱衣', '春药商城', '同城交友', '吃瓜', '成人漫画'}
def homeContent(self, filter):
self._select_best_site()
self._ensure_session()
classes = []
filters = {}
# 动态加载真实分类(来自 initH5_1 typeTitleList),过滤掉不需要的
for cat in self._categories:
cid = str(cat.get('contentId', ''))
title = cat.get('title', '')
if not cid or not title or title in self._CATEGORY_BLACKLIST:
continue
classes.append({'type_id': cid, 'type_name': title})
# ---- 构建该分类的筛选器 ----
cat_filters = []
# 1. 二级分类 (videoTypeList 中 typePid == contentId 的子项)
sub_cats = [v for v in self._video_type_list if str(v.get('typePid', '')) == cid]
if sub_cats:
sub_values = [{'n': '全部', 'v': ''}]
for sc in sub_cats:
sc_id = str(sc.get('typeId', ''))
sc_name = sc.get('typeName', '')
if sc_id and sc_name:
sub_values.append({'n': sc_name, 'v': sc_id})
if len(sub_values) > 1:
cat_filters.append({'key': 'label', 'name': '分类', 'value': sub_values})
# 2. 标签 (尝试从 videoTypeList 中匹配该 contentId 对应一级类型的 tags)
# 一级类型 typePid==0 且 typeId 可能等于 contentId
first_level = [v for v in self._video_type_list
if str(v.get('typePid', '')) == '0' and str(v.get('typeId', '')) == cid]
if first_level:
tags_str = first_level[0].get('tags', '')
if tags_str:
tag_list = [t.strip() for t in tags_str.split(',') if t.strip()]
if tag_list:
tag_values = [{'n': '全部', 'v': ''}]
for t in tag_list:
tag_values.append({'n': t, 'v': t})
cat_filters.append({'key': 'tag', 'name': '标签', 'value': tag_values})
# 3. 排序 (JS sortList: ["最近更新","最多播放","最多收藏"] → 索引 0/1/2)
cat_filters.append({'key': 'sort', 'name': '排序', 'value': [
{'n': '最近更新', 'v': '0'},
{'n': '最多播放', 'v': '1'},
{'n': '最多收藏', 'v': '2'},
]})
if cat_filters:
filters[cid] = cat_filters
# ---- 添加特殊分类: 女优 (actor) ----
classes.append({'type_id': 'actor', 'type_name': '女优'})
# 动态生成筛选值 (API 只接受单值精确匹配)
_actors_filters = []
# 身高: 150-164cm
_actors_filters.append({'key': 'height', 'name': '身高', 'value': [
{'n': '身高', 'v': ''},
] + [{'n': f'{h}cm', 'v': str(h)} for h in range(150, 165)]})
# 罩杯: A-G
_actors_filters.append({'key': 'cup', 'name': '罩杯', 'value': [
{'n': '罩杯', 'v': ''},
] + [{'n': f'{c}罩杯', 'v': c} for c in 'ABCDEFG']})
# 年龄: 1976-2002 (出生年份)
_actors_filters.append({'key': 'birthday', 'name': '年龄', 'value': [
{'n': '年龄', 'v': ''},
] + [{'n': f'{y}', 'v': str(y)} for y in range(2002, 1975, -1)]})
# 出道: 2001-2025
_actors_filters.append({'key': 'debut', 'name': '出道', 'value': [
{'n': '出道', 'v': ''},
] + [{'n': f'{y}', 'v': str(y)} for y in range(2025, 2000, -1)]})
filters['actor'] = _actors_filters
# ---- 添加特殊分类: 专题 (topic) ----
classes.append({'type_id': 'topic', 'type_name': '专题'})
# 同时返回首页推荐视频列表 (兼容 T3 统一返回模式)
home_videos = self.categoryContent('home', 1, '', {})
return {
'class': classes,
'filters': filters,
'type': '影视',
'list': home_videos.get('list', []),
'page': home_videos.get('page', 1),
'pagecount': home_videos.get('pagecount', 1),
'limit': home_videos.get('limit', 0),
'total': home_videos.get('total', 0),
}
def homeVideoContent(self, tid, pg, filter, extend):
return self.categoryContent(tid or 'home', pg, filter, extend)
# ============================================================
# 分类列表
# ============================================================
def categoryContent(self, tid, pg, filter, extend):
tid = str(tid)
pg = int(pg)
self._select_best_site()
self._ensure_session()
vod_list = []
# ---- @ folder 模式: 点击文件夹 → 获取视频列表 ----
if '@' in tid:
real_tid = tid.replace('@', '')
if real_tid.startswith('actor_'):
actor_id = real_tid[len('actor_'):]
# 先查演员名
detail_resp = self._api_request('/ht/content/queryActorDetail', {
'actorId': actor_id,
})
actor_name = ''
if detail_resp and detail_resp.get('code') == 10000:
detail_data = detail_resp.get('data', {})
actor_info = (detail_data.get('actorDetail') or detail_data or {})
actor_name = (actor_info.get('actorName') or actor_info.get('actor_name') or '')
# 用演员名搜索视频
if actor_name:
resp = self._api_request('/ht/content/search', {
'keywords': actor_name,
'pageNo': str(pg - 1),
'pageSize': '20',
})
else:
# 降级: 用 actorId 尝试 queryTypeVideosH5
resp = self._api_request('/ht/content/queryTypeVideosH5', {
'actorId': actor_id,
'pageNo': str(pg - 1),
'pageSize': '20',
'type': '1',
})
if not resp or resp.get('code') != 10000:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = resp.get('data', {})
vod_list = self._extract_videos_from_data(data)
total_page = int(data.get('totalPage') or data.get('total_page') or 1)
return {'list': vod_list, 'page': pg, 'pagecount': max(total_page, 1),
'limit': len(vod_list), 'total': max(total_page, 1) * 20}
elif real_tid.startswith('topic_'):
topic_id = real_tid[len('topic_'):]
resp = self._api_request('/ht/content/queryOriTopicVideos', {
'topicId': topic_id,
'pageNo': str(pg - 1),
'pageSize': '20',
})
if not resp or resp.get('code') != 10000:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = resp.get('data', {})
vod_list = self._extract_videos_from_data(data)
total_page = int(data.get('totalPage') or data.get('total_page') or 1)
return {'list': vod_list, 'page': pg, 'pagecount': max(total_page, 1),
'limit': len(vod_list), 'total': max(total_page, 1) * 20}
else:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
# ---- 女优列表 (folder 模式) ----
if tid == 'actor':
# 构建 API 参数, 映射 extend 中的筛选 key → API 参数名
api_params = {
'pageNo': str(pg - 1),
'pageSize': '20',
}
if isinstance(extend, dict):
_actor_filter_map = {
'height': 'actorHeight',
'cup': 'cupSize',
'birthday': 'actorBirthday',
'debut': 'actorDebut',
}
for ek, ak in _actor_filter_map.items():
val = extend.get(ek, '')
if val:
api_params[ak] = val
resp = self._api_request('/ht/content/getActors', api_params)
if not resp or resp.get('code') != 10000:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = resp.get('data', {})
vod_list = self._parse_actor_list(data)
total_page = int(data.get('totalPage') or 1)
return {'list': vod_list, 'page': pg, 'pagecount': total_page,
'limit': len(vod_list), 'total': total_page * 20}
# ---- 专题列表 (folder 模式) ----
if tid == 'topic':
resp = self._api_request('/ht/content/getOriTopicList', {
'pageNo': str(pg - 1),
'pageSize': '20',
})
if not resp or resp.get('code') != 10000:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = resp.get('data', {})
vod_list = self._parse_topic_list(data)
return {'list': vod_list, 'page': pg, 'pagecount': 50, 'limit': len(vod_list),
'total': len(vod_list) * 50}
if tid in ('home', 'new', 'hot'):
# 首页/最新/热门 → 使用 queryTypeVideosH5
# homeH5 端点始终返回 20001,改用已验证通的 queryTypeVideosH5
sort_map = {'home': '1', 'new': '1', 'hot': '2'}
resp = self._api_request('/ht/content/queryTypeVideosH5', {
'pageNo': str(pg - 1),
'pageSize': '20',
'sort': sort_map.get(tid, '1'),
'type': '1',
})
if not resp or resp.get('code') != 10000:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = resp.get('data', {})
items = (data.get('typeVideoList') or data.get('list') or data.get('data') or data.get('videoList') or [])
if isinstance(items, list):
for v in items:
parsed = self._parse_video(v)
if parsed:
vod_list.append(parsed)
else:
# 数值分类 (contentId) → queryTypeVideosH5
# T3 通过 extend dict 传递筛选和排序参数
# extend: {'label': '子分类id', 'tag': '标签名', 'sort': '排序值'}
api_params = {
'pageNo': str(pg - 1),
'pageSize': '20',
'typeId': tid, # 按分类过滤(queryTypeVideosH5 → typeId
'type': '1', # 媒体类型 1=视频(home 分支也带,缺少会导致 API 返回默认列表)
}
if isinstance(extend, dict):
for key in ('label', 'tag', 'sort'):
val = extend.get(key, '')
if val:
api_params[key] = val
resp = self._api_request('/ht/content/queryTypeVideosH5', api_params)
if not resp or resp.get('code') != 10000:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = resp.get('data', {})
items = (data.get('typeVideoList') or data.get('list') or data.get('data') or data.get('videoList') or [])
if isinstance(items, list):
for v in items:
parsed = self._parse_video(v)
if parsed:
vod_list.append(parsed)
# 使用 API 返回的真实 totalPagepageSize 固定 20
total_page = int(data.get('totalPage') or 1)
return {
'list': vod_list,
'page': pg,
'pagecount': total_page,
'limit': len(vod_list),
'total': total_page * 20,
}
# ============================================================
# 辅助: 从 data 提取视频列表
# ============================================================
def _extract_videos_from_data(self, data):
"""从响应 data 中提取视频列表(多种格式兼容)"""
# data 可能是 dict 或 list
if isinstance(data, list):
items = data
elif not isinstance(data, dict):
return []
else:
items = (data.get('videoList') or data.get('list') or data.get('data')
or data.get('videos') or data.get('typeVideoList')
or data.get('topicVideoIdList') or data.get('searchList')
or data.get('contentList') or data.get('records')
or data.get('pageData') or [])
if not isinstance(items, list):
return []
return [p for v in items if (p := self._parse_video(v))]
# ============================================================
# 辅助: 从 dict item 中尝试获取字段值(多种命名兼容)
# ============================================================
@staticmethod
def _try_get(item, *keys):
"""依次尝试多个字段名, 返回第一个非空值"""
for k in keys:
v = item.get(k)
if v is not None and v != '':
return v
return ''
# ============================================================
# 解析女优列表(getActors API 响应 → folder list, vod_id + '@'
# ============================================================
def _parse_actor_list(self, data):
"""解析 getActors 返回的演员列表,生成带 @ 后缀的 folder 条目"""
# data 可能是 dict 或 list
if isinstance(data, list):
items = data
elif not isinstance(data, dict):
return []
else:
items = (data.get('actorList') or data.get('actors') or data.get('list')
or data.get('data') or [])
if not isinstance(items, list):
return []
results = []
seen = set()
for item in items:
if not isinstance(item, dict):
continue
actor_id = str(self._try_get(item,
'actorId', 'contentId', 'id', 'artId', 'actor_id', 'userId'))
actor_name = str(self._try_get(item,
'actorName', 'name', 'title', 'artName', 'actor_name', 'actor'))
actor_img = str(self._try_get(item,
'actorPic', 'actorImg', 'img', 'avatar', 'cover',
'imageUrl', 'headImg', 'head', 'photo', 'image', 'pic', 'actor_img'))
actor_count = str(self._try_get(item,
'videoCount', 'contentCount', 'count', 'totalCount',
'total', 'video_count'))
if not actor_id:
continue
if actor_id in seen:
continue
seen.add(actor_id)
# 兜底: 无图时用 favicon 保证 item 可见
if not actor_img:
actor_img = self.host + '/favicon.ico'
remarks = f'{actor_count}' if actor_count else ''
results.append({
'vod_id': 'actor_' + actor_id + '@',
'vod_name': actor_name or ('演员' + actor_id),
'vod_pic': self.get_proxy_image_url(actor_img),
'vod_tag': 'folder',
'vod_remarks': remarks,
})
return results
# ============================================================
# 解析专题列表(getOriTopicList API 响应 → folder list, vod_id + '@'
# ============================================================
def _parse_topic_list(self, data):
"""解析 getOriTopicList 返回的专题列表,生成带 @ 后缀的 folder 条目"""
# data 可能是 dict 或 list
if isinstance(data, list):
items = data
elif not isinstance(data, dict):
return []
else:
items = (data.get('topicList') or data.get('oriTopicList') or data.get('list')
or data.get('data') or data.get('topics') or [])
if not isinstance(items, list):
return []
results = []
seen = set()
for item in items:
if not isinstance(item, dict):
continue
topic_id = str(self._try_get(item,
'topicId', 'id', 'contentId', 'oriTopicId', 'topic_id'))
topic_name = str(self._try_get(item,
'topicName', 'name', 'title', 'oriTopicName', 'topic_name', 'topic'))
topic_img = str(self._try_get(item,
'topicPic', 'topicImg', 'img', 'cover', 'imageUrl', 'pic',
'thumb', 'image', 'topic_img', 'oriTopicImg'))
topic_count = str(self._try_get(item,
'videoCount', 'count', 'contentCount', 'totalCount',
'total', 'video_count'))
if not topic_id:
continue
if topic_id in seen:
continue
seen.add(topic_id)
# 兜底: 无图时用 favicon 保证 item 可见
if not topic_img:
topic_img = self.host + '/favicon.ico'
remarks = f'{topic_count}' if topic_count else ''
results.append({
'vod_id': 'topic_' + topic_id + '@',
'vod_name': topic_name or ('专题' + topic_id),
'vod_pic': self.get_proxy_image_url(topic_img),
'vod_tag': 'folder',
'vod_remarks': remarks,
})
return results
# ============================================================
# 解析视频条目
# ============================================================
def _parse_video(self, item):
# 过滤广告 (contentType=3, 带 jumpScheme 跳转链接)
if item.get('contentType') != 1:
return None
vid = str(item.get('contentId') or item.get('id') or item.get('videoId') or '')
title = item.get('title') or item.get('name') or item.get('videoTitle') or ''
pic = item.get('img') or item.get('cover') or item.get('coverUrl') or item.get('pic') or item.get('imageUrl') or ''
remarks = item.get('duration') or item.get('playCount') or item.get('remark') or ''
# 时长格式化
if remarks and str(remarks).isdigit():
remarks = self._fmt_duration(remarks)
return {
'vod_id': vid,
'vod_name': title,
'vod_pic': self.get_proxy_image_url(pic) if pic else '',
'vod_remarks': str(remarks) if remarks else '',
}
# ============================================================
# 详情页
# ============================================================
def detailContent(self, ids):
did = ids[0] if isinstance(ids, list) else ids
self._select_best_site()
self._ensure_session()
resp = self._api_request('/ht/content/detail', {'contentId': str(did)})
if not resp or resp.get('code') != 10000:
return {'list': []}
detail = resp.get('data', {})
if not detail:
return {'list': []}
# 兼容多种字段名
title = (detail.get('title') or detail.get('name') or
detail.get('videoTitle') or '未知标题')
pic = (detail.get('cover') or detail.get('coverUrl') or
detail.get('img') or detail.get('imageUrl') or '')
desc = detail.get('description') or detail.get('desc') or detail.get('intro') or ''
duration = detail.get('duration', 0)
actor = detail.get('actor') or detail.get('actors') or ''
# 播放地址: videoUrl / playUrl / m3u8
play_url = (detail.get('videoUrl') or detail.get('playUrl') or
detail.get('url') or detail.get('m3u8Url') or
detail.get('sl') or '')
vod_play_url = '播放$' + str(did)
if play_url:
vod_play_url = '播放$' + play_url
return {'list': [{
'vod_id': str(did),
'vod_name': title,
'vod_pic': self.get_proxy_image_url(pic) if pic else '',
'vod_actor': str(actor) if actor else '',
'vod_director': '',
'vod_content': desc,
'vod_year': '',
'vod_area': '',
'vod_remarks': self._fmt_duration(duration),
'vod_play_from': '蜜桃视频',
'vod_play_url': vod_play_url,
'type': 'video',
}]}
# ============================================================
# 搜索
# ============================================================
def searchContent(self, key, quick, pg=1):
self._select_best_site()
self._ensure_session()
pg = int(pg)
resp = self._api_request('/ht/content/search', {
'keywords': key,
'pageNo': pg - 1,
'pageSize': 20,
})
if not resp or resp.get('code') != 10000:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = resp.get('data', {})
# 兼容多种 data 形态:list / dict
if isinstance(data, list):
items = data
total_n = len(data)
elif isinstance(data, dict):
items = (data.get('searchList')
or data.get('list')
or data.get('data')
or data.get('videoList')
or data.get('records')
or data.get('resultList')
or data.get('content')
or [])
total_n = data.get('total') or data.get('totalCount') or data.get('totalNum') or 0
else:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
if not isinstance(items, list):
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
vod_list = [p for v in items if (p := self._parse_video(v))]
total_page = int(data.get('totalPage') or 1) if isinstance(data, dict) else max(1, len(vod_list) // 20)
return {
'list': vod_list,
'page': pg,
'pagecount': total_page,
'limit': len(vod_list),
'total': total_page * 20,
}
# ============================================================
# 播放解析
# ============================================================
def playerContent(self, flag, id, vipFlags=None):
url = id.split('$')[-1]
# 如果已经是完整 URL
if url.startswith('http'):
return {
'parse': 0,
'url': url,
'jx': 0,
'header': {
'User-Agent': self.headers['User-Agent'],
'Referer': self.host + '/',
},
}
# 否则作为 videoId 重新获取
self._select_best_site()
self._ensure_session()
resp = self._api_request('/ht/content/detail', {'contentId': url})
if not resp or resp.get('code') != 10000:
return {'parse': 0, 'url': '', 'jx': 0}
detail = resp.get('data', {})
play_url = (detail.get('videoUrl') or detail.get('playUrl') or
detail.get('url') or detail.get('m3u8Url') or
detail.get('sl') or '')
return {
'parse': 0,
'url': play_url,
'jx': 0,
'header': {
'User-Agent': self.headers['User-Agent'],
'Referer': self.host + '/',
},
}
# ============================================================
# 图片代理
# ============================================================
def localProxy(self, params):
try:
if params.get('type') != PROXY_TYPE:
return [404, 'text/plain', 'not found']
img_url = params.get('url', '')
if not img_url:
return [400, 'text/plain', 'missing url']
img_url = unquote(img_url)
r = requests.get(img_url, headers={
'User-Agent': self.headers['User-Agent'],
'Referer': self.host + '/',
}, timeout=TIMEOUT, verify=False)
if r.status_code != 200:
return [404, 'text/plain', 'image not found']
data = r.content
# 尝试 XOR 0x88 解密 (蜜桃图片防盗链, _xfile.jpg 全部 XOR)
if data[:2] != b'\xff\xd8' and data[:4] != b'\x89PNG' \
and not (data[:4] == b'RIFF' and data[8:12] == b'WEBP'):
decoded = bytes(b ^ 0x88 for b in data)
if decoded[:2] == b'\xff\xd8' or decoded[:4] == b'\x89PNG' \
or (decoded[:4] == b'RIFF' and decoded[8:12] == b'WEBP'):
data = decoded
if data[:2] == b'\xff\xd8':
return [200, 'image/jpeg', data, {'Content-Length': str(len(data))}]
elif data[:4] == b'\x89PNG':
return [200, 'image/png', data, {'Content-Length': str(len(data))}]
elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return [200, 'image/webp', data, {'Content-Length': str(len(data))}]
else:
mime = r.headers.get('Content-Type', 'image/jpeg')
if mime.startswith('image/'):
return [200, mime, data, {'Content-Length': str(len(data))}]
return [404, 'text/plain', 'invalid image format']
except Exception:
return [500, 'text/plain', 'proxy error']