Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-23 16:32:38 +00:00
parent a0c51a5e3e
commit 41a8863644
48 changed files with 11565 additions and 9146 deletions
+275 -86
View File
@@ -1,89 +1,278 @@
"""
@header({
searchable: 1,
filterable: 1,
quickSearch: 1,
title: '新韩剧网',
lang: 'hipy'
})
"""
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# @Author : Doubebly
# @Time : 2025/12/21 14:45
# @file : 新韩剧网.min
import sys
import re
from bs4 import BeautifulSoup
import requests as rq
from urllib.parse import quote
D=print
C=Exception
import re,sys,requests as B
from urllib import parse
from pyquery import PyQuery as F
from Crypto.Cipher import AES as A
from Crypto.Util.Padding import unpad
import base64 as I
sys.path.append('..')
from base.spider import Spider as E
class Spider(E):
def getName(A):return A.name
def init(A,extend='{}'):
A.debug=False;A.name='新韩剧网';A.error_play_url='https://kjjsaas-sh.oss-cn-shanghai.aliyuncs.com/u/3401405881/20240818-936952-fc31b16575e80a7562cdb1f81a39c6b0.mp4';A.home_url='https://www.hanju7.com';A.headers={'User-Agent':'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36','Referer':'https://www.hanju7.com/'};A.extend=extend
def homeContent(E,filter):
G={'class':[{'type_id':'1','type_name':'韩剧'},{'type_id':'3','type_name':'韩国电影'},{'type_id':'4','type_name':'韩国综艺'},{'type_id':'hot','type_name':'排行榜'},{'type_id':'new','type_name':'最新更新'}],'filters':{},'list':[],'parse':0,'jx':0}
try:
H=B.get(E.home_url,headers=E.headers);H.encoding='utf-8';I=F(H.text)
for A in I('div.list ul li').items():G['list'].append({'vod_id':A('a').attr('href'),'vod_name':A('a').attr('title'),'vod_pic':(lambda u:u if u.startswith(('https','http'))else'https:'+u)(A('a').attr('data-original')),'vod_remarks':A('span.tip').text()})
except C as J:D(J)
return G
def categoryContent(I,cid,page,filter,ext):
H=page;G=cid;E={'list':[],'parse':0,'jx':0};H=int(H)
if G in['hot','new']:J=I.home_url+f"/{G}.html"
else:J=I.home_url+f"/list/{G}---{H-1}.html"
try:
K=B.get(J,headers=I.headers);K.encoding='utf-8';L=F(K.text)
if G in['hot','new']:
for A in L('div.txt ul li').items():
M=A('a').attr('href')
if M is None:continue
E['list'].append({'vod_id':A('a').attr('href'),'vod_name':A('a').text(),'vod_pic':'https://youke2.picui.cn/s1/2025/12/21/694796745c0c6.png','vod_remarks':A('#actor').text(),'style':{'type':'list'}})
E['pagecount']=1;E['page']=H
else:
for A in L('div.list ul li').items():E['list'].append({'vod_id':A('a').attr('href'),'vod_name':A('a').attr('title'),'vod_pic':(lambda u:u if u.startswith(('https','http'))else'https:'+u)(A('a').attr('data-original')),'vod_remarks':A('span.tip').text()})
except C as N:D(N)
return E
def detailContent(E,did):
G={'list':[],'parse':0,'jx':0};H=did[0]
try:
I=B.get(E.home_url+H,headers=E.headers);I.encoding='utf-8';A=F(I.text);J=[]
for K in A('div.play ul li').items():L=K('a').text();M=re.search("'(.*?)'",K('a').attr('onclick')).group(1);J.append(f"{L}${M}")
N={'type_name':A('div.detail div.info dl:eq(2) dd').text(),'vod_id':H,'vod_name':A('div.detail div.info dl:eq(0) dd').text(),'vod_remarks':A('div.detail div.info dl:eq(4) dd').text(),'vod_year':A('div.detail div.info dl:eq(5) dd').text(),'vod_area':'','vod_actor':A('div.detail div.info dl:eq(1) dd').text(),'vod_director':'','vod_content':A('div.juqing').text(),'vod_play_from':A('#playlist').text(),'vod_play_url':'#'.join(J)};G['list'].append(N)
except C as O:D(O)
return G
def searchContent(G,key,quick,page='1'):
A={'list':[],'parse':0,'jx':0}
try:
H=G.headers.copy();H['Content-type']='application/x-www-form-urlencoded';I=B.post(G.home_url+'/search/',headers=H,data=f"show=searchkey&keyboard={parse.quote(key)}");I.encoding='utf-8';J=F(I.text)
for E in J('div.txt ul li').items():
K=E('a').attr('href')
if K is None:continue
A['list'].append({'vod_id':E('a').attr('href'),'vod_name':E('a').text(),'vod_pic':'https://youke2.picui.cn/s1/2025/12/21/694796745c0c6.png','vod_remarks':E('#actor').text(),'style':{'type':'list'}})
A['pagecount']=1;A['page']=1
except C as L:D(L)
return A
def playerContent(E,flag,pid,vipFlags):
F={'url':E.error_play_url,'parse':0,'jx':0,'header':{}}
try:
G=B.get(E.home_url+f"/u/u1.php?ud={pid}",headers=E.headers)
if G.ok:J=bytes([109,121,45,116,111,45,110,101,119,104,97,110,45,50,48,50,53,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);H=I.b64decode(G.text);K=H[:16];L=H[16:];M=A.new(J,A.MODE_CBC,K);N=unpad(M.decrypt(L),A.block_size).decode();F['url']=N.strip()
except C as O:D(O)
return F
def homeVideoContent(A):
return {'list':[]}
def isVideoFormat(A,url):
return url.endswith(('.mp4','.m3u8','.flv','.avi','.wmv','.mkv'))
def manualVideoCheck(A):
return False
def localProxy(A,params):
return None
if __name__=='__main__':0
try:
from base.spider import Spider
except ImportError:
class Spider:
def fetch(self, url, headers=None, **kw):
kw.pop('timeout', None)
r = rq.get(url, headers=headers, timeout=15, **kw)
r.encoding = 'utf-8'
return r
HOST = "https://www.jennyhow.com"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
CLASS_MAP = {
"/hxq/1.html": "最新韩剧",
"/hxq/2.html": "韩国电影",
"/hxq/3.html": "韩国综艺",
"/hxq/4.html": "韩国动漫"
}
class Spider(Spider):
def init(self, extend=""):
self._session = rq.Session()
self._session.headers.update({
"User-Agent": UA,
"Referer": HOST
})
def getName(self):
return "韩小圈"
def isVideoFormat(self, url):
return ".m3u8" in url or ".mp4" in url
def manualVideoCheck(self):
return False
def _get(self, url, timeout=10):
try:
r = self._session.get(url, timeout=timeout)
r.encoding = 'utf-8'
return r.text
except Exception as e:
print(f"网络请求失败: {e}")
return ""
def _format_pic(self, pic_url):
if not pic_url: return ""
if pic_url.startswith('//'):
return "https:" + pic_url
if pic_url.startswith('/'):
return HOST + pic_url
return pic_url
def homeContent(self, filter=False):
classes = []
for tid, name in CLASS_MAP.items():
classes.append({"type_id": tid, "type_name": name})
return {"class": classes}
def homeVideoContent(self):
html = self._get(HOST)
soup = BeautifulSoup(html, 'html.parser')
videos = []
items = soup.find_all('div', class_='module-item')
for item in items:
a_tag = item.find('a', class_='module-item-title') or item.find('a')
img_tag = item.find('img')
if a_tag:
name = a_tag.get('title') or a_tag.get_text(strip=True)
href = a_tag.get('href', '')
pic = ""
if img_tag:
pic = img_tag.get('data-src') or img_tag.get('data-original') or img_tag.get('src', '')
videos.append({
"vod_id": href,
"vod_name": name,
"vod_pic": self._format_pic(pic),
"vod_remarks": ""
})
return {"list": videos}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
try:
pn = max(int(str(pg)), 1)
# 拦截缓存
if tid in ["20", "1"]: tid = "/hxq/1.html"
elif tid in ["21", "2"]: tid = "/hxq/2.html"
elif tid in ["22", "3"]: tid = "/hxq/3.html"
elif tid in ["23", "4"]: tid = "/hxq/4.html"
url = tid
if pn > 1 and url.endswith('.html'):
url = url.replace('.html', f'-{pn}.html')
if not url.startswith('http'):
url = HOST + url
html = self._get(url)
soup = BeautifulSoup(html, 'html.parser')
videos = []
for item in soup.find_all('div', class_='module-item'):
a_tag = item.find('a', class_='module-item-pic') or item.find('a')
if not a_tag: continue
img_tag = item.find('img')
name = a_tag.get('title')
if not name and img_tag: name = img_tag.get('alt')
if not name: name = a_tag.get_text(strip=True)
href = a_tag.get('href', '')
pic = ""
if img_tag:
pic = img_tag.get('data-src') or img_tag.get('data-original') or img_tag.get('src', '')
remarks_tag = item.find(class_='module-item-text') or item.find(class_='module-item-note')
remarks = remarks_tag.get_text(strip=True) if remarks_tag else ""
if href:
videos.append({
"vod_id": href,
"vod_name": name,
"vod_pic": self._format_pic(pic),
"vod_remarks": remarks
})
pagecount = pn + 1 if len(videos) > 0 else pn
return {"list": videos, "page": pn, "pagecount": pagecount, "limit": 24, "total": 0}
except:
return {"list": [], "page": pg}
# ================= 详情页大升级 =================
def detailContent(self, ids):
detail_url = ids[0] if ids[0].startswith('http') else HOST + ids[0]
html = self._get(detail_url)
if not html: return {"list": []}
soup = BeautifulSoup(html, 'html.parser')
title_tag = soup.find('h1')
title = title_tag.get_text(strip=True) if title_tag else "未知名称"
pic_tag = soup.find('img', class_='lazyload') or soup.find('img', class_='lazy')
pic = ""
if pic_tag:
pic = pic_tag.get('data-src') or pic_tag.get('data-original') or pic_tag.get('src', '')
# --- 新增:智能文本猎手,自动抓取导演/主演/剧情等信息 ---
vod_director, vod_actor, vod_year, vod_content = "", "", "", ""
# 遍历所有文本节点寻找关键词
for tag in soup.find_all(text=re.compile(r'导演|主演|上映|年份|剧情|简介')):
text_str = tag.strip()
parent = tag.parent
# 过滤掉系统标签
if parent.name in ['title', 'meta', 'script', 'style']: continue
# 往上找包裹着文字的容器
container = parent.parent if parent.name in ['span', 'strong', 'b', 'font'] else parent
full_text = container.get_text(separator=' ', strip=True)
if '导演' in text_str and not vod_director:
vod_director = re.sub(r'.*?导演[:]?\s*', '', full_text)
elif '主演' in text_str and not vod_actor:
vod_actor = re.sub(r'.*?主演[:]?\s*', '', full_text)
elif ('上映' in text_str or '年份' in text_str) and not vod_year:
vod_year = re.sub(r'.*?(上映|年份)[:]?\s*', '', full_text)
elif ('剧情' in text_str or '简介' in text_str) and not vod_content:
vod_content = re.sub(r'.*?(剧情|简介)[:]?\s*', '', full_text)
# 简介兜底:有的网站把简介放进了一个很隐蔽的 class 里
if not vod_content:
intro_tag = soup.find(class_='module-info-introduction-content') or soup.find(class_='module-info-introduction')
if intro_tag:
vod_content = intro_tag.get_text(strip=True)
# 抓取播放列表
play_from = []
play_url = []
tabs_ul = soup.find('ul', class_='nav-tabs')
if tabs_ul:
for li in tabs_ul.find_all('li'):
a_tag = li.find('a')
if not a_tag: continue
line_name = a_tag.get_text(strip=True)
target_id = a_tag.get('href', '').replace('#', '')
playlist_div = soup.find('div', id=target_id)
if playlist_div:
episodes = []
for ep in playlist_div.find_all('a'):
ep_name = ep.get('title') or ep.get_text(strip=True)
ep_href = ep.get('href', '')
if ep_href:
episodes.append(f"{ep_name}${ep_href}")
if episodes:
play_from.append(line_name)
play_url.append("#".join(episodes))
vod = {
"vod_id": ids[0],
"vod_name": title,
"vod_pic": self._format_pic(pic),
"vod_director": vod_director, # 🌟 给 APP 喂进去导演
"vod_actor": vod_actor, # 🌟 给 APP 喂进去主演
"vod_year": vod_year, # 🌟 给 APP 喂进去年份
"vod_content": vod_content, # 🌟 给 APP 喂进去简介
"vod_play_from": "$$$".join(play_from),
"vod_play_url": "$$$".join(play_url),
}
return {"list": [vod]}
def playerContent(self, flag, id, vipFlags=None):
play_url = id if id.startswith('http') else HOST + id
html = self._get(play_url)
if not html: return {"url": ""}
match = re.search(r'var now=[\'"](.*?)[\'"];', html)
if match:
m3u8_url = match.group(1)
return {
"url": m3u8_url,
"header": {"User-Agent": UA}
}
match_json = re.search(r'player_aaaa\s*=\s*(\{[^}]+\})', html)
if match_json:
import json
try:
data = json.loads(match_json.group(1))
return {"url": data.get("url", ""), "header": {"User-Agent": UA}}
except:
pass
return {"url": ""}
def searchContent(self, key, quick=False, pg=1):
try:
url = f"{HOST}/vodsearch/{quote(key)}----------{pg}---.html"
html = self._get(url)
soup = BeautifulSoup(html, 'html.parser')
videos = []
for item in soup.find_all('div', class_='module-search-item') or soup.find_all('div', class_='module-item'):
a_tag = item.find('a')
img_tag = item.find('img')
if a_tag and img_tag:
name = img_tag.get('alt', '') or a_tag.get('title', '')
href = a_tag.get('href', '')
pic = img_tag.get('data-src') or img_tag.get('data-original') or img_tag.get('src', '')
videos.append({
"vod_id": href,
"vod_name": name,
"vod_pic": self._format_pic(pic)
})
return {"list": videos}
except:
return {"list": []}
def localProxy(self, param):
pass
+89
View File
@@ -0,0 +1,89 @@
"""
@header({
searchable: 1,
filterable: 1,
quickSearch: 1,
title: '新韩剧网',
lang: 'hipy'
})
"""
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# @Author : Doubebly
# @Time : 2025/12/21 14:45
# @file : 新韩剧网.min
D=print
C=Exception
import re,sys,requests as B
from urllib import parse
from pyquery import PyQuery as F
from Crypto.Cipher import AES as A
from Crypto.Util.Padding import unpad
import base64 as I
sys.path.append('..')
from base.spider import Spider as E
class Spider(E):
def getName(A):return A.name
def init(A,extend='{}'):
A.debug=False;A.name='新韩剧网';A.error_play_url='https://kjjsaas-sh.oss-cn-shanghai.aliyuncs.com/u/3401405881/20240818-936952-fc31b16575e80a7562cdb1f81a39c6b0.mp4';A.home_url='https://www.hanju7.com';A.headers={'User-Agent':'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36','Referer':'https://www.hanju7.com/'};A.extend=extend
def homeContent(E,filter):
G={'class':[{'type_id':'1','type_name':'韩剧'},{'type_id':'3','type_name':'韩国电影'},{'type_id':'4','type_name':'韩国综艺'},{'type_id':'hot','type_name':'排行榜'},{'type_id':'new','type_name':'最新更新'}],'filters':{},'list':[],'parse':0,'jx':0}
try:
H=B.get(E.home_url,headers=E.headers);H.encoding='utf-8';I=F(H.text)
for A in I('div.list ul li').items():G['list'].append({'vod_id':A('a').attr('href'),'vod_name':A('a').attr('title'),'vod_pic':(lambda u:u if u.startswith(('https','http'))else'https:'+u)(A('a').attr('data-original')),'vod_remarks':A('span.tip').text()})
except C as J:D(J)
return G
def categoryContent(I,cid,page,filter,ext):
H=page;G=cid;E={'list':[],'parse':0,'jx':0};H=int(H)
if G in['hot','new']:J=I.home_url+f"/{G}.html"
else:J=I.home_url+f"/list/{G}---{H-1}.html"
try:
K=B.get(J,headers=I.headers);K.encoding='utf-8';L=F(K.text)
if G in['hot','new']:
for A in L('div.txt ul li').items():
M=A('a').attr('href')
if M is None:continue
E['list'].append({'vod_id':A('a').attr('href'),'vod_name':A('a').text(),'vod_pic':'https://youke2.picui.cn/s1/2025/12/21/694796745c0c6.png','vod_remarks':A('#actor').text(),'style':{'type':'list'}})
E['pagecount']=1;E['page']=H
else:
for A in L('div.list ul li').items():E['list'].append({'vod_id':A('a').attr('href'),'vod_name':A('a').attr('title'),'vod_pic':(lambda u:u if u.startswith(('https','http'))else'https:'+u)(A('a').attr('data-original')),'vod_remarks':A('span.tip').text()})
except C as N:D(N)
return E
def detailContent(E,did):
G={'list':[],'parse':0,'jx':0};H=did[0]
try:
I=B.get(E.home_url+H,headers=E.headers);I.encoding='utf-8';A=F(I.text);J=[]
for K in A('div.play ul li').items():L=K('a').text();M=re.search("'(.*?)'",K('a').attr('onclick')).group(1);J.append(f"{L}${M}")
N={'type_name':A('div.detail div.info dl:eq(2) dd').text(),'vod_id':H,'vod_name':A('div.detail div.info dl:eq(0) dd').text(),'vod_remarks':A('div.detail div.info dl:eq(4) dd').text(),'vod_year':A('div.detail div.info dl:eq(5) dd').text(),'vod_area':'','vod_actor':A('div.detail div.info dl:eq(1) dd').text(),'vod_director':'','vod_content':A('div.juqing').text(),'vod_play_from':A('#playlist').text(),'vod_play_url':'#'.join(J)};G['list'].append(N)
except C as O:D(O)
return G
def searchContent(G,key,quick,page='1'):
A={'list':[],'parse':0,'jx':0}
try:
H=G.headers.copy();H['Content-type']='application/x-www-form-urlencoded';I=B.post(G.home_url+'/search/',headers=H,data=f"show=searchkey&keyboard={parse.quote(key)}");I.encoding='utf-8';J=F(I.text)
for E in J('div.txt ul li').items():
K=E('a').attr('href')
if K is None:continue
A['list'].append({'vod_id':E('a').attr('href'),'vod_name':E('a').text(),'vod_pic':'https://youke2.picui.cn/s1/2025/12/21/694796745c0c6.png','vod_remarks':E('#actor').text(),'style':{'type':'list'}})
A['pagecount']=1;A['page']=1
except C as L:D(L)
return A
def playerContent(E,flag,pid,vipFlags):
F={'url':E.error_play_url,'parse':0,'jx':0,'header':{}}
try:
G=B.get(E.home_url+f"/u/u1.php?ud={pid}",headers=E.headers)
if G.ok:J=bytes([109,121,45,116,111,45,110,101,119,104,97,110,45,50,48,50,53,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);H=I.b64decode(G.text);K=H[:16];L=H[16:];M=A.new(J,A.MODE_CBC,K);N=unpad(M.decrypt(L),A.block_size).decode();F['url']=N.strip()
except C as O:D(O)
return F
def homeVideoContent(A):
return {'list':[]}
def isVideoFormat(A,url):
return url.endswith(('.mp4','.m3u8','.flv','.avi','.wmv','.mkv'))
def manualVideoCheck(A):
return False
def localProxy(A,params):
return None
if __name__=='__main__':0
+276 -324
View File
@@ -1,324 +1,276 @@
# coding=utf-8
# !/usr/bin/python
"""
啪啪视频 T3 爬虫源
站点: 4.pp795pp.cc:88
"""
import sys
sys.path.append('..')
from base.spider import BaseSpider
from base.htmlParser import jsoup
import requests
import re
import html as _html
import base64
from urllib.parse import quote, unquote
# ============================================================
# 全局配置
# ============================================================
TIMEOUT = 15
HOST = 'https://4.pp795pp.cc:88'
PROXY_TYPE = 'pp795_img'
class Spider(BaseSpider):
# ---- 基础信息 ----
def getName(self):
return "啪啪视频"
def isVideoFormat(self, url):
return bool(url) and '.m3u8' in url
def manualVideoCheck(self):
return False
def init(self, extend=""):
self._proxy_prefix = ''
# ---- 类变量 ----
filterable = True
searchable = True
host = HOST
_proxy_prefix = ''
session = requests.Session()
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": HOST + '/',
}
# ============================================================
# HTML 解码 (页面被 decodeURIComponent 包裹)
# ============================================================
def _decode_html(self, raw):
if not raw:
return ''
try:
decoded = unquote(raw)
except Exception:
decoded = raw
if '<html' in decoded.lower() or '<body' in decoded.lower():
# 二次解码:HTML 属性值可能仍有 URL 编码 (如 %3D → =, %22 → ")
try:
return unquote(decoded)
except Exception:
return decoded
return raw
# ============================================================
# 网络请求
# ============================================================
def _fetch(self, url):
try:
r = self.fetch(url, headers=self.headers, timeout=TIMEOUT, verify=False)
return self._decode_html(r.text)
except Exception:
return ''
# ============================================================
# 图片代理
# ============================================================
def _ensure_proxy_prefix(self):
if self._proxy_prefix:
return
base = self.getProxyUrl() or 'http://127.0.0.1:9980/proxy?do=py'
self._proxy_prefix = base + '&type=' + PROXY_TYPE + '&url='
def _proxy_img(self, url):
if not url:
return ''
self._ensure_proxy_prefix()
return self._proxy_prefix + quote(url, safe='')
# ============================================================
# 视频列表解析
# ============================================================
def _parse_video_list(self, html):
if not html:
return []
jsp = jsoup(self.host)
items = jsp.pdfa(html, '.vod-item')
results = []
for item in items:
href = jsp.pdfh(item, 'div&&to') or jsp.pdfh(item, 'a&&to')
if not href:
to_match = re.search(r'to=["\'](/play/[^"\']+)', item)
href = to_match.group(1) if to_match else ''
if not href or '/play/' not in href:
continue
vid = href.replace('/play/', '')
title = _html.unescape(jsp.pdfh(item, '.rank-title&&Text') or '')
pic = jsp.pdfh(item, 'img&&data-original') or jsp.pdfh(item, 'img&&src')
# 时长
dur = ''
dur_match = re.search(r'secondsToHMS\((\d+)\)', item)
if dur_match:
s = int(dur_match.group(1))
dur = f'{s // 60:02d}:{s % 60:02d}'
# 热度
hits = jsp.pdfh(item, '.pre-hits span&&Text') or ''
results.append({
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_img(pic),
'vod_remarks': dur or hits,
})
return results
def _get_pagecount(self, html):
m = re.search(r'var\s+total\s*=\s*parseInt\((\d+)\)', html)
if m:
return int(m.group(1))
m = re.search(r'/ (\d+)</span>', html)
if m:
return int(m.group(1))
return 1
# ============================================================
# 首页
# ============================================================
def homeContent(self, filter):
html = self._fetch(self.host)
if not html:
return {'class': [], 'type': '影视'}
jsp = jsoup(self.host)
classes = []
seen_names = set()
skip_tids = {'28', '29'} # 其他综艺、成人游戏
for span in jsp.pdfa(html, '.v-s-li-nav-link-vs.a-link'):
href = jsp.pdfh(span, 'span&&to')
name = _html.unescape(jsp.pdfh(span, 'span&&Text') or '')
if href and name and '/type/' in href and name not in seen_names:
seen_names.add(name)
tid = href.split('/type/')[1].strip('/')
if tid in skip_tids:
continue
classes.append({'type_name': name, 'type_id': tid})
# 首页推荐列表
home_list = self._parse_video_list(html)
return {'class': classes, 'list': home_list, 'type': '影视'}
def homeVideoContent(self, tid, pg, filter, extend):
pg = int(pg)
url = self.host if pg <= 1 else f'{self.host}/page/{pg}'
html = self._fetch(url)
if not html:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = self._parse_video_list(html)
pagecount = self._get_pagecount(html)
return {'list': data, 'page': pg, 'pagecount': pagecount,
'limit': len(data), 'total': pagecount * len(data)}
# ============================================================
# 分类列表
# ============================================================
def categoryContent(self, tid, pg, filter, extend):
pg = int(pg)
url = f'{self.host}/type/{tid}' if pg <= 1 else f'{self.host}/type/{tid}/{pg}'
html = self._fetch(url)
if not html:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = self._parse_video_list(html)
pagecount = self._get_pagecount(html)
return {'list': data, 'page': pg, 'pagecount': pagecount,
'limit': len(data), 'total': pagecount * len(data)}
# ============================================================
# 详情页
# ============================================================
def detailContent(self, ids):
did = ids[0] if isinstance(ids, list) else ids
url = f'{self.host}/play/{did}'
html = self._fetch(url)
if not html:
return {'list': []}
jsp = jsoup(self.host)
# 标题
title = _html.unescape(jsp.pdfh(html, '.video-title&&Text') or '')
# M3U8 地址 (页面内嵌 JS 变量)
m3u8 = ''
m = re.search(r'var\s+url\s*=\s*["\']([^"\']+\.m3u8[^"\']*)', html)
if m:
m3u8 = m.group(1)
play_url = f'播放${m3u8}' if m3u8 else ''
return {'list': [{
'vod_id': did,
'vod_name': title or did,
'vod_pic': '',
'vod_actor': '',
'vod_director': '',
'vod_content': '',
'vod_year': '',
'vod_area': '',
'vod_remarks': '',
'vod_play_from': '啪啪视频',
'vod_play_url': play_url,
'type': 'video',
}]}
# ============================================================
# 搜索
# ============================================================
def searchContent(self, key, quick, pg=1):
pg = int(pg)
encoded = quote(key, safe='')
url = f'{self.host}/search/{encoded}' if pg <= 1 else f'{self.host}/search/{encoded}/{pg}'
html = self._fetch(url)
if not html:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = self._parse_video_list(html)
pagecount = self._get_pagecount(html)
return {'list': data, 'page': pg, 'pagecount': pagecount,
'limit': len(data), 'total': pagecount * len(data)}
# ============================================================
# 播放解析
# ============================================================
def playerContent(self, flag, id, vipFlags=None):
url = id
# 如果 id 不含 m3u8,可能是 vod_id,从详情页重新取
if not url or '.m3u8' not in url:
detail_url = f'{self.host}/play/{id}'
html = self._fetch(detail_url)
m = re.search(r'var\s+url\s*=\s*["\']([^"\']+\.m3u8[^"\']*)', html) if html else None
url = m.group(1) if m else ''
if not url:
return {'parse': 1, 'url': '', 'jx': 0}
try:
r = requests.head(url, headers=self.headers, timeout=TIMEOUT,
verify=False, allow_redirects=True)
final_url = r.url
except Exception:
final_url = url
return {'parse': 0, 'url': final_url, 'jx': 0,
'header': {'Referer': self.host + '/'}}
# ============================================================
# 图片代理
# ============================================================
def _detect_mime(self, data):
"""根据 magic bytes 检测图片 MIME 类型"""
if data[:2] == b'\xff\xd8':
return 'image/jpeg'
elif data[:4] == b'\x89PNG':
return 'image/png'
elif data[:4] == b'RIFF' and len(data) > 12 and data[8:12] == b'WEBP':
return 'image/webp'
return 'image/jpeg' # 默认
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)
headers = dict(self.headers)
headers['Referer'] = self.host + '/'
is_dat = img_url.lower().endswith('.dat')
if is_dat:
# .dat 文件的响应体是 base64 字符串,解码后得到实际图片二进制
r = requests.get(img_url, headers=headers, timeout=TIMEOUT, verify=False)
if r.status_code != 200:
return [404, 'text/plain', 'image not found']
try:
b64_text = "".join(r.text.split())
data = base64.b64decode(b64_text)
mime = self._detect_mime(data)
return [200, mime, data, {'Content-Length': str(len(data))}]
except Exception:
return [404, 'text/plain', 'decode error']
else:
r = requests.get(img_url, headers=headers, timeout=TIMEOUT, verify=False)
if r.status_code != 200:
return [404, 'text/plain', 'image not found']
data = r.content
mime = r.headers.get('Content-Type', 'image/jpeg')
if not mime.startswith('image/'):
mime = self._detect_mime(data)
return [200, mime, data, {'Content-Length': str(len(data))}]
except Exception:
return [500, 'text/plain', 'proxy error']
# -*- coding: utf-8 -*-
# 爬虫源: 怦然心动 (prshinezenx.blog)
# 站点类型: SPA + 服务端渲染,数据通过 Base64 编码嵌入 HTML
# 开发者: AI Assistant
# 日期: 2026-07-22
import re
import json
import base64
from urllib.parse import urljoin, quote
from base.spider import Spider as BaseSpider
class Spider(BaseSpider):
def __init__(self):
self.host = "https://prshinezenx.blog"
self.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",
"Referer": self.host + "/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9"
}
# 分类列表(从首页提取,本地硬编码保证首页秒出)
self.classes = [
{"type_id": "43", "type_name": "国产精选"},
{"type_id": "31", "type_name": "束缚玩法"},
{"type_id": "35", "type_name": "中字专区"},
{"type_id": "33", "type_name": "女优精选"},
{"type_id": "53", "type_name": "传媒拍摄"},
{"type_id": "29", "type_name": "变性纪实"},
{"type_id": "21", "type_name": "同志日常"},
{"type_id": "23", "type_name": "百合情境"},
{"type_id": "39", "type_name": "欧美精选"},
{"type_id": "45", "type_name": "虚拟换脸"},
{"type_id": "47", "type_name": "少女幻想"},
{"type_id": "49", "type_name": "主播日记"},
{"type_id": "51", "type_name": "约会实录"},
{"type_id": "55", "type_name": "伦理剧场"},
{"type_id": "57", "type_name": "黑料档案"},
{"type_id": "63", "type_name": "自拍实录"},
]
# 无筛选功能
self.filters = {}
def getName(self):
return "怦然心动"
def getDependence(self):
return []
def init(self, extend=""):
"""初始化,零网络"""
pass
def _fetch(self, url):
"""请求页面,返回 HTML 文本"""
try:
rsp = self.fetch(url, headers=self.headers, timeout=15000)
if rsp and hasattr(rsp, 'text'):
return rsp.text
return None
except Exception:
return None
def _extract_vod_data(self, html):
"""从 HTML 中提取 window.__vod_data__ 的 Base64 数据并解码"""
if not html:
return None
pattern = r"const binaryStr = atob\('([^']+)'\)"
match = re.search(pattern, html)
if not match:
return None
base64_str = match.group(1)
try:
json_str = base64.b64decode(base64_str).decode('utf-8')
return json.loads(json_str)
except Exception:
return None
def _parse_list(self, items):
"""解析视频列表项,打包数据到 vod_id 以便详情页快速展示"""
if not items:
return []
result = []
for item in items:
vod_id = str(item.get("vod_id", ""))
if not vod_id:
continue
vod_name = item.get("vod_name", "未知标题")
vod_pic = item.get("vod_pic", "")
if vod_pic and not vod_pic.startswith("http"):
vod_pic = urljoin(self.host, vod_pic)
vod_remark = item.get("vod_duration", "")
type_id = str(item.get("type_id", ""))
# 打包数据到 vod_id,方便详情页快速返回
packed_id = f"{vod_id}|$|{vod_name}|$|{vod_pic}|$|{vod_remark}|$|{type_id}"
result.append({
"vod_id": packed_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remark,
})
return result
def homeContent(self, filter=False):
"""首页:返回分类列表,零网络"""
return {
"class": self.classes,
"filters": self.filters if filter else {}
}
def getHomeContent(self, filter=False):
return self.homeContent(filter)
def homeVideoContent(self):
"""首页推荐视频"""
html = self._fetch(self.host + "/")
if not html:
return {"list": []}
data = self._extract_vod_data(html)
if not data:
return {"list": []}
items = data.get("other_request_data", {}).get("random_list", [])
if not items:
items = data.get("request_data", {}).get("list", [])
return {"list": self._parse_list(items[:20])}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
"""分类列表页"""
page = pg or 1
url = f"{self.host}/vodlist/type/{tid}/keyword/all/orderby/default/page/{page}.html"
html = self._fetch(url)
if not html:
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
data = self._extract_vod_data(html)
if not data:
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
items = data.get("request_data", {}).get("list", [])
total = data.get("request_data", {}).get("total", 0)
limit = data.get("limit", 20)
total_pages = (total + limit - 1) // limit if total > 0 else 1
return {
"list": self._parse_list(items),
"page": page,
"pagecount": total_pages,
"limit": limit,
"total": total
}
def detailContent(self, ids):
"""视频详情 - 从打包的 vod_id 中解析数据,快速返回"""
if not ids:
return {"list": []}
raw = str(ids[0])
# 解析打包的数据: vod_id|$|vod_name|$|vod_pic|$|vod_remark|$|type_id
parts = raw.split("|$|")
if len(parts) >= 5:
vod_id = parts[0]
vod_name = parts[1] if len(parts) > 1 else "未知标题"
vod_pic = parts[2] if len(parts) > 2 else ""
vod_remark = parts[3] if len(parts) > 3 else ""
type_id = parts[4] if len(parts) > 4 else ""
else:
# 兼容旧格式:直接传数字ID
vod_id = raw
vod_name = ""
vod_pic = ""
vod_remark = ""
type_id = ""
# 获取播放地址:请求详情页提取 vod_play_url
if type_id:
detail_url = f"{self.host}/voddetail/type/{type_id}/id/{vod_id}.html"
else:
detail_url = f"{self.host}/voddetail/type/all/id/{vod_id}.html"
html = self._fetch(detail_url)
play_page = ""
if html:
data = self._extract_vod_data(html)
if data:
vod_info = data.get("vod_info", {})
play_page = vod_info.get("vod_play_url", "")
if not vod_name:
vod_name = vod_info.get("vod_name", "未知标题")
if not vod_pic:
vod_pic = vod_info.get("vod_pic", "")
if not vod_remark:
vod_remark = vod_info.get("vod_duration", "")
if not play_page:
return {
"list": [{
"vod_id": vod_id,
"vod_name": vod_name or "未知标题",
"vod_pic": vod_pic,
"vod_remarks": vod_remark,
"vod_content": "",
"vod_play_from": "播放",
"vod_play_url": ""
}]
}
# 如果是 ao jie xi 包装,提取真实 m3u8
if "aojiexi.com" in play_page:
match = re.search(r'url=([^&]+)', play_page)
if match:
real_url = match.group(1)
real_url = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), real_url)
play_page = real_url
# 构造播放数据:单线路单集
# 格式参考 tmcrownxlift 成功案例
return {
"list": [{
"vod_id": vod_id,
"vod_name": vod_name or "未知标题",
"vod_pic": vod_pic,
"vod_remarks": vod_remark,
"vod_content": "",
"vod_play_from": "播放",
"vod_play_url": "播放$" + play_page
}]
}
def searchContent(self, key, quick=False, pg="1"):
"""搜索"""
if not key:
return {"list": []}
page = pg or 1
url = f"{self.host}/vodlist/type/all/keyword/{quote(key)}/orderby/default/page/{page}.html"
html = self._fetch(url)
if not html:
return {"list": []}
data = self._extract_vod_data(html)
if not data:
return {"list": []}
items = data.get("request_data", {}).get("list", [])
return {"list": self._parse_list(items)}
def playerContent(self, flag, vid, vipFlags=None):
"""播放地址解析"""
if not vid:
return {"parse": 0, "url": ""}
if vid.endswith((".m3u8", ".mp4")):
return {"parse": 0, "url": vid, "header": self.headers}
if "aojiexi.com" in vid:
match = re.search(r'url=([^&]+)', vid)
if match:
real_url = match.group(1)
real_url = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), real_url)
return {"parse": 0, "url": real_url, "header": self.headers}
if vid.startswith("http"):
html = self._fetch(vid)
if html:
match = re.search(r'https?://[^\s"\']+\.m3u8[^\s"\']*', html)
if match:
return {"parse": 0, "url": match.group(0), "header": self.headers}
return {"parse": 1, "url": vid}
def isVideoFormat(self, url):
if not url:
return False
return url.endswith((".m3u8", ".mp4", ".m3u8?"))
def destroy(self):
pass
+324
View File
@@ -0,0 +1,324 @@
# coding=utf-8
# !/usr/bin/python
"""
啪啪视频 T3 爬虫源
站点: 4.pp795pp.cc:88
"""
import sys
sys.path.append('..')
from base.spider import BaseSpider
from base.htmlParser import jsoup
import requests
import re
import html as _html
import base64
from urllib.parse import quote, unquote
# ============================================================
# 全局配置
# ============================================================
TIMEOUT = 15
HOST = 'https://4.pp795pp.cc:88'
PROXY_TYPE = 'pp795_img'
class Spider(BaseSpider):
# ---- 基础信息 ----
def getName(self):
return "啪啪视频"
def isVideoFormat(self, url):
return bool(url) and '.m3u8' in url
def manualVideoCheck(self):
return False
def init(self, extend=""):
self._proxy_prefix = ''
# ---- 类变量 ----
filterable = True
searchable = True
host = HOST
_proxy_prefix = ''
session = requests.Session()
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": HOST + '/',
}
# ============================================================
# HTML 解码 (页面被 decodeURIComponent 包裹)
# ============================================================
def _decode_html(self, raw):
if not raw:
return ''
try:
decoded = unquote(raw)
except Exception:
decoded = raw
if '<html' in decoded.lower() or '<body' in decoded.lower():
# 二次解码:HTML 属性值可能仍有 URL 编码 (如 %3D → =, %22 → ")
try:
return unquote(decoded)
except Exception:
return decoded
return raw
# ============================================================
# 网络请求
# ============================================================
def _fetch(self, url):
try:
r = self.fetch(url, headers=self.headers, timeout=TIMEOUT, verify=False)
return self._decode_html(r.text)
except Exception:
return ''
# ============================================================
# 图片代理
# ============================================================
def _ensure_proxy_prefix(self):
if self._proxy_prefix:
return
base = self.getProxyUrl() or 'http://127.0.0.1:9980/proxy?do=py'
self._proxy_prefix = base + '&type=' + PROXY_TYPE + '&url='
def _proxy_img(self, url):
if not url:
return ''
self._ensure_proxy_prefix()
return self._proxy_prefix + quote(url, safe='')
# ============================================================
# 视频列表解析
# ============================================================
def _parse_video_list(self, html):
if not html:
return []
jsp = jsoup(self.host)
items = jsp.pdfa(html, '.vod-item')
results = []
for item in items:
href = jsp.pdfh(item, 'div&&to') or jsp.pdfh(item, 'a&&to')
if not href:
to_match = re.search(r'to=["\'](/play/[^"\']+)', item)
href = to_match.group(1) if to_match else ''
if not href or '/play/' not in href:
continue
vid = href.replace('/play/', '')
title = _html.unescape(jsp.pdfh(item, '.rank-title&&Text') or '')
pic = jsp.pdfh(item, 'img&&data-original') or jsp.pdfh(item, 'img&&src')
# 时长
dur = ''
dur_match = re.search(r'secondsToHMS\((\d+)\)', item)
if dur_match:
s = int(dur_match.group(1))
dur = f'{s // 60:02d}:{s % 60:02d}'
# 热度
hits = jsp.pdfh(item, '.pre-hits span&&Text') or ''
results.append({
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_img(pic),
'vod_remarks': dur or hits,
})
return results
def _get_pagecount(self, html):
m = re.search(r'var\s+total\s*=\s*parseInt\((\d+)\)', html)
if m:
return int(m.group(1))
m = re.search(r'/ (\d+)</span>', html)
if m:
return int(m.group(1))
return 1
# ============================================================
# 首页
# ============================================================
def homeContent(self, filter):
html = self._fetch(self.host)
if not html:
return {'class': [], 'type': '影视'}
jsp = jsoup(self.host)
classes = []
seen_names = set()
skip_tids = {'28', '29'} # 其他综艺、成人游戏
for span in jsp.pdfa(html, '.v-s-li-nav-link-vs.a-link'):
href = jsp.pdfh(span, 'span&&to')
name = _html.unescape(jsp.pdfh(span, 'span&&Text') or '')
if href and name and '/type/' in href and name not in seen_names:
seen_names.add(name)
tid = href.split('/type/')[1].strip('/')
if tid in skip_tids:
continue
classes.append({'type_name': name, 'type_id': tid})
# 首页推荐列表
home_list = self._parse_video_list(html)
return {'class': classes, 'list': home_list, 'type': '影视'}
def homeVideoContent(self, tid, pg, filter, extend):
pg = int(pg)
url = self.host if pg <= 1 else f'{self.host}/page/{pg}'
html = self._fetch(url)
if not html:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = self._parse_video_list(html)
pagecount = self._get_pagecount(html)
return {'list': data, 'page': pg, 'pagecount': pagecount,
'limit': len(data), 'total': pagecount * len(data)}
# ============================================================
# 分类列表
# ============================================================
def categoryContent(self, tid, pg, filter, extend):
pg = int(pg)
url = f'{self.host}/type/{tid}' if pg <= 1 else f'{self.host}/type/{tid}/{pg}'
html = self._fetch(url)
if not html:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = self._parse_video_list(html)
pagecount = self._get_pagecount(html)
return {'list': data, 'page': pg, 'pagecount': pagecount,
'limit': len(data), 'total': pagecount * len(data)}
# ============================================================
# 详情页
# ============================================================
def detailContent(self, ids):
did = ids[0] if isinstance(ids, list) else ids
url = f'{self.host}/play/{did}'
html = self._fetch(url)
if not html:
return {'list': []}
jsp = jsoup(self.host)
# 标题
title = _html.unescape(jsp.pdfh(html, '.video-title&&Text') or '')
# M3U8 地址 (页面内嵌 JS 变量)
m3u8 = ''
m = re.search(r'var\s+url\s*=\s*["\']([^"\']+\.m3u8[^"\']*)', html)
if m:
m3u8 = m.group(1)
play_url = f'播放${m3u8}' if m3u8 else ''
return {'list': [{
'vod_id': did,
'vod_name': title or did,
'vod_pic': '',
'vod_actor': '',
'vod_director': '',
'vod_content': '',
'vod_year': '',
'vod_area': '',
'vod_remarks': '',
'vod_play_from': '啪啪视频',
'vod_play_url': play_url,
'type': 'video',
}]}
# ============================================================
# 搜索
# ============================================================
def searchContent(self, key, quick, pg=1):
pg = int(pg)
encoded = quote(key, safe='')
url = f'{self.host}/search/{encoded}' if pg <= 1 else f'{self.host}/search/{encoded}/{pg}'
html = self._fetch(url)
if not html:
return {'list': [], 'page': pg, 'pagecount': 1, 'limit': 0, 'total': 0}
data = self._parse_video_list(html)
pagecount = self._get_pagecount(html)
return {'list': data, 'page': pg, 'pagecount': pagecount,
'limit': len(data), 'total': pagecount * len(data)}
# ============================================================
# 播放解析
# ============================================================
def playerContent(self, flag, id, vipFlags=None):
url = id
# 如果 id 不含 m3u8,可能是 vod_id,从详情页重新取
if not url or '.m3u8' not in url:
detail_url = f'{self.host}/play/{id}'
html = self._fetch(detail_url)
m = re.search(r'var\s+url\s*=\s*["\']([^"\']+\.m3u8[^"\']*)', html) if html else None
url = m.group(1) if m else ''
if not url:
return {'parse': 1, 'url': '', 'jx': 0}
try:
r = requests.head(url, headers=self.headers, timeout=TIMEOUT,
verify=False, allow_redirects=True)
final_url = r.url
except Exception:
final_url = url
return {'parse': 0, 'url': final_url, 'jx': 0,
'header': {'Referer': self.host + '/'}}
# ============================================================
# 图片代理
# ============================================================
def _detect_mime(self, data):
"""根据 magic bytes 检测图片 MIME 类型"""
if data[:2] == b'\xff\xd8':
return 'image/jpeg'
elif data[:4] == b'\x89PNG':
return 'image/png'
elif data[:4] == b'RIFF' and len(data) > 12 and data[8:12] == b'WEBP':
return 'image/webp'
return 'image/jpeg' # 默认
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)
headers = dict(self.headers)
headers['Referer'] = self.host + '/'
is_dat = img_url.lower().endswith('.dat')
if is_dat:
# .dat 文件的响应体是 base64 字符串,解码后得到实际图片二进制
r = requests.get(img_url, headers=headers, timeout=TIMEOUT, verify=False)
if r.status_code != 200:
return [404, 'text/plain', 'image not found']
try:
b64_text = "".join(r.text.split())
data = base64.b64decode(b64_text)
mime = self._detect_mime(data)
return [200, mime, data, {'Content-Length': str(len(data))}]
except Exception:
return [404, 'text/plain', 'decode error']
else:
r = requests.get(img_url, headers=headers, timeout=TIMEOUT, verify=False)
if r.status_code != 200:
return [404, 'text/plain', 'image not found']
data = r.content
mime = r.headers.get('Content-Type', 'image/jpeg')
if not mime.startswith('image/'):
mime = self._detect_mime(data)
return [200, mime, data, {'Content-Length': str(len(data))}]
except Exception:
return [500, 'text/plain', 'proxy error']
+276
View File
@@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
# 爬虫源: 怦然心动 (prshinezenx.blog)
# 站点类型: SPA + 服务端渲染,数据通过 Base64 编码嵌入 HTML
# 开发者: AI Assistant
# 日期: 2026-07-22
import re
import json
import base64
from urllib.parse import urljoin, quote
from base.spider import Spider as BaseSpider
class Spider(BaseSpider):
def __init__(self):
self.host = "https://prshinezenx.blog"
self.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",
"Referer": self.host + "/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9"
}
# 分类列表(从首页提取,本地硬编码保证首页秒出)
self.classes = [
{"type_id": "43", "type_name": "国产精选"},
{"type_id": "31", "type_name": "束缚玩法"},
{"type_id": "35", "type_name": "中字专区"},
{"type_id": "33", "type_name": "女优精选"},
{"type_id": "53", "type_name": "传媒拍摄"},
{"type_id": "29", "type_name": "变性纪实"},
{"type_id": "21", "type_name": "同志日常"},
{"type_id": "23", "type_name": "百合情境"},
{"type_id": "39", "type_name": "欧美精选"},
{"type_id": "45", "type_name": "虚拟换脸"},
{"type_id": "47", "type_name": "少女幻想"},
{"type_id": "49", "type_name": "主播日记"},
{"type_id": "51", "type_name": "约会实录"},
{"type_id": "55", "type_name": "伦理剧场"},
{"type_id": "57", "type_name": "黑料档案"},
{"type_id": "63", "type_name": "自拍实录"},
]
# 无筛选功能
self.filters = {}
def getName(self):
return "怦然心动"
def getDependence(self):
return []
def init(self, extend=""):
"""初始化,零网络"""
pass
def _fetch(self, url):
"""请求页面,返回 HTML 文本"""
try:
rsp = self.fetch(url, headers=self.headers, timeout=15000)
if rsp and hasattr(rsp, 'text'):
return rsp.text
return None
except Exception:
return None
def _extract_vod_data(self, html):
"""从 HTML 中提取 window.__vod_data__ 的 Base64 数据并解码"""
if not html:
return None
pattern = r"const binaryStr = atob\('([^']+)'\)"
match = re.search(pattern, html)
if not match:
return None
base64_str = match.group(1)
try:
json_str = base64.b64decode(base64_str).decode('utf-8')
return json.loads(json_str)
except Exception:
return None
def _parse_list(self, items):
"""解析视频列表项,打包数据到 vod_id 以便详情页快速展示"""
if not items:
return []
result = []
for item in items:
vod_id = str(item.get("vod_id", ""))
if not vod_id:
continue
vod_name = item.get("vod_name", "未知标题")
vod_pic = item.get("vod_pic", "")
if vod_pic and not vod_pic.startswith("http"):
vod_pic = urljoin(self.host, vod_pic)
vod_remark = item.get("vod_duration", "")
type_id = str(item.get("type_id", ""))
# 打包数据到 vod_id,方便详情页快速返回
packed_id = f"{vod_id}|$|{vod_name}|$|{vod_pic}|$|{vod_remark}|$|{type_id}"
result.append({
"vod_id": packed_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remark,
})
return result
def homeContent(self, filter=False):
"""首页:返回分类列表,零网络"""
return {
"class": self.classes,
"filters": self.filters if filter else {}
}
def getHomeContent(self, filter=False):
return self.homeContent(filter)
def homeVideoContent(self):
"""首页推荐视频"""
html = self._fetch(self.host + "/")
if not html:
return {"list": []}
data = self._extract_vod_data(html)
if not data:
return {"list": []}
items = data.get("other_request_data", {}).get("random_list", [])
if not items:
items = data.get("request_data", {}).get("list", [])
return {"list": self._parse_list(items[:20])}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
"""分类列表页"""
page = pg or 1
url = f"{self.host}/vodlist/type/{tid}/keyword/all/orderby/default/page/{page}.html"
html = self._fetch(url)
if not html:
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
data = self._extract_vod_data(html)
if not data:
return {"list": [], "page": page, "pagecount": 1, "limit": 20, "total": 0}
items = data.get("request_data", {}).get("list", [])
total = data.get("request_data", {}).get("total", 0)
limit = data.get("limit", 20)
total_pages = (total + limit - 1) // limit if total > 0 else 1
return {
"list": self._parse_list(items),
"page": page,
"pagecount": total_pages,
"limit": limit,
"total": total
}
def detailContent(self, ids):
"""视频详情 - 从打包的 vod_id 中解析数据,快速返回"""
if not ids:
return {"list": []}
raw = str(ids[0])
# 解析打包的数据: vod_id|$|vod_name|$|vod_pic|$|vod_remark|$|type_id
parts = raw.split("|$|")
if len(parts) >= 5:
vod_id = parts[0]
vod_name = parts[1] if len(parts) > 1 else "未知标题"
vod_pic = parts[2] if len(parts) > 2 else ""
vod_remark = parts[3] if len(parts) > 3 else ""
type_id = parts[4] if len(parts) > 4 else ""
else:
# 兼容旧格式:直接传数字ID
vod_id = raw
vod_name = ""
vod_pic = ""
vod_remark = ""
type_id = ""
# 获取播放地址:请求详情页提取 vod_play_url
if type_id:
detail_url = f"{self.host}/voddetail/type/{type_id}/id/{vod_id}.html"
else:
detail_url = f"{self.host}/voddetail/type/all/id/{vod_id}.html"
html = self._fetch(detail_url)
play_page = ""
if html:
data = self._extract_vod_data(html)
if data:
vod_info = data.get("vod_info", {})
play_page = vod_info.get("vod_play_url", "")
if not vod_name:
vod_name = vod_info.get("vod_name", "未知标题")
if not vod_pic:
vod_pic = vod_info.get("vod_pic", "")
if not vod_remark:
vod_remark = vod_info.get("vod_duration", "")
if not play_page:
return {
"list": [{
"vod_id": vod_id,
"vod_name": vod_name or "未知标题",
"vod_pic": vod_pic,
"vod_remarks": vod_remark,
"vod_content": "",
"vod_play_from": "播放",
"vod_play_url": ""
}]
}
# 如果是 ao jie xi 包装,提取真实 m3u8
if "aojiexi.com" in play_page:
match = re.search(r'url=([^&]+)', play_page)
if match:
real_url = match.group(1)
real_url = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), real_url)
play_page = real_url
# 构造播放数据:单线路单集
# 格式参考 tmcrownxlift 成功案例
return {
"list": [{
"vod_id": vod_id,
"vod_name": vod_name or "未知标题",
"vod_pic": vod_pic,
"vod_remarks": vod_remark,
"vod_content": "",
"vod_play_from": "播放",
"vod_play_url": "播放$" + play_page
}]
}
def searchContent(self, key, quick=False, pg="1"):
"""搜索"""
if not key:
return {"list": []}
page = pg or 1
url = f"{self.host}/vodlist/type/all/keyword/{quote(key)}/orderby/default/page/{page}.html"
html = self._fetch(url)
if not html:
return {"list": []}
data = self._extract_vod_data(html)
if not data:
return {"list": []}
items = data.get("request_data", {}).get("list", [])
return {"list": self._parse_list(items)}
def playerContent(self, flag, vid, vipFlags=None):
"""播放地址解析"""
if not vid:
return {"parse": 0, "url": ""}
if vid.endswith((".m3u8", ".mp4")):
return {"parse": 0, "url": vid, "header": self.headers}
if "aojiexi.com" in vid:
match = re.search(r'url=([^&]+)', vid)
if match:
real_url = match.group(1)
real_url = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), real_url)
return {"parse": 0, "url": real_url, "header": self.headers}
if vid.startswith("http"):
html = self._fetch(vid)
if html:
match = re.search(r'https?://[^\s"\']+\.m3u8[^\s"\']*', html)
if match:
return {"parse": 0, "url": match.group(0), "header": self.headers}
return {"parse": 1, "url": vid}
def isVideoFormat(self, url):
if not url:
return False
return url.endswith((".m3u8", ".mp4", ".m3u8?"))
def destroy(self):
pass
+779 -1078
View File
@@ -1,1078 +1,779 @@
# 蜜桃视频 类型爬虫
# 网站: 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']
# -*- coding: utf-8 -*-
import sys
import re
import json
import base64
import threading
import requests
import urllib3
import os
import time
import random
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import unquote, quote, urljoin
urllib3.disable_warnings()
sys.path.append('..')
from base.spider import Spider as BaseSpider
# ===== 纯 Python AES-128 工具 =====
_sbox = bytes([
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16])
_inv_sbox = bytes([
0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb,
0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb,
0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e,
0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25,
0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92,
0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84,
0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06,
0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b,
0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73,
0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e,
0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b,
0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4,
0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f,
0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef,
0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61,
0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d])
_rcon = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36]
def _xtime(a):
return ((a << 1) ^ 0x1b) & 0xff if a & 0x80 else (a << 1) & 0xff
def _gf_mul(a, b):
r = 0
for _ in range(8):
if b & 1: r ^= a
a = _xtime(a)
b >>= 1
return r
_mul_e = bytes(_gf_mul(0x0e, i) for i in range(256))
_mul_b = bytes(_gf_mul(0x0b, i) for i in range(256))
_mul_d = bytes(_gf_mul(0x0d, i) for i in range(256))
_mul_9 = bytes(_gf_mul(0x09, i) for i in range(256))
_key_schedules = {}
def _key_schedule(key):
k = bytes(key)
if k in _key_schedules: return _key_schedules[k]
w = []
for i in range(4):
w.append([key[4*i], key[4*i+1], key[4*i+2], key[4*i+3]])
for i in range(4, 44):
temp = w[i-1][:]
if i % 4 == 0:
temp = temp[1:] + temp[:1]
temp = [_sbox[b] for b in temp]
temp[0] ^= _rcon[i//4 - 1]
w.append([w[i-4][j] ^ temp[j] for j in range(4)])
_key_schedules[k] = w
return w
def _dec_block(block, w):
s0,s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11,s12,s13,s14,s15 = block
s0 ^= w[40][0]; s1 ^= w[40][1]; s2 ^= w[40][2]; s3 ^= w[40][3]
s4 ^= w[41][0]; s5 ^= w[41][1]; s6 ^= w[41][2]; s7 ^= w[41][3]
s8 ^= w[42][0]; s9 ^= w[42][1]; s10^= w[42][2]; s11^= w[42][3]
s12^= w[43][0]; s13^= w[43][1]; s14^= w[43][2]; s15^= w[43][3]
box = _inv_sbox
for rnd in range(9, 0, -1):
t0=box[s0]; t1=box[s13]; t2=box[s10]; t3=box[s7]
t4=box[s4]; t5=box[s1]; t6=box[s14]; t7=box[s11]
t8=box[s8]; t9=box[s5]; t10=box[s2]; t11=box[s15]
t12=box[s12]; t13=box[s9]; t14=box[s6]; t15=box[s3]
rk=w[rnd*4]; t0^=rk[0]; t1^=rk[1]; t2^=rk[2]; t3^=rk[3]
rk=w[rnd*4+1]; t4^=rk[0]; t5^=rk[1]; t6^=rk[2]; t7^=rk[3]
rk=w[rnd*4+2]; t8^=rk[0]; t9^=rk[1]; t10^=rk[2]; t11^=rk[3]
rk=w[rnd*4+3]; t12^=rk[0]; t13^=rk[1]; t14^=rk[2]; t15^=rk[3]
s0 =_mul_e[t0]^_mul_b[t1]^_mul_d[t2]^_mul_9[t3]
s1 =_mul_9[t0]^_mul_e[t1]^_mul_b[t2]^_mul_d[t3]
s2 =_mul_d[t0]^_mul_9[t1]^_mul_e[t2]^_mul_b[t3]
s3 =_mul_b[t0]^_mul_d[t1]^_mul_9[t2]^_mul_e[t3]
s4 =_mul_e[t4]^_mul_b[t5]^_mul_d[t6]^_mul_9[t7]
s5 =_mul_9[t4]^_mul_e[t5]^_mul_b[t6]^_mul_d[t7]
s6 =_mul_d[t4]^_mul_9[t5]^_mul_e[t6]^_mul_b[t7]
s7 =_mul_b[t4]^_mul_d[t5]^_mul_9[t6]^_mul_e[t7]
s8 =_mul_e[t8]^_mul_b[t9]^_mul_d[t10]^_mul_9[t11]
s9 =_mul_9[t8]^_mul_e[t9]^_mul_b[t10]^_mul_d[t11]
s10=_mul_d[t8]^_mul_9[t9]^_mul_e[t10]^_mul_b[t11]
s11=_mul_b[t8]^_mul_d[t9]^_mul_9[t10]^_mul_e[t11]
s12=_mul_e[t12]^_mul_b[t13]^_mul_d[t14]^_mul_9[t15]
s13=_mul_9[t12]^_mul_e[t13]^_mul_b[t14]^_mul_d[t15]
s14=_mul_d[t12]^_mul_9[t13]^_mul_e[t14]^_mul_b[t15]
s15=_mul_b[t12]^_mul_d[t13]^_mul_9[t14]^_mul_e[t15]
t0=box[s0]; t1=box[s13]; t2=box[s10]; t3=box[s7]
t4=box[s4]; t5=box[s1]; t6=box[s14]; t7=box[s11]
t8=box[s8]; t9=box[s5]; t10=box[s2]; t11=box[s15]
t12=box[s12]; t13=box[s9]; t14=box[s6]; t15=box[s3]
rk=w[0]; t0^=rk[0]; t1^=rk[1]; t2^=rk[2]; t3^=rk[3]
rk=w[1]; t4^=rk[0]; t5^=rk[1]; t6^=rk[2]; t7^=rk[3]
rk=w[2]; t8^=rk[0]; t9^=rk[1]; t10^=rk[2]; t11^=rk[3]
rk=w[3]; t12^=rk[0]; t13^=rk[1]; t14^=rk[2]; t15^=rk[3]
return bytes([t0,t1,t2,t3,t4,t5,t6,t7,t8,t9,t10,t11,t12,t13,t14,t15])
def _aes_cbc_decrypt(data, key, iv):
if not data or len(data) % 16: return data
n = len(data) // 16
w = _key_schedule(key)
out = bytearray(len(data))
prev = iv
for i in range(n):
block = data[i*16:(i+1)*16]
dec = _dec_block(block, w)
for j in range(16):
out[i*16+j] = dec[j] ^ prev[j]
prev = block
pad = out[-1]
if 1 <= pad <= 16:
return bytes(out[:-pad])
return bytes(out)
# ===== 全局代理服务 =====
_proxy_port = 0
_proxy_started = False
_proxy_session = requests.Session()
_proxy_session.verify = False
_proxy_headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://hscka.cc/',
}
class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class _ProxyHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
real_url = unquote(self.path[1:])
if not real_url or not real_url.startswith('http'):
self.send_response(404); self.end_headers(); return
r = _proxy_session.get(real_url, headers=_proxy_headers, timeout=20, verify=False)
ct = r.headers.get('Content-Type', 'image/jpeg')
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Content-Length', len(r.content))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(r.content)
except BrokenPipeError:
pass
except Exception:
self.send_response(404); self.end_headers()
def log_message(self, format, *args): pass
def _find_free_port():
import socket
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.bind(('127.0.0.1', 0))
port = sk.getsockname()[1]
sk.close()
return port
def _start_proxy():
global _proxy_port, _proxy_started
if _proxy_started: return
_proxy_port = _find_free_port()
server = _ThreadedHTTPServer(('127.0.0.1', _proxy_port), _ProxyHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
_proxy_started = True
# ===== Spider 类 =====
class Spider(BaseSpider):
session = requests.Session()
host = 'https://hscka.cc'
def __init__(self):
super().__init__()
self._categories_cache = None
self._m3u_lock = threading.Lock()
self._debug = True # 开启调试日志
# ===== 持久化存储配置 =====
self._data_dir = '/sdcard' if os.path.exists('/sdcard') else '.'
self._saved_data_file = os.path.join(self._data_dir, '.hscka_saved.json')
# 加载已保存数据: {vid: {name, url, pic, cat, type, time}}
self._saved_videos = self._load_saved_data()
self._log(f'已加载历史记录: {len(self._saved_videos)}')
def _log(self, msg):
if self._debug:
print(f'[hscka] {msg}')
def _load_saved_data(self):
"""从JSON文件加载已保存的视频记录"""
if os.path.exists(self._saved_data_file):
try:
with open(self._saved_data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
self._log(f'加载历史数据失败: {e}')
return {}
def _save_data(self):
"""保存视频记录到JSON文件"""
try:
with open(self._saved_data_file, 'w', encoding='utf-8') as f:
json.dump(self._saved_videos, f, ensure_ascii=False, indent=2)
except Exception as e:
self._log(f'保存历史数据失败: {e}')
def getName(self): return 'hscka'
def isVideoFormat(self, url):
if not url: return False
return '.m3u8' in url or '.mp4' in url or '.ts' in url or url.startswith('magnet:')
def manualVideoCheck(self): return False
def destroy(self): pass
def localProxy(self, param):
return [404, 'text/plain', '']
def init(self, extend=''):
self.session.verify = False
self.session.headers.update(self._get_headers())
_start_proxy()
text = self._fetch(self.host)
if text:
self._load_categories(text)
def _get_headers(self, referer=None):
"""获取完整的请求头,模拟真实浏览器"""
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,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',
'Cache-Control': 'max-age=0',
}
if referer:
headers['Referer'] = referer
else:
headers['Referer'] = self.host + '/'
return headers
def _proxy_url(self, url):
if not url: return ''
if url.startswith('http://127.0.0.1'):
return url
return f'http://127.0.0.1:{_proxy_port}/{quote(url, safe="")}'
def _fetch(self, url, referer=None, retries=3):
"""增强版请求,支持重试和随机延迟"""
for attempt in range(retries):
try:
if referer is None:
referer = self.host + '/'
headers = self._get_headers(referer)
if attempt > 0:
time.sleep(random.uniform(0.5, 1.5))
r = self.session.get(url, headers=headers, timeout=30, verify=False)
r.encoding = 'utf-8'
if r.status_code == 200:
return r.text
elif r.status_code in [403, 429, 503]:
self._log(f'请求被拦截 [{r.status_code}],第{attempt+1}次重试: {url}')
continue
else:
self._log(f'请求失败 [{r.status_code}]: {url}')
return ''
except requests.exceptions.Timeout:
self._log(f'请求超时,第{attempt+1}次重试: {url}')
except Exception as e:
self._log(f'请求异常 [{e}],第{attempt+1}次重试: {url}')
return ''
@staticmethod
def _decode_b64(encoded_str):
try:
raw = base64.b64decode(encoded_str)
return raw.decode('utf-8')
except:
return encoded_str
# ----- 分类加载 -----
def _load_categories(self, text):
if not text:
return []
cats = []
seen = set()
pattern = r'href="(/list/\d+-\d+\.html)"[^>]*>\s*<script[^>]*>document\.write\(d\(\'([A-Za-z0-9+/=]+)\'\)\);</script>'
for path, b64_name in re.findall(pattern, text, re.S):
name = self._decode_b64(b64_name)
name = re.sub(r'<[^>]+>', '', name).strip()
if not name or name in seen:
continue
seen.add(name)
tid = path.split('/')[-1].split('-')[0]
cats.append({'type_id': tid, 'type_name': name})
self._categories_cache = cats
return cats
def _get_category_name(self, tid):
for cat in self._categories_cache or []:
if cat['type_id'] == tid:
return cat['type_name']
return tid
# ----- 列表解析 -----
def _parse_list(self, html):
items = []
cards = re.findall(r'<div class="item item-post">\s*(.*?)\s*</div>', html, re.S)
for card in cards:
a_match = re.search(r'<a href="([^"]+)"', card)
if not a_match:
continue
href = a_match.group(1)
img_match = re.search(r'<img[^>]+(?:data-original|src)="([^"]+)"', card)
pic = img_match.group(1) if img_match else ''
title = ''
title_match = re.search(r'<h3 class="name">(.*?)</h3>', card, re.S)
if title_match:
title_raw = title_match.group(1)
b64 = re.search(r"document\.write\(d\('([A-Za-z0-9+/=]+)'\)\)", title_raw)
if b64:
title = self._decode_b64(b64.group(1))
title = re.sub(r'<[^>]+>', '', title).strip()
else:
title = re.sub(r'<[^>]+>', '', title_raw).strip()
if href.startswith('magnet:'):
items.append({
'vod_id': href,
'vod_name': title or '磁力资源',
'vod_pic': self._proxy_url(pic),
'vod_remarks': '磁力',
})
elif '/torrent/' in href:
vid = href.split('/')[-1].replace('.html', '')
items.append({
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(pic),
'vod_remarks': '磁力',
})
elif '/video/' in href:
vid = href.split('/')[-1].replace('.html', '')
items.append({
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(pic),
'vod_remarks': '',
})
return items
def _get_list(self, tid, page):
url = f'{self.host}/list/{tid}-{page}.html'
html = self._fetch(url, referer=f'{self.host}/list/{tid}-1.html')
if not html:
return []
return self._parse_list(html)
# ----- 首页 -----
def homeContent(self, filter):
try:
text = self._fetch(self.host)
if text:
self._load_categories(text)
cats = self._categories_cache or []
items = []
if cats:
items = self._get_list(cats[0]['type_id'], 1)
return {
'class': cats,
'filters': {},
'type': '影视',
'list': items,
'page': 1,
'pagecount': 1,
'limit': len(items),
'total': len(items)
}
except Exception as e:
self._log(f'homeContent 异常: {e}')
return {
'class': [], 'filters': {}, 'type': '影视',
'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0
}
def homeVideoContent(self):
if self._categories_cache:
return {'list': self._get_list(self._categories_cache[0]['type_id'], 1)}
return {'list': []}
# ----- 分类内容 -----
def categoryContent(self, tid, pg, filter, extend):
try:
page = int(pg) if pg else 1
items = self._get_list(tid, page)
total_page = page + 1
if page == 1:
html = self._fetch(f'{self.host}/list/{tid}-1.html')
if html:
pages = re.findall(r'/list/\d+-(\d+)\.html', html)
if pages:
total_page = max(int(p) for p in pages)
cat_name = self._get_category_name(tid)
# 后台导出到M3U和TXT(持久化去重)
threading.Thread(target=self._export_page_to_files, args=(items, cat_name), daemon=True).start()
return {
'list': items, 'page': page, 'pagecount': total_page,
'limit': len(items), 'total': total_page * len(items)
}
except Exception as e:
self._log(f'categoryContent 异常: {e}')
return {
'list': [], 'page': int(pg) if pg else 1,
'pagecount': 1, 'limit': 0, 'total': 0
}
# ===== 文件导出(M3U + 磁力TXT,持久化去重) =====
def _export_page_to_files(self, items, cat_name):
"""导出当前页视频到M3U和TXT,支持跨会话去重和替换"""
if not items:
return
safe_name = re.sub(r'[\\/:*?"<>|]', '_', cat_name)
updated = False
# 遍历当前页item,更新持久化数据
for item in items:
vid = item['vod_id']
play_url = self._resolve_play_url(item)
if not play_url:
continue
is_magnet = vid.startswith('magnet:')
existing = self._saved_videos.get(vid)
# 如果已存在且URL完全相同 -> 忽略(跳过)
if existing and existing.get('url') == play_url:
continue
# 否则:新增或替换(更新)
self._saved_videos[vid] = {
'name': item['vod_name'],
'url': play_url,
'pic': item.get('vod_pic', ''),
'cat': cat_name,
'type': 'magnet' if is_magnet else 'video',
'time': datetime.now().isoformat()
}
updated = True
action = '新增' if not existing else '替换'
self._log(f'{action}记录: {item["vod_name"][:30]}... ({vid[:20]}...)')
if not updated:
self._log(f'分类[{cat_name}]无新数据,跳过写入')
return
# 保存JSON索引
self._save_data()
with self._m3u_lock:
# ---- 重写该分类的M3U文件(只含非磁力视频)----
m3u_file = os.path.join(self._data_dir, f'{safe_name}.m3u')
with open(m3u_file, 'w', encoding='utf-8') as f:
f.write('#EXTM3U\n')
count = 0
for vid, data in self._saved_videos.items():
if data.get('cat') == cat_name and data.get('type') != 'magnet':
f.write(f'#EXTINF:-1 tvg-logo="{data["pic"]}" group-title="{cat_name}",{data["name"]}\n')
f.write(f'{data["url"]}\n')
count += 1
self._log(f'已重写M3U: {m3u_file} ({count}条)')
# ---- 重写磁力链接TXT文件(汇总所有分类的磁力)----
txt_file = os.path.join(self._data_dir, '磁力链接.txt')
with open(txt_file, 'w', encoding='utf-8') as f:
f.write('# ==========================================\n')
f.write('# 磁力链接汇总文件\n')
f.write(f'# 生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
f.write('# 提示: 请使用支持云播放/离线下载的播放器或迅雷打开\n')
f.write('# ==========================================\n\n')
mag_count = 0
for vid, data in self._saved_videos.items():
if data.get('type') == 'magnet':
f.write(f'{data["name"]}\n')
f.write(f'{data["url"]}\n')
f.write(f'# 分类: {data.get("cat", "未知")} | 保存时间: {data.get("time", "未知")}\n')
f.write('-' * 50 + '\n')
mag_count += 1
self._log(f'已重写磁力TXT: {txt_file} ({mag_count}条)')
def _resolve_play_url(self, item):
vid = item['vod_id']
if vid.startswith('magnet:'):
return vid
detail = self._fetch_detail(vid)
if not detail or not detail.get('vod_play_url'):
return ''
first_line = detail['vod_play_url'].split('#')[0]
if '$' in first_line:
return first_line.split('$', 1)[1]
return first_line
# ----- 详情 (核心修复:移除'在线播放',统一用'备用播放') -----
def _fetch_detail(self, vid):
if vid.startswith('magnet:'):
return {'vod_play_url': f'磁力${vid}'}
url_patterns = [
f'{self.host}/video/{vid}.html',
f'{self.host}/torrent/{vid}.html',
f'{self.host}/v/{vid}.html',
f'{self.host}/movie/{vid}.html',
f'{self.host}/play/{vid}.html',
]
for url in url_patterns:
self._log(f'尝试获取详情: {url}')
html = self._fetch(url, referer=self.host)
if html and ('video' in html or 'play' in html or 'magnet' in html or 'm3u8' in html or 'mp4' in html):
result = self._parse_detail(html, vid, url)
if result and result.get('vod_play_url'):
self._log(f'成功解析详情: {vid}')
return result
self._log(f'无法获取详情: {vid}')
return None
def _parse_detail(self, html, vid, base_url):
"""增强版详情解析:移除'在线播放',统一为'备用播放',增强各类链接提取"""
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
if m:
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>([^<]+)</title>', html)
if m:
title = m.group(1).strip()
cover = ''
m = re.search(r'<meta[^>]*property="og:image"[^>]*content="([^"]+)"', html)
if m:
cover = m.group(1)
if not cover:
m = re.search(r'<img[^>]*class="thumb"[^>]*src="([^"]+)"', html)
if m:
cover = m.group(1)
if not cover:
m = re.search(r'<img[^>]*class="poster"[^>]*src="([^"]+)"', html)
if m:
cover = m.group(1)
play_urls = []
seen_urls = set() # 用于去重
def _add_url(label, url):
"""辅助函数:添加播放链接,自动去重"""
if url in seen_urls:
return False
seen_urls.add(url)
play_urls.append(f'{label}${url}')
self._log(f'解析到[{label}]: {url[:80]}...')
return True
# 1. 磁力链接
for mag in set(re.findall(r'magnet:\?xt=urn:btih:[A-Za-z0-9]+[^\s"\'<>]*', html)):
_add_url('磁力', mag)
# 3. 备用播放:相对路径的 play.php
for link in set(re.findall(r'href=["\']?(/[^"\'<>\s]*play\.php[^"\'<>\s]*)', html)):
full_link = urljoin(base_url, link)
_add_url('备用播放', full_link)
# 4. 备用播放:引号中的 play.php(更宽松的匹配)
for link in set(re.findall(r'["\']([^"\']*play\.php[^"\']*)["\']', html)):
if link.startswith('http'):
_add_url('备用播放', link)
# 5. iframe(支持单双引号、data-src
iframe_pattern = r'<iframe[^>]+(?:src|data-src)=["\']([^"\']+)["\']'
for src in set(re.findall(iframe_pattern, html)):
if any(k in src for k in ['play.php', 'm3u8', 'mp4', 'embed', 'player']):
full_src = src if src.startswith('http') else urljoin(base_url, src)
_add_url('外链', full_src)
# 6. 媒体直链(支持带参数)
for media in set(re.findall(r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|flv|mkv|ts)(?:\?[^\s"\'<>]*)?', html)):
_add_url('直链', media)
# 7. 从 script 标签中提取 JSON/变量中的播放链接
script_tags = re.findall(r'<script[^>]*>(.*?)</script>', html, re.S)
for script in script_tags:
# 提取 JSON 中的 url/src/playUrl/videoUrl 字段
for match in re.findall(r'["\'](?:url|src|playUrl|videoUrl|file|source)["\']\s*:\s*["\']([^"\']+)["\']', script):
if any(ext in match for ext in ['.m3u8', '.mp4', 'play.php', 'magnet:', '.flv', '.ts']):
full_match = match if match.startswith('http') else urljoin(base_url, match)
_add_url('JS解析', full_match)
# 提取 base64 编码的链接
for b64 in re.findall(r'["\']([A-Za-z0-9+/]{20,}={0,2})["\']', script):
try:
decoded = base64.b64decode(b64).decode('utf-8')
if decoded.startswith('http') and any(ext in decoded for ext in ['.m3u8', '.mp4', 'play.php', '.flv']):
_add_url('Base64解码', decoded)
except:
pass
# 提取 AES 加密的数据
aes_pattern = r'["\']([A-Za-z0-9+/]{50,}={0,2})["\']'
for aes_b64 in re.findall(aes_pattern, script):
try:
raw = base64.b64decode(aes_b64)
if len(raw) % 16 == 0 and len(raw) >= 16:
common_keys = [
(b'1234567890123456', b'1234567890123456'),
(b'0123456789abcdef', b'0123456789abcdef'),
]
for key, iv in common_keys:
try:
decrypted = _aes_cbc_decrypt(raw, key, iv)
dec_str = decrypted.decode('utf-8')
if dec_str.startswith('http') and any(ext in dec_str for ext in ['.m3u8', '.mp4', 'play.php']):
_add_url('AES解码', dec_str)
break
except:
continue
except:
pass
# 8. 从 video/source 标签提取
for media in set(re.findall(r'<(?:video|source)[^>]+src=["\']([^"\']+)["\']', html)):
if any(ext in media for ext in ['.m3u8', '.mp4', '.flv', '.ts']):
full_media = media if media.startswith('http') else urljoin(base_url, media)
_add_url('HTML5', full_media)
# 9. 从 a 标签的 data-url / data-src / data-link 提取
for media in set(re.findall(r'<a[^>]+(?:data-url|data-src|data-link)=["\']([^"\']+)["\']', html)):
if any(ext in media for ext in ['.m3u8', '.mp4', 'play.php', 'magnet:']):
full_media = media if media.startswith('http') else urljoin(base_url, media)
_add_url('数据属性', full_media)
# 10. 从 onclick 属性提取
for onclick in set(re.findall(r'onclick=["\'][^"\']*(https?://[^"\'<>]+)["\']', html)):
if any(ext in onclick for ext in ['.m3u8', '.mp4', 'play.php']):
_add_url('点击播放', onclick)
if not play_urls:
self._log(f'未找到任何播放链接: {vid}')
return None
self._log(f'共解析到 {len(play_urls)} 个播放源')
# 构建 TVBox 标准格式的播放数据
sources = []
urls = []
for i, pu in enumerate(play_urls):
if '$' in pu:
source_name, url = pu.split('$', 1)
else:
source_name = f'线路{i+1}'
url = pu
sources.append(source_name)
urls.append(f'{source_name}${url}')
return {
'vod_id': vid,
'vod_name': title or vid,
'vod_pic': self._proxy_url(cover) if cover else '',
'vod_play_from': '$$$'.join(sources),
'vod_play_url': '#'.join(urls),
'vod_content': title or '',
}
def detailContent(self, ids):
try:
vid = str(ids[0] if isinstance(ids, list) else ids)
if vid.startswith('magnet:'):
return {
'list': [{
'vod_id': vid,
'vod_name': '磁力资源',
'vod_pic': '',
'vod_play_from': '磁力',
'vod_play_url': f'磁力${vid}',
'vod_content': '磁力链接(建议配合云播放/离线下载使用)',
}]
}
detail = self._fetch_detail(vid)
if not detail:
self._log(f'detailContent 获取详情失败: {vid}')
return {'list': []}
return {'list': [detail]}
except Exception as e:
self._log(f'detailContent 异常: {e}')
return {'list': []}
# ----- 播放 -----
def playerContent(self, flag, id, vipFlags=None):
try:
if id.startswith('magnet:'):
return {'parse': 0, 'url': id, 'header': {}}
# 外部播放链接,直接返回让播放器请求
if 'play.php' in id or 'm3u8' in id or 'mp4' in id or 'flv' in id or 'ts' in id:
return {
'parse': 0,
'url': id,
'header': {
'Referer': self.host,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Origin': self.host,
}
}
return {
'parse': 0,
'url': id,
'header': {
'Referer': self.host,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
}
except Exception as e:
self._log(f'playerContent 异常: {e}')
return {'parse': 0, 'url': '', 'header': {}}
# ----- 搜索 -----
def searchContent(self, key, quick, pg='1'):
try:
page = int(pg) if pg else 1
# 视频搜索
url = f'{self.host}/search.php?content={quote(key)}&type=1&page={page}'
html = self._fetch(url, referer=self.host)
items = self._parse_list(html) if html else []
if not items:
# 磁力搜索
url = f'{self.host}/search.php?content={quote(key)}&type=2&page={page}'
html = self._fetch(url, referer=self.host)
items = self._parse_list(html) if html else []
return {
'list': items, 'page': page, 'pagecount': page + 1,
'limit': len(items), 'total': page * len(items)
}
except Exception as e:
self._log(f'searchContent 异常: {e}')
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}
+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']
+278
View File
@@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
import sys
import re
from bs4 import BeautifulSoup
import requests as rq
from urllib.parse import quote
sys.path.append('..')
try:
from base.spider import Spider
except ImportError:
class Spider:
def fetch(self, url, headers=None, **kw):
kw.pop('timeout', None)
r = rq.get(url, headers=headers, timeout=15, **kw)
r.encoding = 'utf-8'
return r
HOST = "https://www.jennyhow.com"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
CLASS_MAP = {
"/hxq/1.html": "最新韩剧",
"/hxq/2.html": "韩国电影",
"/hxq/3.html": "韩国综艺",
"/hxq/4.html": "韩国动漫"
}
class Spider(Spider):
def init(self, extend=""):
self._session = rq.Session()
self._session.headers.update({
"User-Agent": UA,
"Referer": HOST
})
def getName(self):
return "韩小圈"
def isVideoFormat(self, url):
return ".m3u8" in url or ".mp4" in url
def manualVideoCheck(self):
return False
def _get(self, url, timeout=10):
try:
r = self._session.get(url, timeout=timeout)
r.encoding = 'utf-8'
return r.text
except Exception as e:
print(f"网络请求失败: {e}")
return ""
def _format_pic(self, pic_url):
if not pic_url: return ""
if pic_url.startswith('//'):
return "https:" + pic_url
if pic_url.startswith('/'):
return HOST + pic_url
return pic_url
def homeContent(self, filter=False):
classes = []
for tid, name in CLASS_MAP.items():
classes.append({"type_id": tid, "type_name": name})
return {"class": classes}
def homeVideoContent(self):
html = self._get(HOST)
soup = BeautifulSoup(html, 'html.parser')
videos = []
items = soup.find_all('div', class_='module-item')
for item in items:
a_tag = item.find('a', class_='module-item-title') or item.find('a')
img_tag = item.find('img')
if a_tag:
name = a_tag.get('title') or a_tag.get_text(strip=True)
href = a_tag.get('href', '')
pic = ""
if img_tag:
pic = img_tag.get('data-src') or img_tag.get('data-original') or img_tag.get('src', '')
videos.append({
"vod_id": href,
"vod_name": name,
"vod_pic": self._format_pic(pic),
"vod_remarks": ""
})
return {"list": videos}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
try:
pn = max(int(str(pg)), 1)
# 拦截缓存
if tid in ["20", "1"]: tid = "/hxq/1.html"
elif tid in ["21", "2"]: tid = "/hxq/2.html"
elif tid in ["22", "3"]: tid = "/hxq/3.html"
elif tid in ["23", "4"]: tid = "/hxq/4.html"
url = tid
if pn > 1 and url.endswith('.html'):
url = url.replace('.html', f'-{pn}.html')
if not url.startswith('http'):
url = HOST + url
html = self._get(url)
soup = BeautifulSoup(html, 'html.parser')
videos = []
for item in soup.find_all('div', class_='module-item'):
a_tag = item.find('a', class_='module-item-pic') or item.find('a')
if not a_tag: continue
img_tag = item.find('img')
name = a_tag.get('title')
if not name and img_tag: name = img_tag.get('alt')
if not name: name = a_tag.get_text(strip=True)
href = a_tag.get('href', '')
pic = ""
if img_tag:
pic = img_tag.get('data-src') or img_tag.get('data-original') or img_tag.get('src', '')
remarks_tag = item.find(class_='module-item-text') or item.find(class_='module-item-note')
remarks = remarks_tag.get_text(strip=True) if remarks_tag else ""
if href:
videos.append({
"vod_id": href,
"vod_name": name,
"vod_pic": self._format_pic(pic),
"vod_remarks": remarks
})
pagecount = pn + 1 if len(videos) > 0 else pn
return {"list": videos, "page": pn, "pagecount": pagecount, "limit": 24, "total": 0}
except:
return {"list": [], "page": pg}
# ================= 详情页大升级 =================
def detailContent(self, ids):
detail_url = ids[0] if ids[0].startswith('http') else HOST + ids[0]
html = self._get(detail_url)
if not html: return {"list": []}
soup = BeautifulSoup(html, 'html.parser')
title_tag = soup.find('h1')
title = title_tag.get_text(strip=True) if title_tag else "未知名称"
pic_tag = soup.find('img', class_='lazyload') or soup.find('img', class_='lazy')
pic = ""
if pic_tag:
pic = pic_tag.get('data-src') or pic_tag.get('data-original') or pic_tag.get('src', '')
# --- 新增:智能文本猎手,自动抓取导演/主演/剧情等信息 ---
vod_director, vod_actor, vod_year, vod_content = "", "", "", ""
# 遍历所有文本节点寻找关键词
for tag in soup.find_all(text=re.compile(r'导演|主演|上映|年份|剧情|简介')):
text_str = tag.strip()
parent = tag.parent
# 过滤掉系统标签
if parent.name in ['title', 'meta', 'script', 'style']: continue
# 往上找包裹着文字的容器
container = parent.parent if parent.name in ['span', 'strong', 'b', 'font'] else parent
full_text = container.get_text(separator=' ', strip=True)
if '导演' in text_str and not vod_director:
vod_director = re.sub(r'.*?导演[:]?\s*', '', full_text)
elif '主演' in text_str and not vod_actor:
vod_actor = re.sub(r'.*?主演[:]?\s*', '', full_text)
elif ('上映' in text_str or '年份' in text_str) and not vod_year:
vod_year = re.sub(r'.*?(上映|年份)[:]?\s*', '', full_text)
elif ('剧情' in text_str or '简介' in text_str) and not vod_content:
vod_content = re.sub(r'.*?(剧情|简介)[:]?\s*', '', full_text)
# 简介兜底:有的网站把简介放进了一个很隐蔽的 class 里
if not vod_content:
intro_tag = soup.find(class_='module-info-introduction-content') or soup.find(class_='module-info-introduction')
if intro_tag:
vod_content = intro_tag.get_text(strip=True)
# 抓取播放列表
play_from = []
play_url = []
tabs_ul = soup.find('ul', class_='nav-tabs')
if tabs_ul:
for li in tabs_ul.find_all('li'):
a_tag = li.find('a')
if not a_tag: continue
line_name = a_tag.get_text(strip=True)
target_id = a_tag.get('href', '').replace('#', '')
playlist_div = soup.find('div', id=target_id)
if playlist_div:
episodes = []
for ep in playlist_div.find_all('a'):
ep_name = ep.get('title') or ep.get_text(strip=True)
ep_href = ep.get('href', '')
if ep_href:
episodes.append(f"{ep_name}${ep_href}")
if episodes:
play_from.append(line_name)
play_url.append("#".join(episodes))
vod = {
"vod_id": ids[0],
"vod_name": title,
"vod_pic": self._format_pic(pic),
"vod_director": vod_director, # 🌟 给 APP 喂进去导演
"vod_actor": vod_actor, # 🌟 给 APP 喂进去主演
"vod_year": vod_year, # 🌟 给 APP 喂进去年份
"vod_content": vod_content, # 🌟 给 APP 喂进去简介
"vod_play_from": "$$$".join(play_from),
"vod_play_url": "$$$".join(play_url),
}
return {"list": [vod]}
def playerContent(self, flag, id, vipFlags=None):
play_url = id if id.startswith('http') else HOST + id
html = self._get(play_url)
if not html: return {"url": ""}
match = re.search(r'var now=[\'"](.*?)[\'"];', html)
if match:
m3u8_url = match.group(1)
return {
"url": m3u8_url,
"header": {"User-Agent": UA}
}
match_json = re.search(r'player_aaaa\s*=\s*(\{[^}]+\})', html)
if match_json:
import json
try:
data = json.loads(match_json.group(1))
return {"url": data.get("url", ""), "header": {"User-Agent": UA}}
except:
pass
return {"url": ""}
def searchContent(self, key, quick=False, pg=1):
try:
url = f"{HOST}/vodsearch/{quote(key)}----------{pg}---.html"
html = self._get(url)
soup = BeautifulSoup(html, 'html.parser')
videos = []
for item in soup.find_all('div', class_='module-search-item') or soup.find_all('div', class_='module-item'):
a_tag = item.find('a')
img_tag = item.find('img')
if a_tag and img_tag:
name = img_tag.get('alt', '') or a_tag.get('title', '')
href = a_tag.get('href', '')
pic = img_tag.get('data-src') or img_tag.get('data-original') or img_tag.get('src', '')
videos.append({
"vod_id": href,
"vod_name": name,
"vod_pic": self._format_pic(pic)
})
return {"list": videos}
except:
return {"list": []}
def localProxy(self, param):
pass
+779
View File
@@ -0,0 +1,779 @@
# -*- coding: utf-8 -*-
import sys
import re
import json
import base64
import threading
import requests
import urllib3
import os
import time
import random
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import unquote, quote, urljoin
urllib3.disable_warnings()
sys.path.append('..')
from base.spider import Spider as BaseSpider
# ===== 纯 Python AES-128 工具 =====
_sbox = bytes([
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16])
_inv_sbox = bytes([
0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb,
0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb,
0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e,
0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25,
0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92,
0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84,
0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06,
0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b,
0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73,
0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e,
0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b,
0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4,
0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f,
0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef,
0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61,
0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d])
_rcon = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36]
def _xtime(a):
return ((a << 1) ^ 0x1b) & 0xff if a & 0x80 else (a << 1) & 0xff
def _gf_mul(a, b):
r = 0
for _ in range(8):
if b & 1: r ^= a
a = _xtime(a)
b >>= 1
return r
_mul_e = bytes(_gf_mul(0x0e, i) for i in range(256))
_mul_b = bytes(_gf_mul(0x0b, i) for i in range(256))
_mul_d = bytes(_gf_mul(0x0d, i) for i in range(256))
_mul_9 = bytes(_gf_mul(0x09, i) for i in range(256))
_key_schedules = {}
def _key_schedule(key):
k = bytes(key)
if k in _key_schedules: return _key_schedules[k]
w = []
for i in range(4):
w.append([key[4*i], key[4*i+1], key[4*i+2], key[4*i+3]])
for i in range(4, 44):
temp = w[i-1][:]
if i % 4 == 0:
temp = temp[1:] + temp[:1]
temp = [_sbox[b] for b in temp]
temp[0] ^= _rcon[i//4 - 1]
w.append([w[i-4][j] ^ temp[j] for j in range(4)])
_key_schedules[k] = w
return w
def _dec_block(block, w):
s0,s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11,s12,s13,s14,s15 = block
s0 ^= w[40][0]; s1 ^= w[40][1]; s2 ^= w[40][2]; s3 ^= w[40][3]
s4 ^= w[41][0]; s5 ^= w[41][1]; s6 ^= w[41][2]; s7 ^= w[41][3]
s8 ^= w[42][0]; s9 ^= w[42][1]; s10^= w[42][2]; s11^= w[42][3]
s12^= w[43][0]; s13^= w[43][1]; s14^= w[43][2]; s15^= w[43][3]
box = _inv_sbox
for rnd in range(9, 0, -1):
t0=box[s0]; t1=box[s13]; t2=box[s10]; t3=box[s7]
t4=box[s4]; t5=box[s1]; t6=box[s14]; t7=box[s11]
t8=box[s8]; t9=box[s5]; t10=box[s2]; t11=box[s15]
t12=box[s12]; t13=box[s9]; t14=box[s6]; t15=box[s3]
rk=w[rnd*4]; t0^=rk[0]; t1^=rk[1]; t2^=rk[2]; t3^=rk[3]
rk=w[rnd*4+1]; t4^=rk[0]; t5^=rk[1]; t6^=rk[2]; t7^=rk[3]
rk=w[rnd*4+2]; t8^=rk[0]; t9^=rk[1]; t10^=rk[2]; t11^=rk[3]
rk=w[rnd*4+3]; t12^=rk[0]; t13^=rk[1]; t14^=rk[2]; t15^=rk[3]
s0 =_mul_e[t0]^_mul_b[t1]^_mul_d[t2]^_mul_9[t3]
s1 =_mul_9[t0]^_mul_e[t1]^_mul_b[t2]^_mul_d[t3]
s2 =_mul_d[t0]^_mul_9[t1]^_mul_e[t2]^_mul_b[t3]
s3 =_mul_b[t0]^_mul_d[t1]^_mul_9[t2]^_mul_e[t3]
s4 =_mul_e[t4]^_mul_b[t5]^_mul_d[t6]^_mul_9[t7]
s5 =_mul_9[t4]^_mul_e[t5]^_mul_b[t6]^_mul_d[t7]
s6 =_mul_d[t4]^_mul_9[t5]^_mul_e[t6]^_mul_b[t7]
s7 =_mul_b[t4]^_mul_d[t5]^_mul_9[t6]^_mul_e[t7]
s8 =_mul_e[t8]^_mul_b[t9]^_mul_d[t10]^_mul_9[t11]
s9 =_mul_9[t8]^_mul_e[t9]^_mul_b[t10]^_mul_d[t11]
s10=_mul_d[t8]^_mul_9[t9]^_mul_e[t10]^_mul_b[t11]
s11=_mul_b[t8]^_mul_d[t9]^_mul_9[t10]^_mul_e[t11]
s12=_mul_e[t12]^_mul_b[t13]^_mul_d[t14]^_mul_9[t15]
s13=_mul_9[t12]^_mul_e[t13]^_mul_b[t14]^_mul_d[t15]
s14=_mul_d[t12]^_mul_9[t13]^_mul_e[t14]^_mul_b[t15]
s15=_mul_b[t12]^_mul_d[t13]^_mul_9[t14]^_mul_e[t15]
t0=box[s0]; t1=box[s13]; t2=box[s10]; t3=box[s7]
t4=box[s4]; t5=box[s1]; t6=box[s14]; t7=box[s11]
t8=box[s8]; t9=box[s5]; t10=box[s2]; t11=box[s15]
t12=box[s12]; t13=box[s9]; t14=box[s6]; t15=box[s3]
rk=w[0]; t0^=rk[0]; t1^=rk[1]; t2^=rk[2]; t3^=rk[3]
rk=w[1]; t4^=rk[0]; t5^=rk[1]; t6^=rk[2]; t7^=rk[3]
rk=w[2]; t8^=rk[0]; t9^=rk[1]; t10^=rk[2]; t11^=rk[3]
rk=w[3]; t12^=rk[0]; t13^=rk[1]; t14^=rk[2]; t15^=rk[3]
return bytes([t0,t1,t2,t3,t4,t5,t6,t7,t8,t9,t10,t11,t12,t13,t14,t15])
def _aes_cbc_decrypt(data, key, iv):
if not data or len(data) % 16: return data
n = len(data) // 16
w = _key_schedule(key)
out = bytearray(len(data))
prev = iv
for i in range(n):
block = data[i*16:(i+1)*16]
dec = _dec_block(block, w)
for j in range(16):
out[i*16+j] = dec[j] ^ prev[j]
prev = block
pad = out[-1]
if 1 <= pad <= 16:
return bytes(out[:-pad])
return bytes(out)
# ===== 全局代理服务 =====
_proxy_port = 0
_proxy_started = False
_proxy_session = requests.Session()
_proxy_session.verify = False
_proxy_headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://hscka.cc/',
}
class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class _ProxyHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
real_url = unquote(self.path[1:])
if not real_url or not real_url.startswith('http'):
self.send_response(404); self.end_headers(); return
r = _proxy_session.get(real_url, headers=_proxy_headers, timeout=20, verify=False)
ct = r.headers.get('Content-Type', 'image/jpeg')
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Content-Length', len(r.content))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(r.content)
except BrokenPipeError:
pass
except Exception:
self.send_response(404); self.end_headers()
def log_message(self, format, *args): pass
def _find_free_port():
import socket
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.bind(('127.0.0.1', 0))
port = sk.getsockname()[1]
sk.close()
return port
def _start_proxy():
global _proxy_port, _proxy_started
if _proxy_started: return
_proxy_port = _find_free_port()
server = _ThreadedHTTPServer(('127.0.0.1', _proxy_port), _ProxyHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
_proxy_started = True
# ===== Spider 类 =====
class Spider(BaseSpider):
session = requests.Session()
host = 'https://hscka.cc'
def __init__(self):
super().__init__()
self._categories_cache = None
self._m3u_lock = threading.Lock()
self._debug = True # 开启调试日志
# ===== 持久化存储配置 =====
self._data_dir = '/sdcard' if os.path.exists('/sdcard') else '.'
self._saved_data_file = os.path.join(self._data_dir, '.hscka_saved.json')
# 加载已保存数据: {vid: {name, url, pic, cat, type, time}}
self._saved_videos = self._load_saved_data()
self._log(f'已加载历史记录: {len(self._saved_videos)}')
def _log(self, msg):
if self._debug:
print(f'[hscka] {msg}')
def _load_saved_data(self):
"""从JSON文件加载已保存的视频记录"""
if os.path.exists(self._saved_data_file):
try:
with open(self._saved_data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
self._log(f'加载历史数据失败: {e}')
return {}
def _save_data(self):
"""保存视频记录到JSON文件"""
try:
with open(self._saved_data_file, 'w', encoding='utf-8') as f:
json.dump(self._saved_videos, f, ensure_ascii=False, indent=2)
except Exception as e:
self._log(f'保存历史数据失败: {e}')
def getName(self): return 'hscka'
def isVideoFormat(self, url):
if not url: return False
return '.m3u8' in url or '.mp4' in url or '.ts' in url or url.startswith('magnet:')
def manualVideoCheck(self): return False
def destroy(self): pass
def localProxy(self, param):
return [404, 'text/plain', '']
def init(self, extend=''):
self.session.verify = False
self.session.headers.update(self._get_headers())
_start_proxy()
text = self._fetch(self.host)
if text:
self._load_categories(text)
def _get_headers(self, referer=None):
"""获取完整的请求头,模拟真实浏览器"""
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,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',
'Cache-Control': 'max-age=0',
}
if referer:
headers['Referer'] = referer
else:
headers['Referer'] = self.host + '/'
return headers
def _proxy_url(self, url):
if not url: return ''
if url.startswith('http://127.0.0.1'):
return url
return f'http://127.0.0.1:{_proxy_port}/{quote(url, safe="")}'
def _fetch(self, url, referer=None, retries=3):
"""增强版请求,支持重试和随机延迟"""
for attempt in range(retries):
try:
if referer is None:
referer = self.host + '/'
headers = self._get_headers(referer)
if attempt > 0:
time.sleep(random.uniform(0.5, 1.5))
r = self.session.get(url, headers=headers, timeout=30, verify=False)
r.encoding = 'utf-8'
if r.status_code == 200:
return r.text
elif r.status_code in [403, 429, 503]:
self._log(f'请求被拦截 [{r.status_code}],第{attempt+1}次重试: {url}')
continue
else:
self._log(f'请求失败 [{r.status_code}]: {url}')
return ''
except requests.exceptions.Timeout:
self._log(f'请求超时,第{attempt+1}次重试: {url}')
except Exception as e:
self._log(f'请求异常 [{e}],第{attempt+1}次重试: {url}')
return ''
@staticmethod
def _decode_b64(encoded_str):
try:
raw = base64.b64decode(encoded_str)
return raw.decode('utf-8')
except:
return encoded_str
# ----- 分类加载 -----
def _load_categories(self, text):
if not text:
return []
cats = []
seen = set()
pattern = r'href="(/list/\d+-\d+\.html)"[^>]*>\s*<script[^>]*>document\.write\(d\(\'([A-Za-z0-9+/=]+)\'\)\);</script>'
for path, b64_name in re.findall(pattern, text, re.S):
name = self._decode_b64(b64_name)
name = re.sub(r'<[^>]+>', '', name).strip()
if not name or name in seen:
continue
seen.add(name)
tid = path.split('/')[-1].split('-')[0]
cats.append({'type_id': tid, 'type_name': name})
self._categories_cache = cats
return cats
def _get_category_name(self, tid):
for cat in self._categories_cache or []:
if cat['type_id'] == tid:
return cat['type_name']
return tid
# ----- 列表解析 -----
def _parse_list(self, html):
items = []
cards = re.findall(r'<div class="item item-post">\s*(.*?)\s*</div>', html, re.S)
for card in cards:
a_match = re.search(r'<a href="([^"]+)"', card)
if not a_match:
continue
href = a_match.group(1)
img_match = re.search(r'<img[^>]+(?:data-original|src)="([^"]+)"', card)
pic = img_match.group(1) if img_match else ''
title = ''
title_match = re.search(r'<h3 class="name">(.*?)</h3>', card, re.S)
if title_match:
title_raw = title_match.group(1)
b64 = re.search(r"document\.write\(d\('([A-Za-z0-9+/=]+)'\)\)", title_raw)
if b64:
title = self._decode_b64(b64.group(1))
title = re.sub(r'<[^>]+>', '', title).strip()
else:
title = re.sub(r'<[^>]+>', '', title_raw).strip()
if href.startswith('magnet:'):
items.append({
'vod_id': href,
'vod_name': title or '磁力资源',
'vod_pic': self._proxy_url(pic),
'vod_remarks': '磁力',
})
elif '/torrent/' in href:
vid = href.split('/')[-1].replace('.html', '')
items.append({
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(pic),
'vod_remarks': '磁力',
})
elif '/video/' in href:
vid = href.split('/')[-1].replace('.html', '')
items.append({
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(pic),
'vod_remarks': '',
})
return items
def _get_list(self, tid, page):
url = f'{self.host}/list/{tid}-{page}.html'
html = self._fetch(url, referer=f'{self.host}/list/{tid}-1.html')
if not html:
return []
return self._parse_list(html)
# ----- 首页 -----
def homeContent(self, filter):
try:
text = self._fetch(self.host)
if text:
self._load_categories(text)
cats = self._categories_cache or []
items = []
if cats:
items = self._get_list(cats[0]['type_id'], 1)
return {
'class': cats,
'filters': {},
'type': '影视',
'list': items,
'page': 1,
'pagecount': 1,
'limit': len(items),
'total': len(items)
}
except Exception as e:
self._log(f'homeContent 异常: {e}')
return {
'class': [], 'filters': {}, 'type': '影视',
'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0
}
def homeVideoContent(self):
if self._categories_cache:
return {'list': self._get_list(self._categories_cache[0]['type_id'], 1)}
return {'list': []}
# ----- 分类内容 -----
def categoryContent(self, tid, pg, filter, extend):
try:
page = int(pg) if pg else 1
items = self._get_list(tid, page)
total_page = page + 1
if page == 1:
html = self._fetch(f'{self.host}/list/{tid}-1.html')
if html:
pages = re.findall(r'/list/\d+-(\d+)\.html', html)
if pages:
total_page = max(int(p) for p in pages)
cat_name = self._get_category_name(tid)
# 后台导出到M3U和TXT(持久化去重)
threading.Thread(target=self._export_page_to_files, args=(items, cat_name), daemon=True).start()
return {
'list': items, 'page': page, 'pagecount': total_page,
'limit': len(items), 'total': total_page * len(items)
}
except Exception as e:
self._log(f'categoryContent 异常: {e}')
return {
'list': [], 'page': int(pg) if pg else 1,
'pagecount': 1, 'limit': 0, 'total': 0
}
# ===== 文件导出(M3U + 磁力TXT,持久化去重) =====
def _export_page_to_files(self, items, cat_name):
"""导出当前页视频到M3U和TXT,支持跨会话去重和替换"""
if not items:
return
safe_name = re.sub(r'[\\/:*?"<>|]', '_', cat_name)
updated = False
# 遍历当前页item,更新持久化数据
for item in items:
vid = item['vod_id']
play_url = self._resolve_play_url(item)
if not play_url:
continue
is_magnet = vid.startswith('magnet:')
existing = self._saved_videos.get(vid)
# 如果已存在且URL完全相同 -> 忽略(跳过)
if existing and existing.get('url') == play_url:
continue
# 否则:新增或替换(更新)
self._saved_videos[vid] = {
'name': item['vod_name'],
'url': play_url,
'pic': item.get('vod_pic', ''),
'cat': cat_name,
'type': 'magnet' if is_magnet else 'video',
'time': datetime.now().isoformat()
}
updated = True
action = '新增' if not existing else '替换'
self._log(f'{action}记录: {item["vod_name"][:30]}... ({vid[:20]}...)')
if not updated:
self._log(f'分类[{cat_name}]无新数据,跳过写入')
return
# 保存JSON索引
self._save_data()
with self._m3u_lock:
# ---- 重写该分类的M3U文件(只含非磁力视频)----
m3u_file = os.path.join(self._data_dir, f'{safe_name}.m3u')
with open(m3u_file, 'w', encoding='utf-8') as f:
f.write('#EXTM3U\n')
count = 0
for vid, data in self._saved_videos.items():
if data.get('cat') == cat_name and data.get('type') != 'magnet':
f.write(f'#EXTINF:-1 tvg-logo="{data["pic"]}" group-title="{cat_name}",{data["name"]}\n')
f.write(f'{data["url"]}\n')
count += 1
self._log(f'已重写M3U: {m3u_file} ({count}条)')
# ---- 重写磁力链接TXT文件(汇总所有分类的磁力)----
txt_file = os.path.join(self._data_dir, '磁力链接.txt')
with open(txt_file, 'w', encoding='utf-8') as f:
f.write('# ==========================================\n')
f.write('# 磁力链接汇总文件\n')
f.write(f'# 生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
f.write('# 提示: 请使用支持云播放/离线下载的播放器或迅雷打开\n')
f.write('# ==========================================\n\n')
mag_count = 0
for vid, data in self._saved_videos.items():
if data.get('type') == 'magnet':
f.write(f'{data["name"]}\n')
f.write(f'{data["url"]}\n')
f.write(f'# 分类: {data.get("cat", "未知")} | 保存时间: {data.get("time", "未知")}\n')
f.write('-' * 50 + '\n')
mag_count += 1
self._log(f'已重写磁力TXT: {txt_file} ({mag_count}条)')
def _resolve_play_url(self, item):
vid = item['vod_id']
if vid.startswith('magnet:'):
return vid
detail = self._fetch_detail(vid)
if not detail or not detail.get('vod_play_url'):
return ''
first_line = detail['vod_play_url'].split('#')[0]
if '$' in first_line:
return first_line.split('$', 1)[1]
return first_line
# ----- 详情 (核心修复:移除'在线播放',统一用'备用播放') -----
def _fetch_detail(self, vid):
if vid.startswith('magnet:'):
return {'vod_play_url': f'磁力${vid}'}
url_patterns = [
f'{self.host}/video/{vid}.html',
f'{self.host}/torrent/{vid}.html',
f'{self.host}/v/{vid}.html',
f'{self.host}/movie/{vid}.html',
f'{self.host}/play/{vid}.html',
]
for url in url_patterns:
self._log(f'尝试获取详情: {url}')
html = self._fetch(url, referer=self.host)
if html and ('video' in html or 'play' in html or 'magnet' in html or 'm3u8' in html or 'mp4' in html):
result = self._parse_detail(html, vid, url)
if result and result.get('vod_play_url'):
self._log(f'成功解析详情: {vid}')
return result
self._log(f'无法获取详情: {vid}')
return None
def _parse_detail(self, html, vid, base_url):
"""增强版详情解析:移除'在线播放',统一为'备用播放',增强各类链接提取"""
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
if m:
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>([^<]+)</title>', html)
if m:
title = m.group(1).strip()
cover = ''
m = re.search(r'<meta[^>]*property="og:image"[^>]*content="([^"]+)"', html)
if m:
cover = m.group(1)
if not cover:
m = re.search(r'<img[^>]*class="thumb"[^>]*src="([^"]+)"', html)
if m:
cover = m.group(1)
if not cover:
m = re.search(r'<img[^>]*class="poster"[^>]*src="([^"]+)"', html)
if m:
cover = m.group(1)
play_urls = []
seen_urls = set() # 用于去重
def _add_url(label, url):
"""辅助函数:添加播放链接,自动去重"""
if url in seen_urls:
return False
seen_urls.add(url)
play_urls.append(f'{label}${url}')
self._log(f'解析到[{label}]: {url[:80]}...')
return True
# 1. 磁力链接
for mag in set(re.findall(r'magnet:\?xt=urn:btih:[A-Za-z0-9]+[^\s"\'<>]*', html)):
_add_url('磁力', mag)
# 3. 备用播放:相对路径的 play.php
for link in set(re.findall(r'href=["\']?(/[^"\'<>\s]*play\.php[^"\'<>\s]*)', html)):
full_link = urljoin(base_url, link)
_add_url('备用播放', full_link)
# 4. 备用播放:引号中的 play.php(更宽松的匹配)
for link in set(re.findall(r'["\']([^"\']*play\.php[^"\']*)["\']', html)):
if link.startswith('http'):
_add_url('备用播放', link)
# 5. iframe(支持单双引号、data-src
iframe_pattern = r'<iframe[^>]+(?:src|data-src)=["\']([^"\']+)["\']'
for src in set(re.findall(iframe_pattern, html)):
if any(k in src for k in ['play.php', 'm3u8', 'mp4', 'embed', 'player']):
full_src = src if src.startswith('http') else urljoin(base_url, src)
_add_url('外链', full_src)
# 6. 媒体直链(支持带参数)
for media in set(re.findall(r'https?://[^\s"\'<>]+\.(?:m3u8|mp4|flv|mkv|ts)(?:\?[^\s"\'<>]*)?', html)):
_add_url('直链', media)
# 7. 从 script 标签中提取 JSON/变量中的播放链接
script_tags = re.findall(r'<script[^>]*>(.*?)</script>', html, re.S)
for script in script_tags:
# 提取 JSON 中的 url/src/playUrl/videoUrl 字段
for match in re.findall(r'["\'](?:url|src|playUrl|videoUrl|file|source)["\']\s*:\s*["\']([^"\']+)["\']', script):
if any(ext in match for ext in ['.m3u8', '.mp4', 'play.php', 'magnet:', '.flv', '.ts']):
full_match = match if match.startswith('http') else urljoin(base_url, match)
_add_url('JS解析', full_match)
# 提取 base64 编码的链接
for b64 in re.findall(r'["\']([A-Za-z0-9+/]{20,}={0,2})["\']', script):
try:
decoded = base64.b64decode(b64).decode('utf-8')
if decoded.startswith('http') and any(ext in decoded for ext in ['.m3u8', '.mp4', 'play.php', '.flv']):
_add_url('Base64解码', decoded)
except:
pass
# 提取 AES 加密的数据
aes_pattern = r'["\']([A-Za-z0-9+/]{50,}={0,2})["\']'
for aes_b64 in re.findall(aes_pattern, script):
try:
raw = base64.b64decode(aes_b64)
if len(raw) % 16 == 0 and len(raw) >= 16:
common_keys = [
(b'1234567890123456', b'1234567890123456'),
(b'0123456789abcdef', b'0123456789abcdef'),
]
for key, iv in common_keys:
try:
decrypted = _aes_cbc_decrypt(raw, key, iv)
dec_str = decrypted.decode('utf-8')
if dec_str.startswith('http') and any(ext in dec_str for ext in ['.m3u8', '.mp4', 'play.php']):
_add_url('AES解码', dec_str)
break
except:
continue
except:
pass
# 8. 从 video/source 标签提取
for media in set(re.findall(r'<(?:video|source)[^>]+src=["\']([^"\']+)["\']', html)):
if any(ext in media for ext in ['.m3u8', '.mp4', '.flv', '.ts']):
full_media = media if media.startswith('http') else urljoin(base_url, media)
_add_url('HTML5', full_media)
# 9. 从 a 标签的 data-url / data-src / data-link 提取
for media in set(re.findall(r'<a[^>]+(?:data-url|data-src|data-link)=["\']([^"\']+)["\']', html)):
if any(ext in media for ext in ['.m3u8', '.mp4', 'play.php', 'magnet:']):
full_media = media if media.startswith('http') else urljoin(base_url, media)
_add_url('数据属性', full_media)
# 10. 从 onclick 属性提取
for onclick in set(re.findall(r'onclick=["\'][^"\']*(https?://[^"\'<>]+)["\']', html)):
if any(ext in onclick for ext in ['.m3u8', '.mp4', 'play.php']):
_add_url('点击播放', onclick)
if not play_urls:
self._log(f'未找到任何播放链接: {vid}')
return None
self._log(f'共解析到 {len(play_urls)} 个播放源')
# 构建 TVBox 标准格式的播放数据
sources = []
urls = []
for i, pu in enumerate(play_urls):
if '$' in pu:
source_name, url = pu.split('$', 1)
else:
source_name = f'线路{i+1}'
url = pu
sources.append(source_name)
urls.append(f'{source_name}${url}')
return {
'vod_id': vid,
'vod_name': title or vid,
'vod_pic': self._proxy_url(cover) if cover else '',
'vod_play_from': '$$$'.join(sources),
'vod_play_url': '#'.join(urls),
'vod_content': title or '',
}
def detailContent(self, ids):
try:
vid = str(ids[0] if isinstance(ids, list) else ids)
if vid.startswith('magnet:'):
return {
'list': [{
'vod_id': vid,
'vod_name': '磁力资源',
'vod_pic': '',
'vod_play_from': '磁力',
'vod_play_url': f'磁力${vid}',
'vod_content': '磁力链接(建议配合云播放/离线下载使用)',
}]
}
detail = self._fetch_detail(vid)
if not detail:
self._log(f'detailContent 获取详情失败: {vid}')
return {'list': []}
return {'list': [detail]}
except Exception as e:
self._log(f'detailContent 异常: {e}')
return {'list': []}
# ----- 播放 -----
def playerContent(self, flag, id, vipFlags=None):
try:
if id.startswith('magnet:'):
return {'parse': 0, 'url': id, 'header': {}}
# 外部播放链接,直接返回让播放器请求
if 'play.php' in id or 'm3u8' in id or 'mp4' in id or 'flv' in id or 'ts' in id:
return {
'parse': 0,
'url': id,
'header': {
'Referer': self.host,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Origin': self.host,
}
}
return {
'parse': 0,
'url': id,
'header': {
'Referer': self.host,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
}
except Exception as e:
self._log(f'playerContent 异常: {e}')
return {'parse': 0, 'url': '', 'header': {}}
# ----- 搜索 -----
def searchContent(self, key, quick, pg='1'):
try:
page = int(pg) if pg else 1
# 视频搜索
url = f'{self.host}/search.php?content={quote(key)}&type=1&page={page}'
html = self._fetch(url, referer=self.host)
items = self._parse_list(html) if html else []
if not items:
# 磁力搜索
url = f'{self.host}/search.php?content={quote(key)}&type=2&page={page}'
html = self._fetch(url, referer=self.host)
items = self._parse_list(html) if html else []
return {
'list': items, 'page': page, 'pagecount': page + 1,
'limit': len(items), 'total': page * len(items)
}
except Exception as e:
self._log(f'searchContent 异常: {e}')
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}