Sync all projects
This commit is contained in:
+275
-86
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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']
|
||||
@@ -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
@@ -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 返回的真实 totalPage(pageSize 固定 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}
|
||||
|
||||
@@ -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 返回的真实 totalPage(pageSize 固定 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']
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user