Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-22 14:45:04 +00:00
parent fb3776ddc4
commit 50f3df720b
64 changed files with 21626 additions and 24957 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+724
View File
@@ -0,0 +1,724 @@
# -*- coding: utf-8 -*-
import sys
import re
import json
import base64
import threading
import requests
import urllib3
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import unquote, quote
urllib3.disable_warnings()
sys.path.append('..')
from base.spider import Spider
# ===== 纯 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)
# ===== 全局代理服务(封面图走代理绕过 SSL) =====
_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 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://mjv011.com/',
}
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
# ===== 内容类型判断 =====
def _is_novel(tid_or_vid):
if not tid_or_vid: return False
return tid_or_vid.startswith('novel')
def _is_image(tid_or_vid):
if not tid_or_vid: return False
return any(tid_or_vid.startswith(p) for p in ['18H', 'doujin', 'cg', 'cwp'])
# ===== Spider =====
class Spider(Spider):
session = requests.Session()
host = 'https://mjv011.com'
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': 'https://mjv011.com/',
}
def getName(self): return "mjv011"
def isVideoFormat(self, url):
if not url: return False
return '.m3u8' in url or '.mp4' in url or '.ts' in url
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
_start_proxy()
try:
self.session.get(
f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html',
headers=self.headers, timeout=15, verify=False)
except Exception:
pass
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):
try:
r = self.session.get(url, headers=self.headers, timeout=20, verify=False)
r.encoding = 'utf-8'
if r.status_code == 200:
text = r.text
# 年龄验证页检测:自动绕过
if len(text) < 3000 and ('同意(enter)' in text or 'IamOverEighteenYearsOld' in text):
self.session.get(
f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html',
headers=self.headers, timeout=20, verify=False)
r = self.session.get(url, headers=self.headers, timeout=20, verify=False)
r.encoding = 'utf-8'
if r.status_code == 200:
return r.text
return ''
return text
return ''
except Exception:
return ''
# ===== 列表解析 =====
def _parse_text_posts(self, text):
"""小说列表(无图)"""
items = []
for m in re.finditer(r"<div class='post'>\s*<div class='con'>\s*<h3[^>]*><a[^>]*href=\"([^\"]+)\"[^>]*>([^<]+)</a></h3>", text):
href, title = m.groups()
mm = re.search(r'/([^/]+)_content/(\d+)/([^/]+)\.html', href)
if not mm: continue
ctype, vid, slug = mm.group(1), mm.group(2), mm.group(3)
items.append({
'vod_id': f'{ctype}#{vid}#{slug}',
'vod_name': title.strip(),
'vod_pic': '',
'vod_remarks': '',
})
return items
def _parse_posts(self, text, tid=''):
"""根据分类解析列表:小说单独处理,其他统一按带图 post 处理"""
if _is_novel(tid):
return self._parse_text_posts(text)
items = []
for m in re.finditer(r"<div class='post'>\s*<a[^>]*href=\"([^\"]+)\"[^>]*><img[^>]*src='([^']+)'[^>]*>\s*</a>\s*<div class='con'>\s*<h3[^>]*><a[^>]*>([^<]+)</a></h3>(?:\s*<div class='meta'>([^<]*)</div>)?", text):
href, pic, title, date = m.groups()
date = date or ''
mm = re.search(r'/([^/]+)_content/(\d+)/([^/]+)\.html', href)
if not mm: continue
ctype, vid, slug = mm.group(1), mm.group(2), mm.group(3)
items.append({
'vod_id': f'{ctype}#{vid}#{slug}',
'vod_name': title.strip(),
'vod_pic': self._proxy_url(pic),
'vod_remarks': date.strip(),
})
return items
def _build_cat_url(self, tid, page, extend=None):
if extend and extend.get('sub'):
sub = quote(unquote(extend['sub']), safe='/()')
return f'{self.host}/zh/{sub}/{page}.html'
if tid.startswith('search_'):
kw = tid[7:]
return f'{self.host}/zh/chinese_search/all/{kw}/{page}.html'
# TVBox 可能截断 type_id,自动补全
if tid.endswith('_random'):
tid = tid + '/all'
elif '/' not in tid:
tid = tid + '_random/all'
if page == 1:
return f'{self.host}/zh/{tid}/index.html'
return f'{self.host}/zh/{tid}/index_{page}.html'
# ===== 接口 =====
def homeContent(self, filter):
classes = [
{'type_id': 'chinese_random/all', 'type_name': '中文字幕'},
{'type_id': 'censored_random/all', 'type_name': '有码'},
{'type_id': 'uncensored_random/all', 'type_name': '无码'},
{'type_id': 'reducing-mosaic_random/all', 'type_name': '无码破解'},
{'type_id': 'amateurjav_random/all', 'type_name': '素人'},
{'type_id': 'animation_random/all', 'type_name': 'H动画'},
{'type_id': 'dt_random/all', 'type_name': '国产自拍'},
{'type_id': '18H_random/all', 'type_name': '18H漫画'},
{'type_id': 'cg_random/all', 'type_name': '写真图集'},
{'type_id': 'cwp_random/all', 'type_name': '国产写真'},
{'type_id': 'novel_random/all', 'type_name': '小说'},
]
filter = self._build_filters()
return {'class': classes, 'filters': filter, 'type': '影视'}
def _build_filters(self):
"""构造 TVBox filter 格式(网站左侧导航真实子分类)"""
filters = {}
chinese_opts = [
{'n': '全部', 'v': ''},
{'n': '随机', 'v': 'chinese_randomall/all'},
{'n': '类别清单', 'v': 'chinese_categorylist/list'},
]
filters['chinese_random/all'] = [{'key': 'sub', 'name': '中文字幕', 'value': chinese_opts}]
uncensored_opts: list[dict[str, str]] = [
{'n': '全部', 'v': ''},
{'n': '一本道(1pondo)', 'v': 'uncensored_makersr/32/一本道(1pondo)'},
{'n': 'カリビアンコム(Caribbeancom)', 'v': 'uncensored_makersr/30/カリビアンコム(Caribbeancom)'},
{'n': 'カリビアンコムPPV', 'v': 'uncensored_makersr/40/カリビアンコムPPV(Caribbeancompr)'},
{'n': '天然むすめ(10musume)', 'v': 'uncensored_makersr/31/天然むすめ(10musume)'},
{'n': 'HEYZO', 'v': 'uncensored_makersr/17/HEYZO'},
{'n': '東京熱(Tokyo Hot)', 'v': 'uncensored_makersr/29/東京熱(Tokyo Hot)'},
{'n': 'ガチん娘!(Gachinco)', 'v': 'uncensored_makersr/35/ガチん娘!(Gachinco)'},
{'n': 'パコパコママ(pacopacomama)', 'v': 'uncensored_makersr/36/パコパコママ(pacopacomama)'},
{'n': 'エッチな4610', 'v': 'uncensored_makersr/34/エッチな4610'},
{'n': '人妻斬り0930', 'v': 'uncensored_makersr/38/人妻斬り0930'},
{'n': 'エッチな0930', 'v': 'uncensored_makersr/39/エッチな0930'},
{'n': 'トリプルエックス(XXX-AV)', 'v': 'uncensored_makersr/126/トリプルエックス (XXX-AV)'},
]
filters['uncensored_random/all'] = [{'key': 'sub', 'name': '厂商', 'value': uncensored_opts}]
animation_opts = [
{'n': '全部', 'v': ''},
{'n': 'H有码动画', 'v': 'CensoredAnimation_random/all'},
{'n': 'H无码动画', 'v': 'UncensoredAnimation_random/all'},
{'n': 'H_3D动画', 'v': 'tdAnimation_random/all'},
]
filters['animation_random/all'] = [{'key': 'sub', 'name': '动漫', 'value': animation_opts}]
comic_opts = [
{'n': '全部', 'v': ''},
{'n': '短篇同人', 'v': 'doujin_random/all'},
]
filters['18H_random/all'] = [{'key': 'sub', 'name': '漫画', 'value': comic_opts}]
cg_opts = [
{'n': '全部', 'v': ''},
{'n': 'Bejean On Line', 'v': 'cg_search/all/Bejean On Line'},
{'n': 'Bomb.tv', 'v': 'cg_search/all/Bomb.tv'},
{'n': 'DGC', 'v': 'cg_search/all/DGC'},
{'n': 'Graphis Gals', 'v': 'cg_search/all/Graphis Gals'},
{'n': 'Graphis Hatsunugi', 'v': 'cg_search/all/Graphis Hatsunugi'},
{'n': 'image.tv', 'v': 'cg_search/all/image.tv'},
{'n': 'Sabra.net', 'v': 'cg_search/all/Sabra.net'},
{'n': 'S-Cute', 'v': 'cg_search/all/S-Cute'},
{'n': 'X-City', 'v': 'cg_search/all/X-City'},
{'n': 'YS Web', 'v': 'cg_search/all/YS Web'},
]
filters['cg_random/all'] = [{'key': 'sub', 'name': '系列', 'value': cg_opts}]
cwp_opts = [
{'n': '全部', 'v': ''},
{'n': '3AGirl AAA女郎', 'v': 'cwp_search/all/3AGirl AAA女郎'},
{'n': 'ROSI寫真', 'v': 'cwp_search/all/ROSI寫真'},
{'n': 'RU1MM 如壹寫真', 'v': 'cwp_search/all/RU1MM 如壹寫真'},
{'n': 'DISI第四印象', 'v': 'cwp_search/all/DISI第四印象'},
]
filters['cwp_random/all'] = [{'key': 'sub', 'name': '系列', 'value': cwp_opts}]
novel_opts = [
{'n': '全部', 'v': ''},
{'n': '學生校園', 'v': 'novel_search/all/學生校園'},
{'n': '職場激情', 'v': 'novel_search/all/職場激情'},
{'n': '經驗故事', 'v': 'novel_search/all/經驗故事'},
{'n': '暴力虐待', 'v': 'novel_search/all/暴力虐待'},
{'n': '不倫戀情', 'v': 'novel_search/all/不倫戀情'},
{'n': '群體換伴', 'v': 'novel_search/all/群體換伴'},
{'n': '人妻熟女', 'v': 'novel_search/all/人妻熟女'},
{'n': '科學幻想', 'v': 'novel_search/all/科學幻想'},
{'n': '其他故事', 'v': 'novel_search/all/其他故事'},
{'n': '玄幻仙俠', 'v': 'novel_search/all/玄幻仙俠'},
{'n': '動漫修改', 'v': 'novel_search/all/動漫修改'},
{'n': '長篇連載', 'v': 'novel_search/all/長篇連載'},
]
filters['novel_random/all'] = [{'key': 'sub', 'name': '主题', 'value': novel_opts}]
return filters
def homeVideoContent(self):
text = self._fetch(f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html')
items = self._parse_posts(text, 'content_news/all')
return {'list': items}
def categoryContent(self, tid, pg, filter, extend):
try:
return self._categoryContent_inner(tid, pg, filter, extend)
except Exception:
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def _categoryContent_inner(self, tid, pg, filter, extend):
if isinstance(extend, str):
try:
extend = json.loads(extend)
except Exception:
extend = {}
if not extend:
extend = {}
page = int(pg) if pg else 1
# ===== 类别清单(中文字幕的子分类): 文件夹形式(仿 18av) =====
if extend.get('sub') == 'chinese_categorylist/list':
return self._category_folder(page)
if '@' in str(tid):
return self._folder_detail(tid, page)
url = self._build_cat_url(tid, page, extend)
# 子分类筛选时按子分类内容类型解析
parse_tid = tid
if parse_tid.endswith('_random'):
parse_tid = parse_tid + '/all'
elif '/' not in parse_tid:
parse_tid = parse_tid + '_random/all'
if extend and extend.get('sub'):
sub = extend['sub']
if _is_novel(sub):
parse_tid = 'novel_random/all'
elif _is_image(sub):
parse_tid = sub.split('_')[0] + '_random/all' if '_' in sub else 'cg_random/all'
text = self._fetch(url)
items = self._parse_posts(text, parse_tid)
return {'list': items, 'page': page, 'pagecount': page + 1,
'limit': len(items), 'total': page * len(items) + 1}
# ===== 类别清单(文件夹形式,仿 18av) =====
def _category_folder(self, pg):
"""类别清单: 抓取子分类索引页,以 folder 形式返回,点文件夹进入视频列表"""
html = self._fetch(f'{self.host}/zh/chinese_categorylist/list/index.html')
lst = []
for m in re.finditer(
r"<a[^>]+href=[\"']([^\"']*chinese_category/(\d+)/([^\"'/]+)/[^\"']*)[\"'][^>]*>([^<]+)</a>",
html, re.I):
category_url = m.group(1).strip()
category_name = m.group(4).strip()
if category_url.startswith(self.host):
category_url = category_url[len(self.host):]
lst.append({
'vod_id': category_url + '@',
'vod_name': category_name,
'vod_pic': self.host.rstrip('/') + '/images/1v.jpg',
'vod_tag': 'folder',
'vod_remarks': '分类',
})
return {'list': lst, 'page': 1, 'pagecount': 1,
'limit': len(lst), 'total': len(lst)}
def _folder_detail(self, tid, pg):
"""@ 文件夹: 去掉 @, 把子分类链接当作真实 URL 拉取视频列表(支持翻页)"""
tid = str(tid).replace('@', '')
url = self.host.rstrip('/') + tid
if re.search(r'/\d+\.html$', url):
url = re.sub(r'/\d+\.html$', '/' + str(pg) + '.html', url)
else:
base = url.rstrip('/')
url = base + ('/index.html' if pg <= 1 else f'/index_{pg}.html')
html = self._fetch(url)
lst = self._parse_posts(html, 'chinese_category')
return {'list': lst, 'page': pg, 'pagecount': 9999,
'limit': len(lst), 'total': 9999}
def detailContent(self, ids):
try:
return self._detailContent_inner(ids)
except Exception:
return {'list': []}
def _detailContent_inner(self, ids):
vid = str(ids[0] if isinstance(ids, list) else ids)
ctype, num, slug = vid.split('#', 2)
if _is_novel(ctype):
return self._novel_detail(vid, ctype, num, slug)
elif _is_image(ctype):
return self._image_detail(vid, ctype, num, slug)
else:
return self._video_detail(vid, ctype, num, slug)
def _video_detail(self, vid, ctype, num, slug):
"""视频详情(有播放器解密)"""
url = f'{self.host}/zh/{ctype}_content/{num}/{slug}.html'
text = self._fetch(url)
if not text: return {'list': []}
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', text, re.S)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>([^<]+)</title>', text)
if m: title = m.group(1).strip()
cover = ''
m = re.search(r'"thumbnailUrl"\s*:\s*"([^"]+)"', text)
if m: cover = m.group(1)
if not cover:
m = re.search(r"<meta[^>]*property=\"og:image\"[^>]*content=\"([^\"]+)\"", text)
if m: cover = m.group(1)
if not cover:
m = re.search(r"<div class='post'>.*?<img[^>]*src='([^']+)'", text, re.S)
if m: cover = m.group(1)
m = re.search(r'hadeedg252=(\d+)', text)
if not m: return {'list': []}
hadeedg252 = int(m.group(1))
m = re.search(r'hcdeedg252=(\d+)', text)
if not m: return {'list': []}
hcdeedg252 = int(m.group(1))
m = re.search(r"var argdeqweqweqwe = '([^']+)'", text)
if not m: return {'list': []}
aes_key = m.group(1)
m = re.search(r"var hdddedg252 = '([^']+)'", text)
if not m: return {'list': []}
aes_iv = m.group(1)
mm = re.search(r"mvarr\['10_1'\]=(\[.*?\]);", text, re.S)
if not mm: return {'list': []}
mvarr_str = mm.group(1)
items = re.findall(r"\['([^']*)','([^']*)','([^']*)','([^']*)','([^']*)','([^']*)'\]", mvarr_str)
if not items: return {'list': []}
urls = []
for iframe_id, enc, html, prefix, empty, label in items:
if not enc or not prefix: continue
pid = self._decrypt_id(enc, hadeedg252, hcdeedg252, aes_key, aes_iv)
if not pid: continue
for res, label_name in [('1080', '1080P'), ('720', '720P'), ('480', '480P')]:
urls.append(f'{label_name}${num}|{slug}|{pid}|{res}')
if not urls: return {'list': []}
vod = {
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(cover),
'vod_content': '',
'vod_remarks': '',
'vod_play_from': 'mjv011',
'vod_play_url': '#'.join(urls),
}
return {'list': [vod]}
def _novel_detail(self, vid, ctype, num, slug):
"""小说详情"""
url = f'{self.host}/zh/{ctype}_content/{num}/{slug}.html'
text = self._fetch(url)
if not text: return {'list': []}
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', text, re.S)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
content = ''
m = re.search(r"id=['\"]novel_content_txtsize['\"][^>]*>(.*?)</div>", text, re.S)
if m:
raw = m.group(1)
content = re.sub(r'<[^>]+>', '', raw)
content = re.sub(r'&nbsp;', ' ', content)
content = re.sub(r'\s+', ' ', content).strip()
if not content:
m = re.search(r"<span class='content_18h_wpcg'>([\s\S]*?)</span>\s*<div class='contents'", text)
if m:
raw = m.group(1)
content = re.sub(r'<[^>]+>', '', raw)
content = re.sub(r'&nbsp;', ' ', content)
content = re.sub(r'\s+', ' ', content).strip()
if len(content) > 3000:
content = content[:3000] + '...'
novel_json = json.dumps({'title': title, 'content': content}, ensure_ascii=False)
play_url = f'阅读$novel://{novel_json}'
vod = {
'vod_id': vid,
'vod_name': title,
'vod_pic': '',
'vod_content': '',
'vod_remarks': '',
'vod_play_from': '小说',
'vod_play_url': play_url,
'vod_tag': 'text',
'vod_player': '',
}
return {'list': [vod]}
def _image_detail(self, vid, ctype, num, slug):
"""图片详情(写真/漫画)"""
url = f'{self.host}/zh/{ctype}_content/{num}/{slug}.html'
text = self._fetch(url)
if not text: return {'list': []}
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', text, re.S)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
cover = ''
m = re.search(r'"thumbnailUrl"\s*:\s*"([^"]+)"', text)
if m: cover = m.group(1)
# 提取详情页所有大图(第一页已包含全部)
all_imgs = re.findall(r"src=['\"]([^'\"]*eemmhh02\.com/[^'\"]+\.(?:jpg|png|webp))['\"]", text, re.I)
big_imgs = []
seen = set()
for img in all_imgs:
if img in seen:
continue
seen.add(img)
big_imgs.append(self._proxy_url(img))
if not big_imgs:
return {'list': []}
pics = '&&'.join(big_imgs)
play_url = f'查看$pics://{pics}'
vod = {
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(cover),
'vod_content': f'{len(big_imgs)} 张图片',
'vod_remarks': str(len(big_imgs)) + 'P',
'vod_play_from': '图片',
'vod_play_url': play_url,
'vod_tag': 'image',
'vod_player': '',
}
return {'list': [vod]}
def _decrypt_id(self, enc, xor_key, base, aes_key, aes_iv):
try:
sep = chr(base + 97)
parts = enc.split(sep)
s1 = ''.join(chr(int(p, base) ^ xor_key) for p in parts if p)
data = base64.b64decode(s1)
plain = _aes_cbc_decrypt(data, aes_key.encode(), aes_iv.encode())
return plain.decode('utf-8')
except Exception:
return ''
def searchContent(self, key, quick, pg="1"):
try:
return self._searchContent_inner(key, quick, pg)
except Exception:
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def _searchContent_inner(self, key, quick, pg="1"):
page = int(pg) if pg else 1
return self._categoryContent_inner(f'search_{key}', page, False, {})
def playerContent(self, flag, id, vipFlags=None):
try:
return self._playerContent_inner(flag, id, vipFlags)
except Exception:
return {'parse': 0, 'url': '', 'header': {}, 'position': '0'}
def _playerContent_inner(self, flag, id, vipFlags=None):
# 小说
if id.startswith('novel://'):
return {'parse': 0, 'url': id, 'header': '', 'vod_player': ''}
# 图片
if id.startswith('pics://'):
return {'parse': 0, 'playUrl': '', 'url': id, 'header': self.headers}
# 视频播放
num, slug, pid, res = id.split('|', 3)
url = f'{self.host}/js/player/play.php?numresolution={res}&lo=on&id={pid}'
text = self._fetch(url)
m3u8 = ''
if text:
mm = re.search(r'videoSources\s*=\s*(\[.*?\]);', text, re.S)
if mm:
arr = mm.group(1)
sources = re.findall(r"src:\s*'([^']+)'[^}]*?size:\s*(\d+)", arr, re.S)
if sources:
target = int(res) if str(res).isdigit() else 0
for u, s in sources:
if int(s) == target:
m3u8 = u
break
if not m3u8:
m3u8 = sources[0][0]
if not m3u8:
mm = re.search(r'https?://[^\s"<>\']+?\.m3u8', text)
if mm: m3u8 = mm.group(0)
return {'parse': 0, 'url': m3u8, 'header': {'Referer': self.host + '/'}, 'position': '0'}
+385
View File
@@ -0,0 +1,385 @@
# -*- coding: utf-8 -*-
import sys
import re
import json
from urllib.parse import urljoin, quote, unquote, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
sys.path.append('..')
try:
from base.spider import Spider
except ImportError:
class Spider:
def fetch(self, url, headers=None, **kw):
import requests as rq
kw.pop('timeout', None)
r = rq.get(url, headers=headers, timeout=15, **kw)
r.encoding = 'utf-8'
return r
HOST = "https://www.58hu.com"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
# 封面URL中这些域名已失效,对应影片会被过滤掉
DEAD_IMG_HOSTS = {"image.caiji.cyou", "wim.xrc888.com"}
CATEGORIES = {
"1": "电影", "2": "电视剧", "3": "综艺",
"4": "动漫", "21": "体育",
}
# 子分类: {父分类ID: {子分类ID: 名称}}
SUB_CATS = {
"1": {"6": "动作片", "7": "喜剧片", "8": "爱情片", "9": "科幻片", "10": "恐怖片",
"11": "剧情片", "12": "战争片", "22": "纪录片"},
"2": {"13": "国产剧", "14": "港台剧", "15": "日韩剧", "16": "欧美剧", "24": "海外剧",
"29": "BI番剧", "30": "BI国创", "31": "BI电影"},
"3": {"28": "最新综艺"},
"4": {"27": "最新动漫"},
"21": {"26": "体育赛事"},
}
class Spider(Spider):
def init(self, extend=""):
global HOST
try:
r = self.fetch(HOST, headers={"User-Agent": UA}, timeout=15000)
if hasattr(r, 'url') and r.url and r.url != HOST.rstrip("/"):
HOST = r.url.rstrip("/")
except:
pass
def homeContent(self, filter=False):
r = {"class": [], "list": []}
for k, v in CATEGORIES.items():
r["class"].append({"type_id": k, "type_name": v})
return r
def homeVideoContent(self):
try:
r = self.fetch(HOST, headers={"User-Agent": UA}, timeout=15000)
html = r.text if hasattr(r, 'text') else str(r)
return {"list": self._items(html)}
except:
return {"list": []}
def categoryContent(self, tid, pg=1, filter=False, extend=""):
pn = 1
try: pn = max(int(str(pg)), 1)
except: pass
cat = str(tid)
try:
url = self._build_category_url(cat, pn, extend)
r = self.fetch(url, headers={"User-Agent": UA}, timeout=30000)
html = r.text if hasattr(r, 'text') else str(r)
cat_name = CATEGORIES.get(cat, "")
items = self._items(html, cat_filter=cat_name)
# 过滤掉没有封面或没有可播放线路的影片(并发检查)
items = self._filter_items(items)
pc = self._pagecount(html, pn)
return {"page": pn, "pagecount": pc, "limit": 30, "total": len(items), "list": items}
except:
return {"page": pn, "pagecount": 1, "limit": 30, "total": 0, "list": []}
def _build_category_url(self, cat, pn, extend=""):
"""构建分类URL,支持筛选参数"""
# 解析extend中的筛选条件
ext = {}
if extend and isinstance(extend, str) and extend.strip():
try:
ext = json.loads(extend)
except:
pass
# 判断是否为子分类(在SUB_CATS中)
is_sub = False
for parent, subs in SUB_CATS.items():
if cat in subs:
is_sub = True
break
# 所有分类用 vod/show 模式
parts = []
# class/类型
cls = ext.get("class") or ""
if cls and cls != "全部":
parts.append(f"class/{quote(cls)}")
# area/地区
area = ext.get("area") or ""
if area and area != "全部":
parts.append(f"area/{quote(area)}")
# year/年代
year = ext.get("year") or ""
if year and year != "全部":
parts.append(f"year/{quote(year)}")
# letter/字母
letter = ext.get("letter") or ""
if letter and letter != "全部":
parts.append(f"letter/{quote(letter)}")
# sort/排序
sort = ext.get("sort") or ""
if sort:
parts.append(f"by/{quote(sort)}")
# page
if pn > 1:
parts.append(f"page/{pn}")
filters = "/".join(parts)
if filters:
return f"{HOST}/index.php/vod/show/{filters}/id/{cat}.html"
else:
return f"{HOST}/index.php/vod/show/id/{cat}.html"
def detailContent(self, ids):
if isinstance(ids, list):
vid = ids[0] if ids else ""
else:
vid = str(ids) if ids else ""
m = re.search(r'(\d+)', str(vid))
vid = m.group(1) if m else ""
if not vid:
return {"list": []}
try:
r = self.fetch(f"{HOST}/index.php/vod/detail/id/{vid}.html", headers={"User-Agent": UA}, timeout=30000)
h = r.text if hasattr(r, 'text') else str(r)
except:
return {"list": []}
d = {"vod_id": vid, "vod_name": "", "vod_pic": "", "vod_year": "",
"vod_area": "", "vod_class": "", "vod_director": "", "vod_actor": "",
"vod_content": "", "vod_remarks": "", "vod_play_from": "", "vod_play_url": ""}
# 标题
tn = re.search(r'<h1[^>]*>(.*?)</h1>', h)
if tn:
d["vod_name"] = re.sub(r'<[^>]+>', '', tn.group(1)).strip()
if not d["vod_name"]:
tn = re.search(r'<title>(.*?)</title>', h)
if tn:
d["vod_name"] = tn.group(1).split("-")[0].strip()
# 封面: 优先 class="pic-img video-pic" 里的img
p = re.search(r'class="pic-img video-pic"[\s\S]{0,100}?<img[^>]*src="(https?://[^"]+)"', h, re.I)
if not p:
p = re.search(r'data-src="(https?://[^"]+)"', h)
if not p:
p = re.search(r'src="(https?://[^"]+\.(?:jpg|jpeg|png|webp))"', h, re.I)
if p:
pic_url = p.group(1)
if pic_url.startswith("http://"):
pic_url = pic_url.replace("http://", "https://", 1)
d["vod_pic"] = pic_url
# 简介
desc_m = re.search(r'简介[\s\S]{0,30}?>([\s\S]*?)</div>', h)
if desc_m:
d["vod_content"] = re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', '', desc_m.group(1))).strip()[:500]
if not d["vod_content"]:
desc_m = re.search(r'class="[^"]*desc[^"]*"[^>]*>([\s\S]*?)</div>', h)
if desc_m:
d["vod_content"] = re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', '', desc_m.group(1))).strip()[:500]
# 年份
ym = re.search(r'(\d{4})', d.get("vod_name", ""))
if ym: d["vod_year"] = ym.group(1)
# 地区/分类/导演/主演等
info_items = re.findall(r'<li[^>]*class="[^"]*data[^"]*"[^>]*>([\s\S]*?)</li>', h)
for item in info_items:
clean = re.sub(r'<[^>]+>', '', item).strip()
if '导演' in item and not d["vod_director"]:
d["vod_director"] = clean.replace('导演', '').strip().rstrip('').strip()
elif '主演' in item and not d["vod_actor"]:
d["vod_actor"] = clean.replace('主演', '').strip().rstrip('').strip()
elif '类型' in item and not d["vod_class"]:
d["vod_class"] = clean.replace('类型', '').strip()
elif '地区' in item and not d["vod_area"]:
d["vod_area"] = clean.replace('地区', '').strip()
# 备注
rm = re.search(r'class="[^"]*remarks?[^"]*"[^>]*>([^<]+)<', h)
if rm: d["vod_remarks"] = rm.group(1).strip()
# 播放源(只保留m3u8直链线路)
try:
pf, pu = [], []
line_map = {}
for sid, name in re.findall(r'id="#con_playlist_(\d+)"[^>]*class="gico[^"]*"[^>]*>([^<]+)</a>', h):
line_map[sid] = name.strip()
for m in re.finditer(r'<ul[^>]*id="con_playlist_(\d+)"[^>]*>([\s\S]*?)</ul>', h):
sid = m.group(1)
content = m.group(2)
eps = re.findall(r'href="(/index\.php/vod/play/id/\d+/sid/\d+/nid/\d+\.html)"[^>]*>([\s\S]*?)</a>', content)
if not eps:
continue
line_name = line_map.get(sid, f"线路{sid}")
# 预检第1集:只保留返回m3u8直链的线路
first_url = urljoin(HOST, eps[0][0])
try:
rp = self.fetch(first_url, headers={"User-Agent": UA}, timeout=10000)
hp = rp.text if hasattr(rp, 'text') else str(rp)
pd = re.search(r'player_data\s*=\s*(\{[\s\S]*?\})\s*[;<]', hp)
if not pd:
continue
pdata = json.loads(pd.group(1))
purl = pdata.get("url", "")
if not purl or not purl.startswith("http") or ".m3u8" not in purl:
continue
except:
continue
ep_list = []
for url, name in eps:
clean_name = re.sub(r'<[^>]+>', '', name).strip()
ep_list.append(f"{clean_name}${urljoin(HOST, url)}")
if ep_list:
pf.append(line_name)
pu.append("#".join(ep_list))
if pf:
d["vod_play_from"] = "$$$".join(pf)
d["vod_play_url"] = "$$$".join(pu)
except:
pass
return {"list": [d]}
def searchContent(self, key, quick=False, pg="1"):
try:
pn = 1
try: pn = int(str(pg))
except: pass
url = f"{HOST}/index.php/vod/search/wd/{quote(key)}"
if pn > 1:
url += f"/page/{pn}"
url += ".html"
r = self.fetch(url, headers={"User-Agent": UA}, timeout=30000)
html = r.text if hasattr(r, 'text') else str(r)
items = self._items(html)
return {"list": items, "page": pn}
except:
return {"list": []}
def playerContent(self, flag, id, vipFlags=None):
url = str(id) if id else str(flag)
if url.startswith("http") and ".m3u8" in url:
return {"url": url}
if url.startswith("http"):
full_url = url
else:
if not url.startswith("/"):
url = "/" + url
full_url = urljoin(HOST, url)
try:
r = self.fetch(full_url, headers={"User-Agent": UA}, timeout=30000)
h = r.text if hasattr(r, 'text') else str(r)
except:
return {"url": ""}
pd = re.search(r'player_data\s*=\s*(\{[\s\S]*?\})\s*[;<]', h)
if pd:
try:
data = json.loads(pd.group(1))
play_url = data.get("url", "")
if play_url:
return {"url": play_url}
except:
pass
m3u8 = re.search(r'(https?://[^\s"\'<>]+\.m3u8)', h)
if m3u8:
return {"url": m3u8.group(1)}
return {"url": ""}
def localProxy(self, param):
pass
def _filter_items(self, items):
"""过滤:去掉没封面的 + 并发检查线路,去掉没有LZ/YZ的"""
if not items:
return items
# 先去掉没封面的
items = [it for it in items if it.get("vod_pic")]
if not items:
return items
# 并发检查每个影片是否有LZ/YZ线路
playable_ids = set()
def check(it):
return it["vod_id"], self._check_playable(it["vod_id"])
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(check, it): it for it in items}
for future in as_completed(futures):
try:
vid, ok = future.result()
if ok:
playable_ids.add(vid)
except:
pass
return [it for it in items if it["vod_id"] in playable_ids]
def _check_playable(self, vid):
"""检查详情页是否有LZ或YZ线路名"""
try:
r = self.fetch(f"{HOST}/index.php/vod/detail/id/{vid}.html", headers={"User-Agent": UA}, timeout=5000)
h = r.text if hasattr(r, 'text') else str(r)
lines = re.findall(r'id="#con_playlist_\d+"[^>]*class="gico[^"]*"[^>]*>([^<]+)</a>', h)
for name in lines:
if "LZ" in name or "YZ" in name:
return True
return False
except:
return False
def _pagecount(self, html, current_page=1):
# 匹配分页链接: /index.php/vod/show/.../page/2/...
pages = re.findall(r'/page/(\d+)/', html)
max_page = current_page
for p in pages:
try:
n = int(p)
if n > max_page:
max_page = n
except:
pass
# 如果当前页是最大且还有下一页
has_next = re.search(r'>下一页<', html)
if has_next and max_page <= current_page + 5:
max_page = current_page + 5
return max_page
def _items(self, html, cat_filter=""):
items, seen = [], set()
# 分类过滤关键词映射
ANIME_KEYWORDS = {'动漫', '动画', '卡通', '番剧', ''}
TV_KEYWORDS = {'电视剧', '国产剧', '港台剧', '日韩剧', '欧美剧', '海外剧'}
MOVIE_KEYWORDS = {'电影', '动作片', '喜剧片', '爱情片', '科幻片', '恐怖片', '剧情片', '战争片', '纪录片'}
for m in re.finditer(r'href="(/index\.php/vod/detail/id/(\d+)\.html)"[^>]*title="([^"]*)"', html):
vid = m.group(2)
if vid in seen:
continue
name = m.group(3).strip()
if not name or len(name) > 100:
continue
after = html[m.end():m.end()+2000]
# 封面: 优先 data-original,再 data-src,再 src。HTTP升级为HTTPS
cover = re.search(r'data-original="(https?://[^"]+)"', after)
if not cover:
cover = re.search(r'data-src="(https?://[^"]+)"', after)
if not cover:
cover = re.search(r'src="(https?://[^"]+\.(?:jpg|jpeg|png|webp))"', after, re.I)
pic_url = cover.group(1) if cover else ""
if pic_url.startswith("http://"):
pic_url = pic_url.replace("http://", "https://", 1)
# 过滤失效图床域名
if pic_url:
host = urlparse(pic_url).hostname or ""
if host in DEAD_IMG_HOSTS:
pic_url = ""
# 备注
remark = re.search(r'class="titles"[^>]*>([^<]+)<', after)
if not remark:
remark = re.search(r'class="[^"]*prb[^"]*"[^>]*>([^<]+)<', after)
# 分类过滤: 从 mcat 提取分类标签(注意span内可能有换行)
mcat_raw = re.search(r'class="mcat">([\s\S]*?)</div>', after)
if mcat_raw and cat_filter:
mcat_text = re.sub(r'<[^>]+>', '', mcat_raw.group(1)).strip()
if cat_filter == "电视剧":
if any(k in mcat_text for k in ANIME_KEYWORDS):
continue
elif cat_filter == "动漫":
if any(k in mcat_text for k in TV_KEYWORDS):
continue
seen.add(vid)
items.append({
"vod_id": vid,
"vod_name": name[:50],
"vod_pic": pic_url,
"vod_remarks": remark.group(1).strip() if remark else "",
})
return items
@@ -0,0 +1,1196 @@
# -*- coding: utf-8 -*-
import sys
import re
import json
import base64
import threading
import requests
import urllib3
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import unquote, quote
urllib3.disable_warnings()
sys.path.append('..')
from base.spider import Spider
# ===== 纯 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)
# ===== 全局代理服务(封面图走代理绕过 SSL) =====
_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 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://mjv011.com/',
}
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
# ===== 内容类型判断 =====
def _is_novel(tid_or_vid):
if not tid_or_vid: return False
return tid_or_vid.startswith('novel')
def _is_image(tid_or_vid):
if not tid_or_vid: return False
return any(tid_or_vid.startswith(p) for p in ['18H', 'doujin', 'cg', 'cwp'])
# ===== 静态中文类别清单(硬编码) =====
_CHINESE_CATEGORIES = [
{'n': '企畫', 'v': 'chinese_category/1/企畫'},
{'n': '女同性戀', 'v': 'chinese_category/2/女同性戀'},
{'n': '獵豔、搭訕', 'v': 'chinese_category/3/獵豔、搭訕'},
{'n': '野外・露出', 'v': 'chinese_category/4/野外・露出'},
{'n': '偶像藝人', 'v': 'chinese_category/5/偶像藝人'},
{'n': '其他戀物癖', 'v': 'chinese_category/6/其他戀物癖'},
{'n': '近親相姦', 'v': 'chinese_category/7/近親相姦'},
{'n': '巨乳癖', 'v': 'chinese_category/8/巨乳癖'},
{'n': '情侶', 'v': 'chinese_category/9/情侶'},
{'n': '男同性恋', 'v': 'chinese_category/10/男同性恋'},
{'n': '花癡', 'v': 'chinese_category/11/花癡'},
{'n': '偷窥', 'v': 'chinese_category/12/偷窥'},
{'n': '戀腿癖', 'v': 'chinese_category/13/戀腿癖'},
{'n': '其他', 'v': 'chinese_category/14/其他'},
{'n': '癡漢', 'v': 'chinese_category/15/癡漢'},
{'n': '倒追', 'v': 'chinese_category/16/倒追'},
{'n': '奴隷', 'v': 'chinese_category/17/奴隷'},
{'n': '跳舞', 'v': 'chinese_category/18/跳舞'},
{'n': '雙性人', 'v': 'chinese_category/19/雙性人'},
{'n': '姐妹', 'v': 'chinese_category/20/姐妹'},
{'n': '通姦', 'v': 'chinese_category/21/通姦'},
{'n': '鬼畜', 'v': 'chinese_category/22/鬼畜'},
{'n': '學校作品', 'v': 'chinese_category/23/學校作品'},
{'n': '惡作劇', 'v': 'chinese_category/24/惡作劇'},
{'n': '妄想', 'v': 'chinese_category/25/妄想'},
{'n': '殘忍畫面', 'v': 'chinese_category/26/殘忍畫面'},
{'n': '爛醉如泥的', 'v': 'chinese_category/27/爛醉如泥的'},
{'n': '處女', 'v': 'chinese_category/28/處女'},
{'n': '美容院', 'v': 'chinese_category/29/美容院'},
{'n': '性感的', 'v': 'chinese_category/30/性感的'},
{'n': '女同接吻', 'v': 'chinese_category/31/女同接吻'},
{'n': '運動', 'v': 'chinese_category/32/運動'},
{'n': '瘙癢', 'v': 'chinese_category/33/瘙癢'},
{'n': '出軌', 'v': 'chinese_category/34/出軌'},
{'n': '正太控', 'v': 'chinese_category/35/正太控'},
{'n': '處男', 'v': 'chinese_category/36/處男'},
{'n': '蠻橫嬌羞', 'v': 'chinese_category/37/蠻橫嬌羞'},
{'n': '觸手', 'v': 'chinese_category/38/觸手'},
{'n': '嘔吐', 'v': 'chinese_category/39/嘔吐'},
{'n': '拷問', 'v': 'chinese_category/40/拷問'},
{'n': '10枚組', 'v': 'chinese_category/41/10枚組'},
{'n': '女優合集', 'v': 'chinese_category/42/女優合集'},
{'n': '温泉', 'v': 'chinese_category/43/温泉'},
{'n': 'M男', 'v': 'chinese_category/44/M男'},
{'n': '原作コラボ', 'v': 'chinese_category/45/原作コラボ'},
{'n': '16時間以上作品', 'v': 'chinese_category/46/16時間以上作品'},
{'n': 'デカチン・巨根', 'v': 'chinese_category/47/デカチン・巨根'},
{'n': 'ファン感謝・訪問', 'v': 'chinese_category/48/ファン感謝・訪問'},
{'n': '動画', 'v': 'chinese_category/49/動画'},
{'n': '巨尻', 'v': 'chinese_category/50/巨尻'},
{'n': 'ハーレム', 'v': 'chinese_category/51/ハーレム'},
{'n': '日焼け', 'v': 'chinese_category/52/日焼け'},
{'n': '早漏', 'v': 'chinese_category/53/早漏'},
{'n': 'キス・接吻', 'v': 'chinese_category/54/キス・接吻'},
{'n': '汗だく', 'v': 'chinese_category/55/汗だく'},
{'n': '服務生', 'v': 'chinese_category/56/服務生'},
{'n': '高中女生', 'v': 'chinese_category/57/高中女生'},
{'n': '女主播', 'v': 'chinese_category/58/女主播'},
{'n': '女生', 'v': 'chinese_category/59/女生'},
{'n': '黑人演員', 'v': 'chinese_category/60/黑人演員'},
{'n': '痴女', 'v': 'chinese_category/61/痴女'},
{'n': '護士', 'v': 'chinese_category/62/護士'},
{'n': '家庭教師', 'v': 'chinese_category/63/家庭教師'},
{'n': '母親', 'v': 'chinese_category/64/母親'},
{'n': '女教師', 'v': 'chinese_category/65/女教師'},
{'n': '展場女孩', 'v': 'chinese_category/66/展場女孩'},
{'n': '女大學生', 'v': 'chinese_category/67/女大學生'},
{'n': '賽車女郎', 'v': 'chinese_category/68/賽車女郎'},
{'n': '妓女', 'v': 'chinese_category/69/妓女'},
{'n': '各種職業', 'v': 'chinese_category/70/各種職業'},
{'n': '女醫生', 'v': 'chinese_category/71/女醫生'},
{'n': '人妻', 'v': 'chinese_category/72/人妻'},
{'n': '白人', 'v': 'chinese_category/73/白人'},
{'n': '千金小姐', 'v': 'chinese_category/74/千金小姐'},
{'n': '寡婦', 'v': 'chinese_category/75/寡婦'},
{'n': '車掌小姐', 'v': 'chinese_category/76/車掌小姐'},
{'n': '格鬥家', 'v': 'chinese_category/77/格鬥家'},
{'n': '姐姐', 'v': 'chinese_category/78/姐姐'},
{'n': '新娘、嫩妻', 'v': 'chinese_category/79/新娘、嫩妻'},
{'n': '美少女', 'v': 'chinese_category/80/美少女'},
{'n': '秘書', 'v': 'chinese_category/81/秘書'},
{'n': '模特兒', 'v': 'chinese_category/82/模特兒'},
{'n': '女主人、女老板', 'v': 'chinese_category/83/女主人、女老板'},
{'n': '明星臉', 'v': 'chinese_category/84/明星臉'},
{'n': '女檢察官', 'v': 'chinese_category/85/女檢察官'},
{'n': '格鬥家', 'v': 'chinese_category/86/格鬥家'},
{'n': '義母', 'v': 'chinese_category/87/義母'},
{'n': '教練', 'v': 'chinese_category/88/教練'},
{'n': '伴侶', 'v': 'chinese_category/89/伴侶'},
{'n': '亞洲女演員', 'v': 'chinese_category/90/亞洲女演員'},
{'n': '公主', 'v': 'chinese_category/91/公主'},
{'n': '童年朋友', 'v': 'chinese_category/92/童年朋友'},
{'n': '黑幫成員', 'v': 'chinese_category/93/黑幫成員'},
{'n': '角色扮演', 'v': 'chinese_category/94/角色扮演'},
{'n': '眼鏡', 'v': 'chinese_category/95/眼鏡'},
{'n': '泳裝', 'v': 'chinese_category/96/泳裝'},
{'n': '校服', 'v': 'chinese_category/97/校服'},
{'n': '迷你裙', 'v': 'chinese_category/98/迷你裙'},
{'n': '女僕', 'v': 'chinese_category/99/女僕'},
{'n': '旗袍', 'v': 'chinese_category/100/旗袍'},
{'n': '學校泳裝', 'v': 'chinese_category/101/學校泳裝'},
{'n': '水手服', 'v': 'chinese_category/102/水手服'},
{'n': '內衣', 'v': 'chinese_category/103/內衣'},
{'n': '體育服', 'v': 'chinese_category/104/體育服'},
{'n': '和服・喪服', 'v': 'chinese_category/105/和服・喪服'},
{'n': 'OL', 'v': 'chinese_category/106/OL'},
{'n': '身體意識', 'v': 'chinese_category/107/身體意識'},
{'n': '連褲襪', 'v': 'chinese_category/108/連褲襪'},
{'n': '空姐', 'v': 'chinese_category/109/空姐'},
{'n': '迷你裙警察', 'v': 'chinese_category/110/迷你裙警察'},
{'n': '裸體圍裙', 'v': 'chinese_category/111/裸體圍裙'},
{'n': '緊身衣', 'v': 'chinese_category/112/緊身衣'},
{'n': '制服', 'v': 'chinese_category/113/制服'},
{'n': '泡泡襪', 'v': 'chinese_category/114/泡泡襪'},
{'n': '女祭司', 'v': 'chinese_category/115/女祭司'},
{'n': '貓耳女', 'v': 'chinese_category/116/貓耳女'},
{'n': '兔女郎', 'v': 'chinese_category/117/兔女郎'},
{'n': '猥褻穿著', 'v': 'chinese_category/118/猥褻穿著'},
{'n': '偷看內褲', 'v': 'chinese_category/119/偷看內褲'},
{'n': '女裝人妖', 'v': 'chinese_category/120/女裝人妖'},
{'n': '女忍者', 'v': 'chinese_category/121/女忍者'},
{'n': '娃娃', 'v': 'chinese_category/122/娃娃'},
{'n': '及膝襪', 'v': 'chinese_category/123/及膝襪'},
{'n': '女戰士', 'v': 'chinese_category/124/女戰士'},
{'n': '巨乳', 'v': 'chinese_category/127/巨乳'},
{'n': '貧乳・微乳', 'v': 'chinese_category/128/貧乳・微乳'},
{'n': '蘿莉塔', 'v': 'chinese_category/129/蘿莉塔'},
{'n': '熟女', 'v': 'chinese_category/130/熟女'},
{'n': '孕婦', 'v': 'chinese_category/131/孕婦'},
{'n': '苗條', 'v': 'chinese_category/132/苗條'},
{'n': '胖女人', 'v': 'chinese_category/133/胖女人'},
{'n': '無毛', 'v': 'chinese_category/134/無毛'},
{'n': '變性者', 'v': 'chinese_category/135/變性者'},
{'n': '', 'v': 'chinese_category/136/高'},
{'n': '屁股', 'v': 'chinese_category/137/屁股'},
{'n': '嬌小的', 'v': 'chinese_category/138/嬌小的'},
{'n': '乳房', 'v': 'chinese_category/139/乳房'},
{'n': '肌肉', 'v': 'chinese_category/140/肌肉'},
{'n': '超乳', 'v': 'chinese_category/141/超乳'},
{'n': '中出', 'v': 'chinese_category/142/中出'},
{'n': '顏射', 'v': 'chinese_category/143/顏射'},
{'n': '自慰', 'v': 'chinese_category/144/自慰'},
{'n': '顏射', 'v': 'chinese_category/145/顏射'},
{'n': '肛交', 'v': 'chinese_category/146/肛交'},
{'n': '吞精', 'v': 'chinese_category/147/吞精'},
{'n': '打手槍', 'v': 'chinese_category/148/打手槍'},
{'n': '放尿', 'v': 'chinese_category/149/放尿'},
{'n': '亂交', 'v': 'chinese_category/150/亂交'},
{'n': '口交', 'v': 'chinese_category/151/口交'},
{'n': '飲尿', 'v': 'chinese_category/152/飲尿'},
{'n': '排便', 'v': 'chinese_category/153/排便'},
{'n': '乳交', 'v': 'chinese_category/154/乳交'},
{'n': '潮吹', 'v': 'chinese_category/155/潮吹'},
{'n': '淫語', 'v': 'chinese_category/156/淫語'},
{'n': '69', 'v': 'chinese_category/157/69'},
{'n': '深喉', 'v': 'chinese_category/158/深喉'},
{'n': '拳交', 'v': 'chinese_category/159/拳交'},
{'n': '舔陰', 'v': 'chinese_category/160/舔陰'},
{'n': '騎乗位', 'v': 'chinese_category/161/騎乗位'},
{'n': '按摩', 'v': 'chinese_category/162/按摩'},
{'n': '手指插入', 'v': 'chinese_category/163/手指插入'},
{'n': '母乳', 'v': 'chinese_category/164/母乳'},
{'n': '足交', 'v': 'chinese_category/165/足交'},
{'n': '食糞', 'v': 'chinese_category/166/食糞'},
{'n': '顏面騎乘', 'v': 'chinese_category/167/顏面騎乘'},
{'n': '肛内中出', 'v': 'chinese_category/168/肛内中出'},
{'n': '多P', 'v': 'chinese_category/169/多P'},
{'n': '按摩棒', 'v': 'chinese_category/170/按摩棒'},
{'n': '緊縛', 'v': 'chinese_category/171/緊縛'},
{'n': '跳蛋', 'v': 'chinese_category/172/跳蛋'},
{'n': '玩具', 'v': 'chinese_category/173/玩具'},
{'n': '糞便', 'v': 'chinese_category/174/糞便'},
{'n': 'SM', 'v': 'chinese_category/175/SM'},
{'n': '車震', 'v': 'chinese_category/176/車震'},
{'n': '藥物', 'v': 'chinese_category/177/藥物'},
{'n': '強姦', 'v': 'chinese_category/178/強姦'},
{'n': '緊縛', 'v': 'chinese_category/179/緊縛'},
{'n': '監禁', 'v': 'chinese_category/180/監禁'},
{'n': '灌腸', 'v': 'chinese_category/181/灌腸'},
{'n': '鴨嘴', 'v': 'chinese_category/182/鴨嘴'},
{'n': '插入異物', 'v': 'chinese_category/183/插入異物'},
{'n': '輪姦', 'v': 'chinese_category/184/輪姦'},
{'n': '拘束', 'v': 'chinese_category/185/拘束'},
{'n': '凌辱', 'v': 'chinese_category/186/凌辱'},
{'n': '羞恥', 'v': 'chinese_category/187/羞恥'},
{'n': '乳液', 'v': 'chinese_category/188/乳液'},
{'n': '催眠', 'v': 'chinese_category/189/催眠'},
{'n': '子宮頸', 'v': 'chinese_category/190/子宮頸'},
{'n': '女優按摩棒', 'v': 'chinese_category/191/女優按摩棒'},
{'n': '立即口交', 'v': 'chinese_category/192/立即口交'},
{'n': '合集', 'v': 'chinese_category/193/合集'},
{'n': '單體作品', 'v': 'chinese_category/194/單體作品'},
{'n': 'DMM獨家', 'v': 'chinese_category/195/DMM獨家'},
{'n': '獨立製作', 'v': 'chinese_category/196/獨立製作'},
{'n': '局部特寫', 'v': 'chinese_category/197/局部特寫'},
{'n': '素人', 'v': 'chinese_category/198/素人'},
{'n': '第一人稱攝影', 'v': 'chinese_category/199/第一人稱攝影'},
{'n': '國外進口', 'v': 'chinese_category/200/國外進口'},
{'n': '纪录片', 'v': 'chinese_category/201/纪录片'},
{'n': '投稿', 'v': 'chinese_category/202/投稿'},
{'n': '數位馬賽克', 'v': 'chinese_category/203/數位馬賽克'},
{'n': '首次亮相', 'v': 'chinese_category/204/首次亮相'},
{'n': '經典', 'v': 'chinese_category/205/經典'},
{'n': '薄馬賽克', 'v': 'chinese_category/206/薄馬賽克'},
{'n': '4小時以上作品', 'v': 'chinese_category/207/4小時以上作品'},
{'n': '介紹影片', 'v': 'chinese_category/208/介紹影片'},
{'n': '主觀視角', 'v': 'chinese_category/209/主觀視角'},
{'n': '高畫質', 'v': 'chinese_category/210/高畫質'},
{'n': '戀愛', 'v': 'chinese_category/211/戀愛'},
{'n': '戲劇', 'v': 'chinese_category/212/戲劇'},
{'n': '複刻版', 'v': 'chinese_category/213/複刻版'},
{'n': '限時降價', 'v': 'chinese_category/214/限時降價'},
{'n': '故事集', 'v': 'chinese_category/215/故事集'},
{'n': '特效', 'v': 'chinese_category/216/特效'},
{'n': '3D', 'v': 'chinese_category/217/3D'},
{'n': 'R-18', 'v': 'chinese_category/219/R-18'},
{'n': 'R-15', 'v': 'chinese_category/220/R-15'},
{'n': 'DMM專屬', 'v': 'chinese_category/221/DMM專屬'},
{'n': '教學', 'v': 'chinese_category/222/教學'},
{'n': '給女性觀眾', 'v': 'chinese_category/223/給女性觀眾'},
{'n': '恐怖', 'v': 'chinese_category/224/恐怖'},
{'n': '合作作品', 'v': 'chinese_category/225/合作作品'},
{'n': 'パラダイスTV', 'v': 'chinese_category/226/パラダイスTV'},
{'n': 'DVDトースター', 'v': 'chinese_category/227/DVDトースター'},
{'n': '魔法少女', 'v': 'chinese_category/228/魔法少女'},
{'n': '假陽具', 'v': 'chinese_category/229/假陽具'},
{'n': '順豐', 'v': 'chinese_category/230/順豐'},
{'n': '圖片視頻(男)', 'v': 'chinese_category/231/圖片視頻(男)'},
{'n': 'AV OPEN 2014 中量級', 'v': 'chinese_category/232/AV OPEN 2014 中量級'},
{'n': 'AV OPEN 2014 超重量級', 'v': 'chinese_category/233/AV OPEN 2014 超重量級'},
{'n': '遊戲實況', 'v': 'chinese_category/234/遊戲實況'},
{'n': 'AV OPEN 2014 ヘビー級', 'v': 'chinese_category/235/AV OPEN 2014 ヘビー級'},
{'n': '變身女主角', 'v': 'chinese_category/236/變身女主角'},
{'n': 'AV OPEN 2015 SM/ハード部門', 'v': 'chinese_category/237/AV OPEN 2015 SM/ハード部門'},
{'n': 'AV OPEN 2015 企画部門', 'v': 'chinese_category/238/AV OPEN 2015 企画部門'},
{'n': 'AV OPEN 2015 女優部門', 'v': 'chinese_category/239/AV OPEN 2015 女優部門'},
{'n': 'AV OPEN 2015 素人部門', 'v': 'chinese_category/240/AV OPEN 2015 素人部門'},
{'n': 'AV OPEN 2015 乙女部門', 'v': 'chinese_category/241/AV OPEN 2015 乙女部門'},
{'n': 'AV OPEN 2015 熟女部門', 'v': 'chinese_category/242/AV OPEN 2015 熟女部門'},
{'n': 'AV OPEN 2015 瘋狂/戀物癖部', 'v': 'chinese_category/243/AV OPEN 2015 瘋狂/戀物癖部'},
{'n': '推薦用於智能手機的垂直視頻', 'v': 'chinese_category/244/推薦用於智能手機的垂直視頻'},
{'n': 'V影院', 'v': 'chinese_category/245/V影院'},
{'n': '動畫', 'v': 'chinese_category/246/動畫'},
{'n': '行動', 'v': 'chinese_category/247/行動'},
{'n': '黑暗系統', 'v': 'chinese_category/248/黑暗系統'},
{'n': '迷你係列', 'v': 'chinese_category/249/迷你係列'},
{'n': '嬌小的', 'v': 'chinese_category/250/嬌小的'},
{'n': '浸漬', 'v': 'chinese_category/251/浸漬'},
{'n': 'AV OPEN 2016 女優部門', 'v': 'chinese_category/252/AV OPEN 2016 女優部門'},
{'n': 'AV OPEN 2016 素人部門', 'v': 'chinese_category/253/AV OPEN 2016 素人部門'},
{'n': 'AV OPEN 2016 バラエティ部門', 'v': 'chinese_category/254/AV OPEN 2016 バラエティ部門'},
{'n': 'AV OPEN 2016 劇情/紀錄片部門', 'v': 'chinese_category/255/AV OPEN 2016 劇情/紀錄片部門'},
{'n': 'AV OPEN 2016 ハード部門', 'v': 'chinese_category/256/AV OPEN 2016 ハード部門'},
{'n': 'AV OPEN 2016 瘋狂/戀物癖部門', 'v': 'chinese_category/257/AV OPEN 2016 瘋狂/戀物癖部門'},
{'n': 'AV OPEN 2016 乙女部門', 'v': 'chinese_category/258/AV OPEN 2016 乙女部門'},
{'n': 'AV OPEN 2016 企画部門', 'v': 'chinese_category/259/AV OPEN 2016 企画部門'},
{'n': 'AV OPEN 2016 人妻・熟女部門', 'v': 'chinese_category/260/AV OPEN 2016 人妻・熟女部門'},
{'n': '婊子', 'v': 'chinese_category/261/婊子'},
{'n': '僅限虛擬現實', 'v': 'chinese_category/262/僅限虛擬現實'},
{'n': 'AV 棒球福利', 'v': 'chinese_category/263/AV 棒球福利'},
{'n': '時間停止', 'v': 'chinese_category/264/時間停止'},
{'n': '漫畫雜誌', 'v': 'chinese_category/265/漫畫雜誌'},
{'n': '搞笑喜劇', 'v': 'chinese_category/266/搞笑喜劇'},
{'n': '男子的愛', 'v': 'chinese_category/267/男子的愛'},
{'n': '幻想', 'v': 'chinese_category/268/幻想'},
{'n': 'AV OPEN 2017 女優部門', 'v': 'chinese_category/269/AV OPEN 2017 女優部門'},
{'n': 'AV OPEN 2017紀錄片部', 'v': 'chinese_category/270/AV OPEN 2017紀錄片部'},
{'n': 'AV OPEN 2017 フェチ部門', 'v': 'chinese_category/271/AV OPEN 2017 フェチ部門'},
{'n': 'AV OPEN 2017 乙女部門', 'v': 'chinese_category/272/AV OPEN 2017 乙女部門'},
{'n': 'AV OPEN 2017 人妻・熟女部門', 'v': 'chinese_category/273/AV OPEN 2017 人妻・熟女部門'},
{'n': 'AV OPEN 2017 マニア部門', 'v': 'chinese_category/274/AV OPEN 2017 マニア部門'},
{'n': 'AV OPEN 2017 企画部門', 'v': 'chinese_category/275/AV OPEN 2017 企画部門'},
{'n': 'AV OPEN 2017 ハード部門', 'v': 'chinese_category/276/AV OPEN 2017 ハード部門'},
{'n': 'AV OPEN 2017 素人部門', 'v': 'chinese_category/277/AV OPEN 2017 素人部門'},
{'n': 'AV OPEN 2017 ドラマ部門', 'v': 'chinese_category/278/AV OPEN 2017 ドラマ部門'},
{'n': '經歷的告白', 'v': 'chinese_category/279/經歷的告白'},
{'n': '變性/女性化', 'v': 'chinese_category/280/變性/女性化'},
{'n': '集產品', 'v': 'chinese_category/281/集產品'},
{'n': '反向強姦', 'v': 'chinese_category/282/反向強姦'},
{'n': '反向強姦', 'v': 'chinese_category/283/反向強姦'},
{'n': '名人朋友【特典C】合格作品', 'v': 'chinese_category/284/名人朋友【特典C】合格作品'},
{'n': '名人朋友【特典A】合格作品', 'v': 'chinese_category/285/名人朋友【特典A】合格作品'},
{'n': '愛情喜劇', 'v': 'chinese_category/286/愛情喜劇'},
{'n': '僅限 FANZA 發行', 'v': 'chinese_category/287/僅限 FANZA 發行'},
{'n': '有資格參加名人朋友活動的作品', 'v': 'chinese_category/288/有資格參加名人朋友活動的作品'},
{'n': '愛欲', 'v': 'chinese_category/289/愛欲'},
{'n': '高品質虛擬現實', 'v': 'chinese_category/290/高品質虛擬現實'},
{'n': '同性戀', 'v': 'chinese_category/291/同性戀'},
{'n': 'AV OPEN 2018 女優部門', 'v': 'chinese_category/292/AV OPEN 2018 女優部門'},
{'n': 'AV OPEN 2018 企画部門', 'v': 'chinese_category/293/AV OPEN 2018 企画部門'},
{'n': 'AV OPEN 2018 素人部門', 'v': 'chinese_category/294/AV OPEN 2018 素人部門'},
{'n': 'AV OPEN 2018 瘋狂/戀物癖部門', 'v': 'chinese_category/295/AV OPEN 2018 瘋狂/戀物癖部門'},
{'n': 'AV OPEN 2018 人妻・熟女部門', 'v': 'chinese_category/296/AV OPEN 2018 人妻・熟女部門'},
{'n': 'AV OPEN 2018 ハード部門', 'v': 'chinese_category/297/AV OPEN 2018 ハード部門'},
{'n': 'AV OPEN 2018 乙女部門', 'v': 'chinese_category/298/AV OPEN 2018 乙女部門'},
{'n': '已婚婦女/家庭主婦', 'v': 'chinese_category/299/已婚婦女/家庭主婦'},
{'n': '連褲襪', 'v': 'chinese_category/300/連褲襪'},
{'n': '按摩/茶點', 'v': 'chinese_category/301/按摩/茶點'},
{'n': '乳液油', 'v': 'chinese_category/302/乳液油'},
{'n': '年輕的妻子', 'v': 'chinese_category/303/年輕的妻子'},
{'n': '小便/洩漏', 'v': 'chinese_category/304/小便/洩漏'},
{'n': '保健皂', 'v': 'chinese_category/305/保健皂'},
{'n': '極致高潮', 'v': 'chinese_category/306/極致高潮'},
{'n': '虐待', 'v': 'chinese_category/307/虐待'},
{'n': 'M女', 'v': 'chinese_category/308/M女'},
{'n': '職業裝', 'v': 'chinese_category/309/職業裝'},
{'n': '背部', 'v': 'chinese_category/310/背部'},
{'n': '不戴胸罩', 'v': 'chinese_category/311/不戴胸罩'},
{'n': '女上司', 'v': 'chinese_category/312/女上司'},
{'n': '洗澡', 'v': 'chinese_category/313/洗澡'},
{'n': '乳頭滑落', 'v': 'chinese_category/314/乳頭滑落'},
{'n': '旅行', 'v': 'chinese_category/315/旅行'},
{'n': '奧納薩波', 'v': 'chinese_category/316/奧納薩波'},
{'n': '蠟燭', 'v': 'chinese_category/317/蠟燭'},
{'n': '男人噴', 'v': 'chinese_category/318/男人噴'},
{'n': '娘・養女', 'v': 'chinese_category/319/娘・養女'},
{'n': '面具,面具', 'v': 'chinese_category/320/面具,面具'},
{'n': '軟體', 'v': 'chinese_category/321/軟體'},
{'n': '面試', 'v': 'chinese_category/322/面試'},
{'n': '宅男', 'v': 'chinese_category/323/宅男'},
{'n': '接待員', 'v': 'chinese_category/324/接待員'},
{'n': '沒有內褲', 'v': 'chinese_category/325/沒有內褲'},
{'n': '酒會', 'v': 'chinese_category/326/酒會'},
{'n': '部下・同僚', 'v': 'chinese_category/327/部下・同僚'},
{'n': '名人', 'v': 'chinese_category/328/名人'},
{'n': '戴綠帽子 / 戴綠帽子 / NTR', 'v': 'chinese_category/329/戴綠帽子 / 戴綠帽子 / NTR'},
{'n': '戴綠帽子 / 戴綠帽子 / NTR', 'v': 'chinese_category/330/戴綠帽子 / 戴綠帽子 / NTR'},
{'n': '約會', 'v': 'chinese_category/331/約會'},
{'n': '花嫁', 'v': 'chinese_category/332/花嫁'},
{'n': '交換/情侶交換', 'v': 'chinese_category/333/交換/情侶交換'},
{'n': '祖父', 'v': 'chinese_category/334/祖父'},
{'n': '', 'v': 'chinese_category/335/姨'},
{'n': '鼻鉤', 'v': 'chinese_category/336/鼻鉤'},
{'n': '運動員', 'v': 'chinese_category/337/運動員'},
{'n': '醫院/診所', 'v': 'chinese_category/338/醫院/診所'},
{'n': '打屁股', 'v': 'chinese_category/339/打屁股'},
{'n': '酒店', 'v': 'chinese_category/340/酒店'},
{'n': '女王', 'v': 'chinese_category/341/女王'},
{'n': '瑜伽', 'v': 'chinese_category/342/瑜伽'},
{'n': '新人陸續登場', 'v': 'chinese_category/343/新人陸續登場'},
{'n': '白目・失神', 'v': 'chinese_category/344/白目・失神'},
{'n': '媽媽朋友', 'v': 'chinese_category/345/媽媽朋友'},
{'n': '啦啦隊長', 'v': 'chinese_category/346/啦啦隊長'},
{'n': '奶奶', 'v': 'chinese_category/347/奶奶'},
{'n': '社團活動經理', 'v': 'chinese_category/348/社團活動經理'},
{'n': '妄想族', 'v': 'chinese_category/349/妄想族'},
{'n': '艾曼紐', 'v': 'chinese_category/350/艾曼紐'},
{'n': '艾曼紐', 'v': 'chinese_category/351/艾曼紐'},
{'n': '放置', 'v': 'chinese_category/352/放置'},
{'n': '心理驚悚片', 'v': 'chinese_category/353/心理驚悚片'},
{'n': '切卡', 'v': 'chinese_category/354/切卡'},
{'n': '福袋', 'v': 'chinese_category/355/福袋'},
{'n': '最好的,綜合性的', 'v': 'chinese_category/356/最好的,綜合性的'},
{'n': '3P、4P', 'v': 'chinese_category/357/3P、4P'},
{'n': '女優', 'v': 'chinese_category/358/女優'},
{'n': '藝人', 'v': 'chinese_category/359/藝人'},
{'n': '強姦', 'v': 'chinese_category/360/強姦'},
{'n': '', 'v': 'chinese_category/361/湯'},
{'n': '凌辱', 'v': 'chinese_category/362/凌辱'},
{'n': '偶像', 'v': 'chinese_category/363/偶像'},
{'n': '看護師', 'v': 'chinese_category/364/看護師'},
{'n': '奴隸', 'v': 'chinese_category/365/奴隸'},
{'n': '風俗', 'v': 'chinese_category/366/風俗'},
{'n': '處女之物', 'v': 'chinese_category/367/處女之物'},
{'n': '小乳房', 'v': 'chinese_category/368/小乳房'},
{'n': '大公雞', 'v': 'chinese_category/369/大公雞'},
{'n': '', 'v': 'chinese_category/370/妹'},
{'n': '戀物癖', 'v': 'chinese_category/371/戀物癖'},
{'n': '示例視頻', 'v': 'chinese_category/372/示例視頻'},
{'n': '秋季大甩賣', 'v': 'chinese_category/373/秋季大甩賣'},
{'n': '藍光', 'v': 'chinese_category/374/藍光'},
{'n': '出口', 'v': 'chinese_category/375/出口'},
{'n': '動漫人物', 'v': 'chinese_category/376/動漫人物'},
{'n': 'DOD 秋季促銷第 2 部分', 'v': 'chinese_category/377/DOD 秋季促銷第 2 部分'},
{'n': '2010年代前半(DOD', 'v': 'chinese_category/378/2010年代前半(DOD'},
{'n': '光盤點播銷售', 'v': 'chinese_category/379/光盤點播銷售'},
{'n': 'AV女優', 'v': 'chinese_category/380/AV女優'},
{'n': '設置有好處的產品', 'v': 'chinese_category/381/設置有好處的產品'},
{'n': 'Triple HAPPY 活動', 'v': 'chinese_category/382/Triple HAPPY 活動'},
{'n': 'Rena 桃園 JOKER 禮品活動', 'v': 'chinese_category/383/Rena 桃園 JOKER 禮品活動'},
{'n': '時代劇', 'v': 'chinese_category/384/時代劇'},
{'n': '全球媒體 40% 折扣銷售', 'v': 'chinese_category/385/全球媒體 40% 折扣銷售'},
{'n': 'Mousozoku 2021上半年進入TOP 100', 'v': 'chinese_category/386/Mousozoku 2021上半年進入TOP 100'},
{'n': 'レズビアン', 'v': 'chinese_category/387/レズビアン'},
{'n': 'BL(ボーイズラブ)', 'v': 'chinese_category/388/BL(ボーイズラブ)'},
{'n': '8KVR', 'v': 'chinese_category/389/8KVR'},
{'n': '妄想族30OFF第1弾', 'v': 'chinese_category/390/妄想族30OFF第1弾'},
{'n': 'ブランドストア30OFF☆', 'v': 'chinese_category/391/ブランドストア30OFF☆'},
{'n': 'ながえスタイル30OFF', 'v': 'chinese_category/392/ながえスタイル30OFF'},
{'n': 'MERCURY(マーキュリー)他30OFF', 'v': 'chinese_category/393/MERCURY(マーキュリー)他30OFF'},
{'n': 'ブランドストア30OFF', 'v': 'chinese_category/394/ブランドストア30OFF'},
{'n': '妄想族30OFF', 'v': 'chinese_category/395/妄想族30OFF'},
{'n': 'BALTAN<バルタン>他30OFF', 'v': 'chinese_category/396/BALTAN<バルタン>他30OFF'},
{'n': 'ながえスタイル他30OFF', 'v': 'chinese_category/397/ながえスタイル他30OFF'},
{'n': '台湾モデル', 'v': 'chinese_category/398/台湾モデル'},
{'n': 'AI生成作品', 'v': 'chinese_category/399/AI生成作品'},
{'n': 'アナルセックス(男の娘)', 'v': 'chinese_category/400/アナルセックス(男の娘)'},
]
# ===== Spider =====
class Spider(Spider):
session = requests.Session()
host = 'https://mjv011.com'
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': 'https://mjv011.com/',
}
_tag_cache = {}
def getName(self): return "mjv011"
def isVideoFormat(self, url):
if not url: return False
return '.m3u8' in url or '.mp4' in url or '.ts' in url
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
_start_proxy()
try:
self.session.get(
f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html',
headers=self.headers, timeout=15, verify=False)
except Exception:
pass
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):
try:
r = self.session.get(url, headers=self.headers, timeout=20, verify=False)
r.encoding = 'utf-8'
if r.status_code == 200:
text = r.text
if len(text) < 3000 and ('同意(enter)' in text or 'IamOverEighteenYearsOld' in text):
self.session.get(
f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html',
headers=self.headers, timeout=20, verify=False)
r = self.session.get(url, headers=self.headers, timeout=20, verify=False)
r.encoding = 'utf-8'
if r.status_code == 200:
return r.text
return ''
return text
return ''
except Exception:
return ''
def _parse_posts(self, text, tid=''):
if _is_novel(tid):
return self._parse_text_posts(text)
items = []
pattern1 = r"<div class='post'>\s*<a[^>]*href=\"([^\"]+)\"[^>]*><img[^>]*src='([^']+)'[^>]*>\s*</a>\s*<div class='con'>\s*<h3[^>]*><a[^>]*>([^<]+)</a></h3>(?:\s*<div class='meta'>([^<]*)</div>)?"
matches = re.findall(pattern1, text, re.S)
if matches:
for href, pic, title, date in matches:
if href.startswith(self.host):
href = href[len(self.host):]
if not href or not href.startswith('/'):
continue
mm = re.search(r'/([^/]+)_content/(\d+)/([^/]+)\.html', href)
if not mm:
continue
ctype, vid, slug = mm.group(1), mm.group(2), mm.group(3)
items.append({
'vod_id': f'{ctype}#{vid}#{slug}',
'vod_name': title.strip(),
'vod_pic': self._proxy_url(pic),
'vod_remarks': date.strip() if date else '',
})
return items
pattern2 = r'<a[^>]+href="([^"]*zh/[^"]*_content/[^"]*)"[^>]*>.*?<img[^>]+src="([^"]+)"[^>]*>.*?<(?:h3|h2|h4|div\s+class="title")[^>]*>(.*?)</'
matches = re.findall(pattern2, text, re.S | re.I)
for href, pic, title in matches:
if href.startswith(self.host):
href = href[len(self.host):]
if not href or not href.startswith('/'):
continue
mm = re.search(r'/([^/]+)_content/(\d+)/([^/]+)\.html', href)
if not mm:
continue
ctype, vid, slug = mm.group(1), mm.group(2), mm.group(3)
title = re.sub(r'<[^>]+>', '', title).strip()
items.append({
'vod_id': f'{ctype}#{vid}#{slug}',
'vod_name': title,
'vod_pic': self._proxy_url(pic),
'vod_remarks': '',
})
return items
def _parse_text_posts(self, text):
items = []
for m in re.finditer(r"<div class='post'>\s*<div class='con'>\s*<h3[^>]*><a[^>]*href=\"([^\"]+)\"[^>]*>([^<]+)</a></h3>", text):
href, title = m.groups()
mm = re.search(r'/([^/]+)_content/(\d+)/([^/]+)\.html', href)
if not mm: continue
ctype, vid, slug = mm.group(1), mm.group(2), mm.group(3)
items.append({
'vod_id': f'{ctype}#{vid}#{slug}',
'vod_name': title.strip(),
'vod_pic': '',
'vod_remarks': '',
})
return items
def _get_chinese_categories(self):
return _CHINESE_CATEGORIES
def _get_category_tags(self, tid):
if tid in self._tag_cache:
return self._tag_cache[tid]
if _is_novel(tid) or _is_image(tid):
return []
base_tid = tid
if base_tid.endswith('_random/all'):
base_tid = base_tid[:-11]
elif base_tid.endswith('_random'):
base_tid = base_tid[:-7]
urls_to_try = [
f'{self.host}/zh/{base_tid}_random/all/index.html',
f'{self.host}/zh/{base_tid}_list/all/index.html',
f'{self.host}/zh/{base_tid}/index.html',
]
text = None
for u in urls_to_try:
text = self._fetch(u)
if text:
break
if not text:
self._tag_cache[tid] = []
return []
tags = []
seen = set()
for match in re.finditer(r'href=[\'"](/zh/((?:' + re.escape(base_tid) + r')_(?:search|tag)/all/[^/\'"]+)/)[\'"]', text, re.I):
path = match.group(1)
label = path.rsplit('/', 2)[0].split('/')[-1]
label = unquote(label)
if not label or label in seen:
continue
seen.add(label)
tags.append({'n': label, 'v': path})
self._tag_cache[tid] = tags
return tags
def _build_filters(self):
filters = {}
chinese_tags = self._get_chinese_categories()
dynamic_chinese = self._get_category_tags('chinese_random/all')
all_chinese_opts = [{'n': '全部', 'v': ''}]
seen_names = set()
for t in dynamic_chinese:
if t['n'] not in seen_names:
seen_names.add(t['n'])
all_chinese_opts.append(t)
for t in chinese_tags:
if t['n'] not in seen_names:
seen_names.add(t['n'])
all_chinese_opts.append(t)
filters['chinese_random/all'] = [{'key': 'sub', 'name': '分类', 'value': all_chinese_opts}]
video_tids = [
'censored_random/all',
'uncensored_random/all',
'amateurjav_random/all',
'reducing-mosaic_random/all',
]
for tid in video_tids:
dynamic = self._get_category_tags(tid)
if tid == 'uncensored_random/all':
static_opts = [
{'n': '一本道(1pondo)', 'v': 'uncensored_makersr/32/一本道(1pondo)'},
{'n': 'カリビアンコム(Caribbeancom)', 'v': 'uncensored_makersr/30/カリビアンコム(Caribbeancom)'},
{'n': 'カリビアンコムPPV', 'v': 'uncensored_makersr/40/カリビアンコムPPV(Caribbeancompr)'},
{'n': '天然むすめ(10musume)', 'v': 'uncensored_makersr/31/天然むすめ(10musume)'},
{'n': 'HEYZO', 'v': 'uncensored_makersr/17/HEYZO'},
{'n': '東京熱(Tokyo Hot)', 'v': 'uncensored_makersr/29/東京熱(Tokyo Hot)'},
{'n': 'ガチん娘!(Gachinco)', 'v': 'uncensored_makersr/35/ガチん娘!(Gachinco)'},
{'n': 'パコパコママ(pacopacomama)', 'v': 'uncensored_makersr/36/パコパコママ(pacopacomama)'},
{'n': 'エッチな4610', 'v': 'uncensored_makersr/34/エッチな4610'},
{'n': '人妻斬り0930', 'v': 'uncensored_makersr/38/人妻斬り0930'},
{'n': 'エッチな0930', 'v': 'uncensored_makersr/39/エッチな0930'},
{'n': 'トリプルエックス(XXX-AV)', 'v': 'uncensored_makersr/126/トリプルエックス (XXX-AV)'},
]
opts = [{'n': '全部', 'v': ''}]
opts.extend(dynamic)
opts.append({'n': '——厂商筛选——', 'v': ''})
opts.extend(static_opts)
filters[tid] = [{'key': 'sub', 'name': '筛选', 'value': opts}]
else:
if dynamic:
opts = [{'n': '全部', 'v': ''}]
opts.extend(dynamic)
filters[tid] = [{'key': 'sub', 'name': '标签', 'value': opts}]
cg_opts = [
{'n': '全部', 'v': ''},
{'n': 'Bejean On Line', 'v': 'cg_search/all/Bejean On Line'},
{'n': 'Bomb.tv', 'v': 'cg_search/all/Bomb.tv'},
{'n': 'DGC', 'v': 'cg_search/all/DGC'},
{'n': 'Graphis Gals', 'v': 'cg_search/all/Graphis Gals'},
{'n': 'Graphis Hatsunugi', 'v': 'cg_search/all/Graphis Hatsunugi'},
{'n': 'image.tv', 'v': 'cg_search/all/image.tv'},
{'n': 'Sabra.net', 'v': 'cg_search/all/Sabra.net'},
{'n': 'S-Cute', 'v': 'cg_search/all/S-Cute'},
{'n': 'X-City', 'v': 'cg_search/all/X-City'},
{'n': 'YS Web', 'v': 'cg_search/all/YS Web'},
]
filters['cg_random/all'] = [{'key': 'sub', 'name': '系列', 'value': cg_opts}]
cwp_opts = [
{'n': '全部', 'v': ''},
{'n': '3AGirl AAA女郎', 'v': 'cwp_search/all/3AGirl AAA女郎'},
{'n': 'ROSI寫真', 'v': 'cwp_search/all/ROSI寫真'},
{'n': 'RU1MM 如壹寫真', 'v': 'cwp_search/all/RU1MM 如壹寫真'},
{'n': 'DISI第四印象', 'v': 'cwp_search/all/DISI第四印象'},
]
filters['cwp_random/all'] = [{'key': 'sub', 'name': '系列', 'value': cwp_opts}]
novel_opts = [
{'n': '全部', 'v': ''},
{'n': '學生校園', 'v': 'novel_search/all/學生校園'},
{'n': '職場激情', 'v': 'novel_search/all/職場激情'},
{'n': '經驗故事', 'v': 'novel_search/all/經驗故事'},
{'n': '暴力虐待', 'v': 'novel_search/all/暴力虐待'},
{'n': '不倫戀情', 'v': 'novel_search/all/不倫戀情'},
{'n': '群體換伴', 'v': 'novel_search/all/群體換伴'},
{'n': '人妻熟女', 'v': 'novel_search/all/人妻熟女'},
{'n': '科學幻想', 'v': 'novel_search/all/科學幻想'},
{'n': '其他故事', 'v': 'novel_search/all/其他故事'},
{'n': '玄幻仙俠', 'v': 'novel_search/all/玄幻仙俠'},
{'n': '動漫修改', 'v': 'novel_search/all/動漫修改'},
{'n': '長篇連載', 'v': 'novel_search/all/長篇連載'},
]
filters['novel_random/all'] = [{'key': 'sub', 'name': '主题', 'value': novel_opts}]
return filters
def homeContent(self, filter):
try:
return self._homeContent_inner(filter)
except Exception:
return {'class': [], 'filters': {}, 'type': '影视', 'list': [],
'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def _load_classes(self, text):
classes = []
seen = set()
for href, key, name in re.findall(r'href=[\'\"](https://mjv011\.com/zh/([^\'\"/_]+)_random/all/index\.html)[\'\"][^>]*>([^<]+)</a>', text):
name = re.sub(r'\s*\(\d+\)\s*$', '', name.strip())
if any(x in name for x in ['random', '隨機', '随机', '近作']):
continue
tid = f'{key}_random/all'
if tid in seen:
continue
seen.add(tid)
classes.append({'type_id': tid, 'type_name': name})
return classes
def _homeContent_inner(self, filter):
text = self._fetch(f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html')
classes = self._load_classes(text)
if not classes:
classes = [
{'type_id': 'chinese_random/all', 'type_name': '中文字幕'},
{'type_id': 'censored_random/all', 'type_name': '有码'},
{'type_id': 'uncensored_random/all', 'type_name': '无码'},
{'type_id': 'amateurjav_random/all', 'type_name': '素人'},
{'type_id': 'reducing-mosaic_random/all', 'type_name': '无码破解'},
{'type_id': 'animation_random/all', 'type_name': 'H动画'},
{'type_id': 'CensoredAnimation_random/all', 'type_name': 'H有码动画'},
{'type_id': 'UncensoredAnimation_random/all', 'type_name': 'H无码动画'},
{'type_id': 'tdAnimation_random/all', 'type_name': 'H_3D动画'},
{'type_id': 'dt_random/all', 'type_name': '国产自拍'},
{'type_id': '18H_random/all', 'type_name': '18H漫画'},
{'type_id': 'doujin_random/all', 'type_name': '18H短篇同人'},
{'type_id': 'cg_random/all', 'type_name': '写真图集'},
{'type_id': 'cwp_random/all', 'type_name': '国产写真'},
{'type_id': 'novel_random/all', 'type_name': '小说'},
]
new_classes = []
new_classes.append({'type_id': 'home', 'type_name': '首页'})
new_classes.append({'type_id': 'content_news/all', 'type_name': '每日更新'})
for c in classes:
if c['type_id'] not in ['home', 'content_news/all']:
new_classes.append(c)
classes = new_classes
items = self._parse_posts(text, 'chinese_random/all')
filters = self._build_filters()
return {'class': classes, 'filters': filters, 'type': '影视', 'list': items,
'page': 1, 'pagecount': 1, 'limit': len(items), 'total': len(items)}
def homeVideoContent(self):
try:
text = self._fetch(f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html')
return {'list': self._parse_posts(text, 'chinese_random/all')}
except Exception:
return {'list': []}
def categoryContent(self, tid, pg, filter, extend):
try:
return self._categoryContent_inner(tid, pg, filter, extend)
except Exception as e:
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def _categoryContent_inner(self, tid, pg, filter, extend):
if isinstance(extend, str):
try:
extend = json.loads(extend)
except Exception:
extend = {}
if not extend:
extend = {}
page = int(pg) if pg else 1
if tid == 'home':
text = self._fetch(f'{self.host}/zh/chinese_IamOverEighteenYearsOld/19/index.html')
if text:
items = self._parse_posts(text, 'chinese_random/all')
per_page = 20
total = len(items)
start = (page - 1) * per_page
end = start + per_page
page_items = items[start:end]
return {'list': page_items, 'page': page,
'pagecount': (total + per_page - 1) // per_page,
'limit': len(page_items), 'total': total}
return {'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0}
if tid == 'content_news/all':
today = datetime.now().strftime('%Y-%m-%d')
url = f'{self.host}/zh/content_news/all/{today}.html'
text = self._fetch(url)
if text:
items = self._parse_posts(text, tid)
if items:
return {'list': items, 'page': 1, 'pagecount': 1, 'limit': len(items), 'total': len(items)}
return {'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0}
candidates = []
if extend and extend.get('sub'):
sub = quote(unquote(extend['sub']), safe='/()')
candidates.append(f'{self.host}/zh/{sub}/{page}.html')
if tid.startswith('search_'):
kw = tid[7:]
candidates.append(f'{self.host}/zh/chinese_search/all/{kw}/{page}.html')
else:
base_tid = tid
if base_tid.endswith('_random/all'):
base_tid = base_tid[:-11]
elif base_tid.endswith('_random'):
base_tid = base_tid[:-7]
elif base_tid.endswith('_list/all'):
base_tid = base_tid[:-10]
if page == 1:
candidates.append(f'{self.host}/zh/{base_tid}_list/all/index.html')
candidates.append(f'{self.host}/zh/{base_tid}_random/all/index.html')
candidates.append(f'{self.host}/zh/{base_tid}_list/all/{page}.html')
candidates.append(f'{self.host}/zh/{base_tid}_list/index_{page}.html')
candidates.append(f'{self.host}/zh/{base_tid}/{page}.html')
candidates.append(f'{self.host}/zh/{base_tid}/page/{page}.html')
candidates.append(f'{self.host}/zh/{base_tid}_random/all/{page}.html')
if page > 1:
candidates.append(f'{self.host}/zh/{base_tid}_list/index_{page}.html')
items = []
for url in candidates:
text = self._fetch(url)
if text:
parse_tid = tid
if extend and extend.get('sub'):
sub = extend['sub']
if _is_novel(sub):
parse_tid = 'novel_random/all'
elif _is_image(sub):
parse_tid = sub.split('_')[0] + '_random/all' if '_' in sub else 'cg_random/all'
items = self._parse_posts(text, parse_tid)
if items:
break
return {'list': items, 'page': page, 'pagecount': page + 1,
'limit': len(items), 'total': page * len(items) + 1}
# ===== 详情接口 =====
def detailContent(self, ids):
try:
return self._detailContent_inner(ids)
except Exception:
return {'list': []}
def _detailContent_inner(self, ids):
vid = str(ids[0] if isinstance(ids, list) else ids)
ctype, num, slug = vid.split('#', 2)
if _is_novel(ctype):
return self._novel_detail(vid, ctype, num, slug)
elif _is_image(ctype):
return self._image_detail(vid, ctype, num, slug)
else:
return self._video_detail(vid, ctype, num, slug)
def _video_detail(self, vid, ctype, num, slug):
url = f'{self.host}/zh/{ctype}_content/{num}/{slug}.html'
text = self._fetch(url)
if not text: return {'list': []}
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', text, re.S)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>([^<]+)</title>', text)
if m: title = m.group(1).strip()
cover = ''
m = re.search(r'"thumbnailUrl"\s*:\s*"([^"]+)"', text)
if m: cover = m.group(1)
if not cover:
m = re.search(r"<meta[^>]*property=\"og:image\"[^>]*content=\"([^\"]+)\"", text)
if m: cover = m.group(1)
if not cover:
m = re.search(r"<div class='post'>.*?<img[^>]*src='([^']+)'", text, re.S)
if m: cover = m.group(1)
m = re.search(r'hadeedg252=(\d+)', text)
if not m: return {'list': []}
hadeedg252 = int(m.group(1))
m = re.search(r'hcdeedg252=(\d+)', text)
if not m: return {'list': []}
hcdeedg252 = int(m.group(1))
m = re.search(r"var argdeqweqweqwe = '([^']+)'", text)
if not m: return {'list': []}
aes_key = m.group(1)
m = re.search(r"var hdddedg252 = '([^']+)'", text)
if not m: return {'list': []}
aes_iv = m.group(1)
mm = re.search(r"mvarr\['10_1'\]=(\[.*?\]);", text, re.S)
if not mm: return {'list': []}
mvarr_str = mm.group(1)
items = re.findall(r"\['([^']*)','([^']*)','([^']*)','([^']*)','([^']*)','([^']*)'\]", mvarr_str)
if not items: return {'list': []}
urls = []
for iframe_id, enc, html, prefix, empty, label in items:
if not enc or not prefix: continue
pid = self._decrypt_id(enc, hadeedg252, hcdeedg252, aes_key, aes_iv)
if not pid: continue
for res, label_name in [('480', '480P'), ('720', '720P'), ('1080', '1080P')]:
urls.append(f'{label_name}${num}|{slug}|{pid}|{res}')
if not urls: return {'list': []}
vod = {
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(cover),
'vod_content': '',
'vod_remarks': '',
'vod_play_from': 'mjv011',
'vod_play_url': '#'.join(urls),
}
return {'list': [vod]}
def _novel_detail(self, vid, ctype, num, slug):
url = f'{self.host}/zh/{ctype}_content/{num}/{slug}.html'
text = self._fetch(url)
if not text: return {'list': []}
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', text, re.S)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
content = ''
m = re.search(r"id=['\"]novel_content_txtsize['\"][^>]*>(.*?)</div>", text, re.S)
if m:
raw = m.group(1)
content = re.sub(r'<[^>]+>', '', raw)
content = re.sub(r'&nbsp;', ' ', content)
content = re.sub(r'\s+', ' ', content).strip()
if not content:
m = re.search(r"<span class='content_18h_wpcg'>([\s\S]*?)</span>\s*<div class='contents'", text)
if m:
raw = m.group(1)
content = re.sub(r'<[^>]+>', '', raw)
content = re.sub(r'&nbsp;', ' ', content)
content = re.sub(r'\s+', ' ', content).strip()
if len(content) > 3000:
content = content[:3000] + '...'
novel_json = json.dumps({'title': title, 'content': content}, ensure_ascii=False)
play_url = f'阅读$novel://{novel_json}'
vod = {
'vod_id': vid,
'vod_name': title,
'vod_pic': '',
'vod_content': '',
'vod_remarks': '',
'vod_play_from': '小说',
'vod_play_url': play_url,
'vod_tag': 'text',
'vod_player': '',
}
return {'list': [vod]}
def _image_detail(self, vid, ctype, num, slug):
url = f'{self.host}/zh/{ctype}_content/{num}/{slug}.html'
text = self._fetch(url)
if not text: return {'list': []}
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', text, re.S)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
cover = ''
m = re.search(r'"thumbnailUrl"\s*:\s*"([^"]+)"', text)
if m: cover = m.group(1)
all_imgs = re.findall(r"src=['\"]([^'\"]*eemmhh02\.com/[^'\"]+\.(?:jpg|png|webp))['\"]", text, re.I)
big_imgs = []
seen = set()
for img in all_imgs:
if img in seen:
continue
seen.add(img)
big_imgs.append(self._proxy_url(img))
if not big_imgs:
return {'list': []}
pics = '&&'.join(big_imgs)
play_url = f'查看$pics://{pics}'
vod = {
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(cover),
'vod_content': f'{len(big_imgs)} 张图片',
'vod_remarks': str(len(big_imgs)) + 'P',
'vod_play_from': '图片',
'vod_play_url': play_url,
'vod_tag': 'image',
'vod_player': '',
}
return {'list': [vod]}
def _decrypt_id(self, enc, xor_key, base, aes_key, aes_iv):
try:
sep = chr(base + 97)
parts = enc.split(sep)
s1 = ''.join(chr(int(p, base) ^ xor_key) for p in parts if p)
data = base64.b64decode(s1)
plain = _aes_cbc_decrypt(data, aes_key.encode(), aes_iv.encode())
return plain.decode('utf-8')
except Exception:
return ''
def searchContent(self, key, quick, pg="1"):
try:
return self._searchContent_inner(key, quick, pg)
except Exception:
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def _searchContent_inner(self, key, quick, pg="1"):
page = int(pg) if pg else 1
return self._categoryContent_inner(f'search_{key}', page, False, {})
def playerContent(self, flag, id, vipFlags=None):
try:
return self._playerContent_inner(flag, id, vipFlags)
except Exception:
return {'parse': 0, 'url': '', 'header': {}, 'position': '0'}
def _playerContent_inner(self, flag, id, vipFlags=None):
if id.startswith('novel://'):
return {'parse': 0, 'url': id, 'header': '', 'vod_player': ''}
if id.startswith('pics://'):
return {'parse': 0, 'playUrl': '', 'url': id, 'header': self.headers}
try:
num, slug, pid, res = id.split('|', 3)
except ValueError:
return {'parse': 1, 'url': id, 'header': self.headers}
url = f'{self.host}/js/player/play.php?numresolution={res}&lo=on&id={pid}'
text = self._fetch(url)
m3u8 = ''
if text:
mm = re.search(r'videoSources\s*=\s*(\[.*?\]);', text, re.S)
if mm:
arr = mm.group(1)
sources = re.findall(r"src:\s*'([^']+)'[^}]*?size:\s*(\d+)", arr, re.S)
if sources:
target = int(res) if str(res).isdigit() else 0
for u, s in sources:
if int(s) == target:
m3u8 = u
break
if not m3u8:
m3u8 = sources[0][0]
if not m3u8:
mm = re.search(r'https?://[^\s"<>\']+?\.m3u8', text)
if mm: m3u8 = mm.group(0)
if m3u8:
return {'parse': 0, 'url': m3u8, 'header': {'Referer': self.host + '/'}, 'position': '0'}
else:
return {'parse': 1, 'url': id, 'header': self.headers}
+507
View File
@@ -0,0 +1,507 @@
# -*- coding: utf-8 -*-
"""
juok3.top (剧OK) 爬虫
- 首页: /api/filter API 获取推荐
- 分类: /api/filter?catId= /type= API
- 详情: /api/detail?cat=&id= API
- 搜索: /api/search?q=关键词
- 播放: 官源链接 + 解析线路, 需要WebView解析
"""
import sys
import re
import json
from urllib.parse import urljoin, quote
sys.path.append('..')
try:
from base.spider import Spider
except ImportError:
class Spider:
def fetch(self, url, headers=None, **kw):
import requests as rq
kw.pop('timeout', None)
r = rq.get(url, headers=headers, timeout=15, **kw)
r.encoding = 'utf-8'
return r
def _(x): return x
HOST = "https://juok3.top"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
# 分类映射: type_id -> (catId, type参数, 中文名)
CLASS_MAP = {
"movie": (1, None, "电影"),
"tv": (2, None, "电视剧"),
"variety": (3, None, "综艺"),
"anime": (4, None, "动漫"),
"short": (2, "短剧", "短剧"), # type=短剧, cat用2
}
# 外部解析线路 (拼接URL) - 从 mojiez.net 提取的38个解析接口
PARSE_APIS = [
{"name": "默认接口", "url": "https://jx.xmflv.com/?url="},
{"name": "我看VIP", "url": "https://a.wkvip.net/?url="},
{"name": "8090", "url": "https://www.8090g.cn/?url="},
{"name": "极速解析", "url": "https://jx.2s0.cn/player/?url="},
{"name": "super", "url": "https://super.playr.top/?url="},
{"name": "fongmi", "url": "https://json.fongmi.cc/web?url="},
{"name": "8090备用", "url": "https://www.8090g.cn/jiexi/?url="},
{"name": "M3U8解析", "url": "https://jx.m3u8.tv/jx/jx.php?url="},
{"name": "Jn1解析", "url": "https://yparse.jn1.cc/index.php?url="},
{"name": "CK解析", "url": "https://www.ckplayer.vip/jiexi/?url="},
{"name": "Player-JY", "url": "https://jx.playerjy.com/?url="},
{"name": "789解析1", "url": "https://jiexi.789jiexi.icu:4433/?url="},
{"name": "789解析2", "url": "https://jiexi.789jiexi.com/?url="},
{"name": "HLS解析", "url": "https://jx.hls.one/?url="},
{"name": "冰豆解析", "url": "https://bd.jx.cn/?url="},
{"name": "剖元解析", "url": "https://www.pouyun.com/?url="},
{"name": "973解析", "url": "https://jx.973973.xyz/?url="},
{"name": "七哥解析", "url": "https://jx.nnxv.cn/tv.php?url="},
{"name": "PlayM3U8", "url": "https://www.playm3u8.cn/jiexi.php?url="},
{"name": "937解析", "url": "https://bfq.937auth.vip?url="},
{"name": "芒果TV专线", "url": "https://video.isyour.love/player/getplayer?url="},
{"name": "M1907", "url": "https://im1907.top/?jx="},
{"name": "夜幕解析", "url": "https://www.yemu.xyz/?url="},
{"name": "盘古解析", "url": "https://www.pangujiexi.com/jiexi/?url="},
{"name": "Yparse", "url": "https://jx.yparse.com/index.php?url="},
{"name": "搜影片名称", "url": "https://z1.m1907.top/?jx="},
{"name": "超清VIP", "url": "https://jx.xmflv.cc/?url="},
{"name": "vip视频解析1", "url": "https://www.8090.la/8090/?url="},
{"name": "vip接口2", "url": "http://jiexi44.qmbo.cn/jiexi/?url="},
{"name": "vip视频解析3", "url": "https://jx.parwix.com:4433/player/?url="},
{"name": "全能VIP解析", "url": "http://api.apii.top/?v="},
{"name": "备用线路1", "url": "https://svip.bljiex.cc/?v="},
{"name": "备用线路2", "url": "https://yparse.ik9.cc/index.php?url="},
{"name": "爱豆", "url": "https://jx.aidouer.net/?url="},
{"name": "YT", "url": "https://jx.yangtu.top/?url="},
{"name": "qianqi", "url": "https://api.qianqi.net/vip/?url="},
{"name": "花旗解析", "url": "https://www.huaqi.live/?url="},
]
# 平台名称映射
PLATFORM_NAMES = {
"qiyi": "爱奇艺", "iqiyi": "爱奇艺",
"imgo": "芒果TV", "mgtv": "芒果TV",
"qq": "腾讯视频", "v.qq": "腾讯视频",
"youku": "优酷",
"leshi": "乐视", "le": "乐视",
"sohu": "搜狐",
"bilibili": "B站",
"1905": "1905",
"pptv": "PPTV",
"xigua": "西瓜视频",
"douyin": "抖音",
"kuaishou": "快手",
}
# 搜索缓存: 用于保存苹果CMS搜索结果的播放信息
_search_cache = {}
class Spider(Spider):
def init(self, extend=""):
self._load_config()
def _load_config(self):
"""从 /api/config 加载解析线路等配置 (已禁用, 使用固定配置)"""
pass
def getName(self):
return "juok3"
def isVideoFormat(self, url):
return ".m3u8" in url or ".mp4" in url or ".flv" in url
def manualVideoCheck(self):
return True
def _cover(self, raw):
"""统一封面URL"""
if not raw:
return ""
if raw.startswith("//"):
return "https:" + raw
return raw
def _year(self, pubdate):
"""从pubdate提取年份"""
if pubdate and len(pubdate) >= 4:
return pubdate[:4]
return ""
def _build_filter_url(self, tid, pg=1):
"""构建分类API URL"""
info = CLASS_MAP.get(tid)
if not info:
return None
cat_id, type_name, _ = info
params = []
if cat_id is not None:
params.append(f"catId={cat_id}")
if type_name:
params.append(f"type={quote(type_name)}")
params.append(f"page={pg}")
params.append("size=24")
return f"{HOST}/api/filter?{'&'.join(params)}"
def _items_from_filter(self, data, cat_id=None):
"""从 /api/filter 返回的JSON中提取影片列表"""
items = []
movies = data.get("movies", [])
for m in movies:
vid = f"detail:{cat_id or 2}:{m.get('id', '')}"
cover = self._cover(m.get("cdncover") or m.get("cover", ""))
# 构建备注信息
remarks = ""
if m.get("upinfo"):
remarks = str(m["upinfo"])
elif m.get("total"):
remarks = f"{m['total']}"
# 电影等没有upinfo时, 用年份+地区
if not remarks:
year = self._year(m.get("pubdate", ""))
area = ""
if m.get("area"):
if isinstance(m["area"], list) and m["area"]:
area = m["area"][0]
else:
area = str(m["area"])
parts = [p for p in [year, area] if p]
if parts:
remarks = " ".join(parts)
items.append({
"vod_id": vid,
"vod_name": m.get("title", "").strip(),
"vod_pic": cover,
"vod_remarks": remarks,
"vod_year": self._year(m.get("pubdate", "")),
})
return items
def _extract_items_from_search_api(self, data):
"""从搜索API返回的JSON中提取影片列表"""
global _search_cache
items = []
results = data.get("results", [])
for item in results:
if "vod_name" in item:
# 苹果CMS格式 (外部源)
vod_id = str(item.get("vod_id", ""))
if not vod_id:
continue
source_key = item.get("sourceKey", "")
vid = f"search:{vod_id}:{source_key}"
pic = item.get("vod_pic", "")
if pic and pic.startswith("//"):
pic = "https:" + pic
# 缓存播放信息用于详情页
_search_cache[vid] = {
"vod_name": item.get("vod_name", "").strip(),
"vod_pic": pic,
"vod_remarks": item.get("vod_remarks", ""),
"vod_year": item.get("vod_year", ""),
"vod_play_url": item.get("vod_play_url", ""),
"vod_play_from": item.get("vod_play_from", ""),
}
items.append({
"vod_id": vid,
"vod_name": item.get("vod_name", "").strip(),
"vod_pic": pic,
"vod_remarks": item.get("vod_remarks", ""),
"vod_year": item.get("vod_year", ""),
})
elif "title" in item:
# 360kan格式
title = item.get("title", "").replace("<b>", "").replace("</b>", "").strip()
cat_id = item.get("cat_id", "")
en_id = item.get("en_id", "")
if not cat_id or not en_id:
continue
vid = f"detail:{cat_id}:{en_id}"
pic = item.get("cover", "")
if pic and pic.startswith("//"):
pic = "https:" + pic
items.append({
"vod_id": vid,
"vod_name": title,
"vod_pic": pic,
"vod_remarks": item.get("cat_name", ""),
})
return items
def homeContent(self, filter=False):
classes = []
for k, v in CLASS_MAP.items():
classes.append({"type_id": k, "type_name": v[2]})
return {"class": classes}
def homeVideoContent(self):
try:
# 首页推荐: 从多个分类各取一些, 凑够50部左右
all_items = []
# 电影20条
for cat_id, size in [(1, 20), (2, 15), (3, 8), (4, 7)]:
url = f"{HOST}/api/filter?catId={cat_id}&page=1&size={size}"
r = self.fetch(url, headers={"User-Agent": UA}, timeout=15000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
items = self._items_from_filter(data, cat_id=cat_id)
all_items.extend(items)
return {"list": all_items}
except:
return {"list": []}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
"""分类内容 - 调用 /api/filter API"""
try:
pn = 1
try:
pn = max(int(str(pg)), 1)
except:
pass
api_url = self._build_filter_url(tid, pn)
if not api_url:
return {"list": [], "page": pn, "pagecount": 1, "limit": 24, "total": 0}
r = self.fetch(api_url, headers={"User-Agent": UA}, timeout=30000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
cat_id = CLASS_MAP.get(tid, (None, None, None))[0]
items = self._items_from_filter(data, cat_id=cat_id)
total = data.get("total", 0)
# 估算总页数
pagecount = (total // 24) + (1 if total % 24 else 0) if total else 1
return {
"list": items,
"page": pn,
"pagecount": max(pagecount, pn),
"limit": 24,
"total": total,
}
except Exception:
return {"list": [], "page": pg, "pagecount": 1, "limit": 24, "total": 0}
def detailContent(self, ids):
"""详情页 - 调用 /api/detail API"""
try:
vid = str(ids[0]) if ids else ""
if not vid:
return {"list": []}
# 苹果CMS搜索结果的直接播放
if vid.startswith("search:"):
return self._detail_from_search(vid)
if not vid.startswith("detail:"):
return {"list": []}
parts = vid.split(":", 2)
if len(parts) != 3:
return {"list": []}
cat_id, hash_id = parts[1], parts[2]
if not cat_id or not hash_id:
return {"list": []}
# 调用详情API
detail_url = f"{HOST}/api/detail?cat={cat_id}&id={hash_id}"
r = self.fetch(detail_url, headers={"User-Agent": UA, "Referer": HOST}, timeout=30000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
if data.get("errno") != 0 or not data.get("data"):
return {"list": []}
d = data["data"]
# 基本信息
title = d.get("title", "")
cover = self._cover(d.get("cdncover") or d.get("cover", ""))
year = self._year(d.get("pubdate", ""))
area = ", ".join(d.get("area", [])) if isinstance(d.get("area"), list) else str(d.get("area", ""))
type_name = ", ".join(d.get("moviecategory", [])) if isinstance(d.get("moviecategory"), list) else str(d.get("moviecategory", ""))
director = ", ".join(d.get("director", [])) if isinstance(d.get("director"), list) else str(d.get("director", ""))
actor = ", ".join(d.get("actor", [])) if isinstance(d.get("actor"), list) else str(d.get("actor", ""))
content = d.get("description", "")
total = d.get("total", 0)
upinfo = d.get("upinfo", "")
remarks = str(upinfo) if upinfo else (f"{total}" if total else "")
# 收集所有平台的播放信息
allepi = d.get("allepidetail", {})
pld = d.get("playlinksdetail", {})
# 平台名称映射辅助函数
def _platform_name(site):
return PLATFORM_NAMES.get(site, site)
# 处理多集剧集 (TV series) - 有allepidetail时取第一个平台
if allepi:
first_site = list(allepi.keys())[0]
episodes = allepi[first_site]
pf_list = []
pu_list = []
for api in PARSE_APIS:
ep_list = []
for ep in episodes:
ep_num = ep.get("playlink_num", "")
ep_url = ep.get("url", "")
if not ep_url:
continue
parse_url = api["url"] + ep_url
ep_list.append(f"{ep_num}集${parse_url}")
if ep_list:
pf_list.append(api["name"])
pu_list.append("#".join(ep_list))
# 处理单集电影/短剧 - 列出所有平台的default_url
elif pld:
pf_list = []
pu_list = []
# 收集所有有default_url的平台
platforms = []
for site, info in pld.items():
default_url = info.get("default_url", "")
if default_url:
platforms.append((site, default_url))
if not platforms:
return {"list": []}
for api in PARSE_APIS:
ep_list = []
for site, url in platforms:
if not url:
continue
parse_url = api["url"] + url
ep_list.append(f"{_platform_name(site)}${parse_url}")
if ep_list:
pf_list.append(api["name"])
pu_list.append("#".join(ep_list))
else:
return {"list": []}
vod = {
"vod_id": vid,
"vod_name": title,
"vod_pic": cover,
"vod_year": year,
"vod_area": area,
"vod_class": type_name,
"vod_director": director,
"vod_actor": actor,
"vod_content": content,
"vod_remarks": remarks,
"vod_play_from": "$$$".join(pf_list),
"vod_play_url": "$$$".join(pu_list),
}
return {"list": [vod]}
except Exception:
return {"list": []}
def _detail_from_search(self, vid):
"""处理搜索结果的直接播放(苹果CMS格式)"""
global _search_cache
try:
cached = _search_cache.get(vid, {})
if not cached or not cached.get("vod_play_url"):
return {"list": []}
# 从缓存构造详情
return {
"list": [{
"vod_id": vid,
"vod_name": cached.get("vod_name", ""),
"vod_pic": cached.get("vod_pic", ""),
"vod_remarks": cached.get("vod_remarks", ""),
"vod_year": cached.get("vod_year", ""),
"vod_play_from": cached.get("vod_play_from", "播放"),
"vod_play_url": cached.get("vod_play_url", ""),
}]
}
except:
return {"list": []}
def searchContent(self, key, quick=False, pg=1):
"""搜索 - 调用API"""
try:
pn = 1
try:
pn = int(str(pg))
except:
pass
url = f"{HOST}/api/search?q={quote(key)}"
if pn > 1:
url += f"&page={pn}"
r = self.fetch(url, headers={
"User-Agent": UA,
"Accept": "application/json, text/plain, */*",
"Referer": HOST + "/",
}, timeout=30000)
text = r.text if hasattr(r, 'text') else str(r)
data = json.loads(text)
items = self._extract_items_from_search_api(data)
return {"list": items, "page": pn}
except:
return {"list": [], "page": 1}
def playerContent(self, flag, id, vipFlags=None):
"""播放 - 官源返回原始URL让壳子解析, 解析线路返回拼接URL"""
url = str(id) if id else str(flag)
if not url:
return {"url": ""}
# 去掉名称前缀
if "$" in url:
parts = url.split("$", 1)
url = parts[1]
# 如果是搜索结果的vod_id
if url.startswith("search:"):
return self._player_from_search(url)
# m3u8直链直接播
if url.startswith("http") and self.isVideoFormat(url):
return {"url": url}
# 视频站链接
if url.startswith("http"):
return {
"url": url,
"parse": 1,
"header": {"User-Agent": UA}
}
return {"url": url, "parse": 1, "header": {"User-Agent": UA}}
def _player_from_search(self, vid):
"""从搜索结果恢复播放链接"""
global _search_cache
try:
cached = _search_cache.get(vid, {})
play_url = cached.get("vod_play_url", "")
if not play_url:
return {"url": ""}
# vod_play_url 格式: 第1集$URL#第2集$URL
# playerContent 传入的 id 已经是单集URL (带$前缀)
# 如果 vid 中没有 $ , 返回第一集
if "$" in vid:
# 这种情况不应该发生, playerContent 已经处理了 $
return {"url": ""}
# 返回第一集URL
first_ep = play_url.split("#")[0]
if "$" in first_ep:
url = first_ep.split("$")[1]
else:
url = first_ep
if url.startswith("http"):
return {"url": url, "parse": 0 if self.isVideoFormat(url) else 1}
return {"url": url}
except:
return {"url": ""}
def localProxy(self, param):
pass
+326
View File
@@ -0,0 +1,326 @@
# -*- coding: utf-8 -*-
"""
ikanbot.com (我爱看播) 爬虫
- 首页: 从 /hot/index-movie-热门.html 和 /hot/index-tv-热门.html 获取推荐
- 分类: 按 movie/tv/anime/variety/documentary + 子筛选(热门/最新/华语/动作等)
- 搜索: /search?q={keyword}
- 播放: /api/getResN API获取m3u8直链 (无需解析线路)
"""
import sys
import re
import json
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
def _(x): return x
HOST = "https://www.ikanbot.com"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
# 分类配置
# type_id -> {name, url_type, tag}
# url_type: 'hot' 用 /hot/index-{type}-{tag}.html
# 'category' 用 /category/{id}?p={page}
CLASS_MAP = {
# === 电影 ===
"movie_hot": {"name": "电影·热门", "url_type": "hot", "cat": "movie", "tag": "热门"},
"movie_new": {"name": "电影·最新", "url_type": "hot", "cat": "movie", "tag": "最新"},
"movie_classic": {"name": "电影·经典", "url_type": "hot", "cat": "movie", "tag": "经典"},
"movie_douban": {"name": "电影·豆瓣高分", "url_type": "hot", "cat": "movie", "tag": "豆瓣高分"},
"movie_hidden": {"name": "电影·冷门佳片", "url_type": "hot", "cat": "movie", "tag": "冷门佳片"},
"movie_cn": {"name": "电影·华语", "url_type": "hot", "cat": "movie", "tag": "华语"},
"movie_us": {"name": "电影·欧美", "url_type": "hot", "cat": "movie", "tag": "欧美"},
"movie_kr": {"name": "电影·韩国", "url_type": "hot", "cat": "movie", "tag": "韩国"},
"movie_jp": {"name": "电影·日本", "url_type": "hot", "cat": "movie", "tag": "日本"},
"movie_action": {"name": "电影·动作", "url_type": "hot", "cat": "movie", "tag": "动作"},
"movie_comedy": {"name": "电影·喜剧", "url_type": "hot", "cat": "movie", "tag": "喜剧"},
"movie_love": {"name": "电影·爱情", "url_type": "hot", "cat": "movie", "tag": "爱情"},
"movie_scifi": {"name": "电影·科幻", "url_type": "hot", "cat": "movie", "tag": "科幻"},
"movie_susp": {"name": "电影·悬疑", "url_type": "hot", "cat": "movie", "tag": "悬疑"},
"movie_horror": {"name": "电影·恐怖", "url_type": "hot", "cat": "movie", "tag": "恐怖"},
"movie_grow": {"name": "电影·成长", "url_type": "hot", "cat": "movie", "tag": "成长"},
"movie_top250": {"name": "豆瓣top250", "url_type": "hot", "cat": "movie", "tag": "豆瓣top250"},
# === 剧集 ===
"tv_hot": {"name": "剧集·热门", "url_type": "hot", "cat": "tv", "tag": "热门"},
"tv_new": {"name": "剧集·最新", "url_type": "hot", "cat": "tv", "tag": "最新"},
"tv_us": {"name": "美剧", "url_type": "hot", "cat": "tv", "tag": "美剧"},
"tv_uk": {"name": "英剧", "url_type": "hot", "cat": "tv", "tag": "英剧"},
"tv_kr": {"name": "韩剧", "url_type": "hot", "cat": "tv", "tag": "韩剧"},
"tv_jp": {"name": "日剧", "url_type": "hot", "cat": "tv", "tag": "日剧"},
"tv_cn": {"name": "国产剧", "url_type": "hot", "cat": "tv", "tag": "国产剧"},
"tv_hk": {"name": "港剧", "url_type": "hot", "cat": "tv", "tag": "港剧"},
"tv_classic": {"name": "剧集·经典", "url_type": "hot", "cat": "tv", "tag": "经典"},
"tv_douban": {"name": "剧集·豆瓣高分", "url_type": "hot", "cat": "tv", "tag": "豆瓣高分"},
# === 动漫/综艺/纪录片 (用category保证内容准确) ===
"anime": {"name": "动漫", "url_type": "category", "cat_id": 18},
"variety": {"name": "综艺", "url_type": "category", "cat_id": 19},
"documentary": {"name": "纪录片", "url_type": "category", "cat_id": 20},
}
class Spider(Spider):
def init(self, extend=""):
self._session = rq.Session()
self._session.headers.update({"User-Agent": UA})
try:
self._session.get(HOST, timeout=10)
except:
pass
def getName(self):
return "ikanbot"
def isVideoFormat(self, url):
return ".m3u8" in url or ".mp4" in url or ".flv" in url
def manualVideoCheck(self):
return False
def homeContent(self, filter=False):
classes = []
for tid, info in CLASS_MAP.items():
classes.append({"type_id": tid, "type_name": info["name"]})
return {"class": classes}
def homeVideoContent(self):
try:
all_items = []
# 电影热门
items = self._fetch_hot_list("movie", "热门", 1, 24)
all_items.extend(items)
# 剧集热门
items = self._fetch_hot_list("tv", "热门", 1, 24)
all_items.extend(items)
return {"list": all_items}
except:
return {"list": []}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
try:
pn = max(int(str(pg)), 1)
info = CLASS_MAP.get(tid)
if not info:
return {"list": [], "page": pn, "pagecount": 1, "limit": 24, "total": 0}
url_type = info.get("url_type", "category")
if url_type == "hot":
cat = info["cat"]
tag = info["tag"]
items = self._fetch_hot_list(cat, tag, pn, 24)
else:
cat_id = info.get("cat_id", 1)
items = self._fetch_category_list(cat_id, pn, 48)
return {"list": items, "page": pn, "pagecount": pn + 10, "limit": 24, "total": 0}
except:
return {"list": [], "page": pg, "pagecount": 1, "limit": 24, "total": 0}
def detailContent(self, ids):
try:
vid = str(ids[0]) if ids else ""
if not vid:
return {"list": []}
detail_url = f"{HOST}/play/{vid}"
r = self._get(detail_url, timeout=15000)
html = r.text if hasattr(r, 'text') else str(r)
etoken = self._extract(html, r'id="e_token"\s+value="([^"]+)"')
cid = self._extract(html, r'id="current_id"\s+value="([^"]+)"')
mtype = self._extract(html, r'id="mtype"\s+value="([^"]+)"') or "1"
title = self._extract(html, r'<h1[^>]*>([^<]+)</h1>') or ""
# 封面: 从第一个data-src取
cover = self._extract(html, r'data-src="([^"]+\.(?:jpg|png|webp))"') or ""
if not cid or not etoken:
return {"list": []}
token = self._gen_token(cid, etoken)
api_url = f"{HOST}/api/getResN?videoId={cid}&mtype={mtype}&token={token}"
r2 = self._get(api_url, timeout=15000)
text = r2.text if hasattr(r2, 'text') else str(r2)
data = json.loads(text)
if data.get("state") != 1:
return {"list": []}
source_list = data.get("data", {}).get("list", [])
pf_list = []
pu_list = []
for src in source_list:
site_id = src.get("siteId", "")
res_data = src.get("resData", "[]")
try:
episodes = json.loads(res_data)
except:
episodes = []
if not episodes:
continue
ep_list = []
for ep in episodes:
flag = ep.get("flag", f"线路{site_id}")
ep_url = ep.get("url", "")
if "$" in ep_url:
ep_name, ep_play_url = ep_url.split("$", 1)
else:
ep_name = ep_url[:30]
ep_play_url = ep_url
if ep_play_url:
ep_list.append(f"{ep_name}${ep_play_url}")
if ep_list:
pf_list.append(flag)
pu_list.append("#".join(ep_list))
vod = {
"vod_id": vid,
"vod_name": title,
"vod_pic": cover,
"vod_play_from": "$$$".join(pf_list),
"vod_play_url": "$$$".join(pu_list),
}
return {"list": [vod]}
except:
return {"list": []}
def searchContent(self, key, quick=False, pg=1):
try:
url = f"{HOST}/search?q={quote(key)}"
items = self._fetch_search(url)
return {"list": items, "page": 1}
except:
return {"list": [], "page": 1}
def playerContent(self, flag, id, vipFlags=None):
url = str(id) if id else str(flag)
if not url:
return {"url": ""}
if "$" in url:
parts = url.split("$", 1)
url = parts[1]
if url.startswith("http"):
return {"url": url}
return {"url": url}
def _get(self, url, timeout=15):
r = self._session.get(url, timeout=timeout)
r.encoding = 'utf-8'
return r
def _extract(self, text, pattern):
m = re.search(pattern, text)
return m.group(1) if m else ""
def _gen_token(self, current_id, e_token):
last4 = current_id[-4:]
parts = []
tk = e_token
for ch in last4:
mod = int(ch) % 3 + 1
part = tk[mod:mod + 8]
parts.append(part)
tk = tk[mod + 8:]
return "".join(parts)
def _fetch_category_list(self, cat_id, page, limit):
"""从 /category/{cat_id}?p={page} 抓取影片列表"""
url = f"{HOST}/category/{cat_id}?p={page}"
r = self._get(url, timeout=15000)
html = r.text if hasattr(r, 'text') else str(r)
items = []
pattern = re.compile(
r'<a\s+class="item"\s+href="(/play/(\d+))">'
r'[\s\S]*?'
r'<img[^>]+id="\d+"[^>]+alt="([^"]+)"'
r'(?:[^>]+data-src="([^"]+)")?'
)
matches = pattern.findall(html)
for href, vid, title, cover in matches[:limit]:
# 没封面的在分类列表中隐藏
if not cover or cover.startswith("data:"):
continue
items.append({
"vod_id": vid,
"vod_name": title.strip(),
"vod_pic": cover,
"vod_remarks": "",
})
return items
def _fetch_hot_list(self, cat, tag, page, limit):
"""从 /hot/index-{cat}-{tag}-p-{page}.html 抓取影片列表"""
if page > 1:
url = f"{HOST}/hot/index-{cat}-{tag}-p-{page}.html"
else:
url = f"{HOST}/hot/index-{cat}-{tag}.html"
r = self._get(url, timeout=15000)
html = r.text if hasattr(r, 'text') else str(r)
items = []
pattern = re.compile(
r'<a\s+class="item"\s+href="(/play/(\d+))">'
r'[\s\S]*?'
r'<img[^>]+id="\d+"[^>]+alt="([^"]+)"'
r'(?:[^>]+data-src="([^"]+)")?'
)
matches = pattern.findall(html)
for href, vid, title, cover in matches[:limit]:
# 没封面的在分类列表中隐藏
if not cover or cover.startswith("data:"):
continue
items.append({
"vod_id": vid,
"vod_name": title.strip(),
"vod_pic": cover,
"vod_remarks": "",
})
return items
def _fetch_search(self, url):
r = self._get(url, timeout=15000)
html = r.text if hasattr(r, 'text') else str(r)
items = []
seen = set()
pattern = re.compile(
r'<a[^>]+href="(/play/(\d+))"[^>]*>'
r'[\s\S]*?'
r'<img[^>]+id="\d+"[^>]+alt="([^"]+)"'
r'(?:[^>]+data-src="([^"]+)")?'
)
matches = pattern.findall(html)
for href, vid, title, cover in matches:
if vid in seen:
continue
seen.add(vid)
items.append({
"vod_id": vid,
"vod_name": title.strip(),
"vod_pic": cover or "",
"vod_remarks": "",
})
return items
def localProxy(self, param):
pass
+1260
View File
@@ -0,0 +1,1260 @@
# -*- coding: utf-8 -*-
# by @星河
# 修复版本 - 参考最新三合一.js重构虎牙、斗鱼、B站直播逻辑
# 修复:虎牙清晰度选择,确保ratio参数正确传递码率值
# 修复:斗鱼切换分辨率只能播放1秒的问题(每次重新获取安全密钥和签名)
# 修复:B站使用特殊UA和WBI签名绕过-352风控 [^90^][^30^]
import json
import re
import sys
import time
import hashlib
import random
import urllib.parse
from base64 import b64decode, b64encode
from urllib.parse import parse_qs
import requests
from pyquery import PyQuery as pq
sys.path.append('..')
from base.spider import Spider
from concurrent.futures import ThreadPoolExecutor
class Spider(Spider):
def init(self, extend=""):
# 初始化B站WBI密钥
self.bili_wbi_keys = None
self.bili_wbi_expire = 0
pass
def getName(self):
return "直播"
def isVideoFormat(self, url):
pass
def manualVideoCheck(self):
pass
def destroy(self):
pass
headers = [
{
# 特殊UA绕过B站风控 [^90^]
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0"
},
{
"User-Agent": "Dart/3.4 (dart:io)"
}
]
excepturl = 'https://www.baidu.com'
hosts = {
"huya": ["https://www.huya.com", "https://mp.huya.com"],
"douyu": "https://www.douyu.com",
"wangyi": "https://cc.163.com",
"bili": ["https://api.live.bilibili.com", "https://api.bilibili.com"]
}
referers = {
"huya": "https://live.cdn.huya.com",
"douyu": "https://m.douyu.com",
"bili": "https://live.bilibili.com"
}
playheaders = {
"wangyi": {
"User-Agent": "ExoPlayer",
"Connection": "Keep-Alive",
"Icy-MetaData": "1"
},
"bili": {
'Accept': '*/*',
'Icy-MetaData': '1',
'referer': 'https://live.bilibili.com',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
},
'huya': {
'User-Agent': 'ExoPlayer',
'Connection': 'Keep-Alive',
'Icy-MetaData': '1'
},
'douyu': {
'User-Agent': 'libmpv',
'Icy-MetaData': '1'
}
}
# WBI签名相关常量 [^30^]
MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
36, 20, 34, 44, 52
]
def _get_bili_wbi_keys(self):
"""获取B站WBI密钥 [^30^]"""
try:
# 检查缓存
if self.bili_wbi_keys and time.time() < self.bili_wbi_expire:
return self.bili_wbi_keys
# 从导航接口获取 - 使用特殊UA [^90^]
resp = self.fetch(
'https://api.bilibili.com/x/web-interface/nav',
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
'Referer': 'https://www.bilibili.com/'
}
).json()
if resp.get('code') != 0:
return None
img_url = resp['data']['wbi_img']['img_url']
sub_url = resp['data']['wbi_img']['sub_url']
# 提取文件名作为key
img_key = img_url.rsplit('/', 1)[1].split('.')[0]
sub_key = sub_url.rsplit('/', 1)[1].split('.')[0]
self.bili_wbi_keys = (img_key, sub_key)
self.bili_wbi_expire = time.time() + 86400 # 24小时过期
return self.bili_wbi_keys
except Exception as e:
print(f"获取B站WBI密钥失败: {e}")
return None
def _get_mixin_key(self, orig: str):
"""生成mixin_key [^30^]"""
return ''.join([orig[i] for i in self.MIXIN_KEY_ENC_TAB])[:32]
def _enc_wbi(self, params: dict):
"""WBI签名 [^30^]"""
keys = self._get_bili_wbi_keys()
if not keys:
return params
img_key, sub_key = keys
mixin_key = self._get_mixin_key(img_key + sub_key)
# 添加时间戳
params['wts'] = round(time.time())
# 排序参数
params = dict(sorted(params.items()))
# 过滤特殊字符
params = {
k: ''.join(filter(lambda c: c not in "!'()*", str(v)))
for k, v in params.items()
}
# 计算签名
query = urllib.parse.urlencode(params)
w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
params['w_rid'] = w_rid
return params
def process_bili(self):
"""获取B站分类列表 - 使用WBI签名 [^30^]"""
try:
# 尝试获取分类列表 - 使用特殊UA和WBI签名
params = {'need_entrance': 1, 'parent_id': 0}
signed_params = self._enc_wbi(params)
data = self.fetch(
f'{self.hosts["bili"][0]}/room/v1/Area/getList',
params=signed_params,
headers=self.headers[0]
).json()
if data.get('code') == 0 and data.get('data'):
# 保存分类数据供后续使用
self.bili_areas = data['data']
return ('bili', [{'key': 'cate', 'name': '分类',
'value': [{'n': i['name'], 'v': str(i['id'])}
for i in data['data']]}])
return 'bili', None
except Exception as e:
print(f"bili处理错误: {e}")
# 使用默认分类
return 'bili', [{'key': 'cate', 'name': '分类',
'value': [{'n': '网游', 'v': '2'}, {'n': '手游', 'v': '3'},
{'n': '单机', 'v': '6'}, {'n': '娱乐', 'v': '1'},
{'n': '电台', 'v': '5'}, {'n': '虚拟主播', 'v': '9'},
{'n': '生活', 'v': '10'}, {'n': '知识', 'v': '11'},
{'n': '赛事', 'v': '13'}]}]
def process_douyu(self):
try:
self.dyufdata = self.fetch(
f'{self.referers["douyu"]}/api/cate/list',
headers=self.headers[1]
).json()
return ('douyu', [{'key': 'cate', 'name': '分类',
'value': [{'n': i['cate1Name'], 'v': str(i['cate1Id'])}
for i in self.dyufdata['data']['cate1Info']]}])
except Exception as e:
print(f"douyu错误: {e}")
return 'douyu', None
def homeContent(self, filter):
result = {}
cateManual = {
"虎牙": "huya",
"斗鱼": "douyu",
"网易": "wangyi",
"B站": "bili"
}
classes = []
filters = {
'huya': [{'key': 'cate', 'name': '分类',
'value': [{'n': '网游', 'v': '1'}, {'n': '单机', 'v': '2'},
{'n': '娱乐', 'v': '8'}, {'n': '手游', 'v': '3'}]}]
}
with ThreadPoolExecutor(max_workers=2) as executor:
futures = {
executor.submit(self.process_bili): 'bili',
executor.submit(self.process_douyu): 'douyu'
}
for future in futures:
platform, filter_data = future.result()
if filter_data:
filters[platform] = filter_data
for k in cateManual:
classes.append({
'type_name': k,
'type_id': cateManual[k]
})
result['class'] = classes
result['filters'] = filters
return result
def homeVideoContent(self):
pass
def categoryContent(self, tid, pg, filter, extend):
vdata = []
result = {}
pagecount = 9999
result['page'] = pg
result['limit'] = 90
result['total'] = 999999
if tid == 'wangyi':
vdata, pagecount = self.wyccContent(tid, pg, filter, extend, vdata)
elif 'bili' in tid:
vdata, pagecount = self.biliContent(tid, pg, filter, extend, vdata)
elif 'huya' in tid:
vdata, pagecount = self.huyaContent(tid, pg, filter, extend, vdata)
elif 'douyu' in tid:
vdata, pagecount = self.douyuContent(tid, pg, filter, extend, vdata)
result['list'] = vdata
result['pagecount'] = pagecount
return result
def wyccContent(self, tid, pg, filter, extend, vdata):
params = {
'format': 'json',
'start': (int(pg) - 1) * 20,
'size': '20',
}
response = self.fetch(f'{self.hosts[tid]}/api/category/live/', params=params, headers=self.headers[0]).json()
for i in response['lives']:
if i.get('cuteid'):
bvdata = self.buildvod(
vod_id=f"{tid}@@{i['cuteid']}",
vod_name=i.get('title'),
vod_pic=i.get('cover'),
vod_remarks=i.get('nickname'),
style={"type": "rect", "ratio": 1.33}
)
vdata.append(bvdata)
return vdata, 9999
def biliContent(self, tid, pg, filter, extend, vdata):
"""B站分类内容 - 使用WBI签名绕过风控 [^30^][^90^]"""
try:
# 分类列表 - 显示子分类
if extend.get('cate') and pg == '1' and 'click' not in tid:
# 从已保存的分类数据中找到对应分类的子分类
if hasattr(self, 'bili_areas'):
for area in self.bili_areas:
if str(area['id']) == extend['cate']:
for sub_area in area.get('list', []):
v = self.buildvod(
vod_id=f"click_{tid}@@{extend['cate']}@@{sub_area['id']}",
vod_name=sub_area.get('name'),
vod_pic=sub_area.get('pic'),
vod_tag=1,
style={"type": "oval", "ratio": 1}
)
vdata.append(v)
return vdata, 1
# 如果没有找到子分类,直接返回空,让用户进入房间列表
return vdata, 1
# 房间列表 - 使用getList接口并添加WBI签名 [^30^]
if 'click' in tid:
# 子分类房间
ids = tid.split('_')[1].split('@@')
tid = ids[0]
parent_area_id = ids[1]
area_id = ids[2]
else:
# 默认使用分类ID作为parent_area_idarea_id为0表示该分类下所有
parent_area_id = extend.get('cate', '2') # 默认网游
area_id = 0
# 构建请求参数并添加WBI签名 [^30^]
params = {
'parent_area_id': parent_area_id,
'area_id': area_id,
'page': pg,
'platform': 'web',
'sort_type': 'online' # 按热度排序
}
signed_params = self._enc_wbi(params)
# 调用getList接口
api_url = f'{self.hosts[tid][0]}/xlive/web-interface/v1/second/getList'
data = self.fetch(api_url, params=signed_params, headers=self.headers[0]).json()
# 如果WBI签名失败,尝试不带签名
if data.get('code') == -352:
print("WBI签名失败,尝试无签名请求...")
params = {
'parent_area_id': parent_area_id,
'area_id': area_id,
'page': pg,
'platform': 'web',
'sort_type': 'online'
}
data = self.fetch(api_url, params=params, headers=self.headers[0]).json()
if data.get('code') == 0:
room_list = data.get('data', {}).get('list', [])
for room in room_list:
if room.get('roomid'):
# 处理在线人数显示
online = room.get('online', 0)
if online > 10000:
online_str = f"{online / 10000:.1f}"
else:
online_str = str(online)
v = self.buildvod(
f"{tid}@@{room['roomid']}",
room.get('title', '未知标题'),
room.get('cover') or room.get('system_cover'),
f"{online_str}",
0,
room.get('uname', ''),
style={"type": "rect", "ratio": 1.33}
)
vdata.append(v)
# 检查是否有更多数据
has_more = data.get('data', {}).get('has_more', 0)
if not has_more:
pagecount = int(pg)
else:
pagecount = 9999
else:
print(f"B站API返回错误: {data.get('message', '未知错误')} (code: {data.get('code')})")
pagecount = 1
return vdata, pagecount
except Exception as e:
print(f"B站内容获取错误: {e}")
return vdata, 1
def huyaContent(self, tid, pg, filter, extend, vdata):
if extend.get('cate') and pg == '1' and 'click' not in tid:
id = extend.get('cate')
data = self.fetch(f'{self.referers[tid]}/liveconfig/game/bussLive?bussType={id}',
headers=self.headers[1]).json()
for i in data['data']:
v = self.buildvod(
vod_id=f"click_{tid}@@{int(i['gid'])}",
vod_name=i.get('gameFullName'),
vod_pic=f'https://huyaimg.msstatic.com/cdnimage/game/{int(i["gid"])}-MS.jpg',
vod_tag=1,
style={"type": "oval", "ratio": 1}
)
vdata.append(v)
return vdata, 1
else:
gid = ''
if 'click' in tid:
ids = tid.split('_')[1].split('@@')
tid = ids[0]
gid = f'&gameId={ids[1]}'
data = self.fetch(f'{self.hosts[tid][0]}/cache.php?m=LiveList&do=getLiveListByPage&tagAll=0{gid}&page={pg}',
headers=self.headers[1]).json()
for i in data['data']['datas']:
if i.get('profileRoom'):
v = self.buildvod(
f"{tid}@@{i['profileRoom']}",
i.get('introduction'),
i.get('screenshot'),
str(int(i.get('totalCount', '1')) / 10000) + '',
0,
i.get('nick'),
style={"type": "rect", "ratio": 1.33}
)
vdata.append(v)
return vdata, 9999
def douyuContent(self, tid, pg, filter, extend, vdata):
if extend.get('cate') and pg == '1' and 'click' not in tid:
for i in self.dyufdata['data']['cate2Info']:
if str(i['cate1Id']) == extend['cate']:
v = self.buildvod(
vod_id=f"click_{tid}@@{i['cate2Id']}",
vod_name=i.get('cate2Name'),
vod_pic=i.get('icon'),
vod_remarks=i.get('count'),
vod_tag=1,
style={"type": "oval", "ratio": 1}
)
vdata.append(v)
return vdata, 1
else:
path = f'/japi/weblist/apinc/allpage/6/{pg}'
if 'click' in tid:
ids = tid.split('_')[1].split('@@')
tid = ids[0]
path = f'/gapi/rkc/directory/mixList/2_{ids[1]}/{pg}'
url = f'{self.hosts[tid]}{path}'
data = self.fetch(url, headers=self.headers[1]).json()
for i in data['data']['rl']:
v = self.buildvod(
vod_id=f"{tid}@@{i['rid']}",
vod_name=i.get('rn'),
vod_pic=i.get('rs16'),
vod_year=str(int(i.get('ol', 1)) / 10000) + '',
vod_remarks=i.get('nn'),
style={"type": "rect", "ratio": 1.33}
)
vdata.append(v)
return vdata, 9999
def detailContent(self, ids):
ids = ids[0].split('@@')
if ids[0] == 'wangyi':
vod = self.wyccDetail(ids)
elif ids[0] == 'bili':
vod = self.biliDetail(ids)
elif ids[0] == 'huya':
vod = self.huyaDetail(ids)
elif ids[0] == 'douyu':
vod = self.douyuDetail(ids)
return {'list': [vod]}
def wyccDetail(self, ids):
try:
vdata = self.getpq(f'{self.hosts[ids[0]]}/{ids[1]}', self.headers[0])('script').eq(-1).text()
def get_quality_name(vbr):
if vbr <= 600:
return "标清"
elif vbr <= 1000:
return "高清"
elif vbr <= 2000:
return "超清"
else:
return "蓝光"
data = json.loads(vdata)['props']['pageProps']['roomInfoInitData']
name = data['live'].get('title', ids[0])
vod = self.buildvod(vod_name=data.get('keywords_suffix'), vod_remarks=data['live'].get('title'),
vod_content=data.get('description_suffix'))
resolution_data = data['live']['quickplay']['resolution']
all_streams = {}
sorted_qualities = sorted(resolution_data.items(),
key=lambda x: x[1]['vbr'],
reverse=True)
for quality, data in sorted_qualities:
vbr = data['vbr']
quality_name = get_quality_name(vbr)
for cdn_name, url in data['cdn'].items():
if cdn_name not in all_streams and type(url) == str and url.startswith('http'):
all_streams[cdn_name] = []
if isinstance(url, str) and url.startswith('http'):
all_streams[cdn_name].extend([quality_name, url])
plists = []
names = []
for i, (cdn_name, stream_list) in enumerate(all_streams.items(), 1):
names.append(f'线路{i}')
pstr = f"{name}${ids[0]}@@{self.e64(json.dumps(stream_list))}"
plists.append(pstr)
vod['vod_play_from'] = "$$$".join(names)
vod['vod_play_url'] = "$$$".join(plists)
return vod
except Exception as e:
return self.handle_exception(e)
def biliDetail(self, ids):
"""
B站直播详情 - 使用playUrl接口获取多清晰度
"""
try:
room_id = ids[1]
# 获取房间信息
info_res = self.fetch(
f'{self.hosts["bili"][0]}/room/v1/Room/get_info?room_id={room_id}',
headers=self.headers[0]
).json()
if info_res.get('code') != 0:
return self.handle_exception(Exception("获取房间信息失败"))
room_info = info_res['data']
title = room_info.get('title', 'B站直播')
vod = self.buildvod(
vod_name=title,
type_name=f"{room_info.get('parent_area_name', '')}/{room_info.get('area_name', '')}",
vod_director=room_info.get('uname', ''),
vod_remarks=f"在线{room_info.get('online', 0)}"
)
# 获取播放地址信息
play_res = self.fetch(
f'{self.hosts["bili"][0]}/room/v1/Room/playUrl?cid={room_id}&qn=10000&platform=web',
headers={
**self.headers[0],
'Referer': 'https://live.bilibili.com/',
'Origin': 'https://live.bilibili.com'
}
).json()
if play_res.get('code') != 0:
return self.handle_exception(Exception("获取播放地址失败"))
play_data = play_res['data']
accept_quality = play_data.get('accept_quality', ['10000', '400', '250', '150'])
quality_desc = {item['qn']: item['desc'] for item in play_data.get('quality_description', [])}
# 构建清晰度列表
qualities = []
for qn in sorted([int(q) for q in accept_quality], reverse=True):
desc = quality_desc.get(qn, f'清晰度{qn}')
qualities.append(f"{desc}$bili@@{room_id}@@{qn}")
vod['vod_play_from'] = 'B站直播'
vod['vod_play_url'] = '#'.join(qualities)
return vod
except Exception as e:
print(f"B站详情错误: {e}")
return self.handle_exception(e)
def huyaDetail(self, ids):
"""
虎牙播放详情 - 参考最新三合一.js重构
支持多线路多清晰度选择
核心算法:通过房间信息API获取uid、streamName和rateArray,为每个清晰度生成签名URL
清晰度说明:
- 蓝光8M/6M/4M/10M = 8000/6000/4000/10000 kbps = 1080P+
- 蓝光 = 3000 kbps = 1080P
- 超清 = 2000 kbps = 1080P (官方标准)
- 高清 = 1200 kbps = 720P
- 标清/流畅 = 500-800 kbps = 480P/540P
"""
try:
room_id = ids[1]
# 1. 获取房间信息
api_url = f'{self.hosts[ids[0]][1]}/cache.php?m=Live&do=profileRoom&roomid={room_id}'
res = self.fetch(api_url, headers=self.headers[0])
if res.status_code != 200:
return self.handle_exception(Exception(f"API请求失败: {res.status_code}"))
data = res.json()
if not data or not data.get('data'):
return self.handle_exception(Exception("房间数据为空"))
room_data = data['data']
# 2. 提取关键信息
uid = room_data.get('profileInfo', {}).get('uid')
stream_info = room_data.get('stream', {})
live_data = room_data.get('liveData', {})
if not uid:
return self.handle_exception(Exception("缺少uid"))
# 3. 获取streamName和码率信息
base_stream_list = stream_info.get('baseSteamInfoList', [])
if not base_stream_list:
return self.handle_exception(Exception("无直播流信息"))
# 获取第一个CDN的streamName作为基准
base_stream = base_stream_list[0]
stream_name = base_stream.get('sStreamName')
if not stream_name:
return self.handle_exception(Exception("无法获取streamName"))
# 4. 构建VOD对象
vod = self.buildvod(
vod_name=live_data.get('introduction', '虎牙直播'),
type_name=live_data.get('gameFullName', ''),
vod_director=live_data.get('nick', ''),
vod_remarks=live_data.get('contentIntro', ''),
)
# 5. 获取所有CDN线路
cdn_list = []
for stream in base_stream_list:
cdn_type = stream.get('sCdnType', 'AL')
flv_url = stream.get('sFlvUrl', '')
hls_url = stream.get('sHlsUrl', '')
stream_name_cdn = stream.get('sStreamName', stream_name)
if flv_url:
cdn_list.append({
'cdn': cdn_type,
'flv_base': flv_url,
'hls_base': hls_url,
'stream_name': stream_name_cdn,
'priority': stream.get('iWebPriorityRate', 0)
})
# 按优先级排序
cdn_list.sort(key=lambda x: x['priority'], reverse=True)
# 6. 获取清晰度列表 (rateArray)
rate_array = stream_info.get('rateArray', [])
# 如果没有rateArray,尝试从vMultiStreamInfo获取
if not rate_array and 'vMultiStreamInfo' in room_data:
rate_array = room_data['vMultiStreamInfo']
# 如果仍然没有,使用默认清晰度(按虎牙官方标准)
if not rate_array:
rate_array = [
{'sDisplayName': '蓝光4M', 'iBitRate': 4000},
{'sDisplayName': '蓝光', 'iBitRate': 3000},
{'sDisplayName': '超清', 'iBitRate': 2000}, # 2000kbps = 1080P
{'sDisplayName': '高清', 'iBitRate': 1200}, # 1200kbps = 720P
{'sDisplayName': '流畅', 'iBitRate': 500}
]
# 过滤和排序清晰度
# 虎牙的rateArray中,iBitRate就是码率值,sDisplayName是显示名称
# 需要确保:超清=2000kbps(1080P),高清=1200kbps(720P)
filtered_rates = []
seen_bitrates = set()
for rate in rate_array:
bit_rate = rate.get('iBitRate', 0)
name = rate.get('sDisplayName', '')
# 跳过重复的码率
if bit_rate in seen_bitrates:
continue
# 修正清晰度名称,确保符合虎牙标准
# 2000kbps应该是超清(1080P),不是高清
if bit_rate == 2000 and ('高清' in name or '720' in name):
name = '超清' # 强制修正为超清
elif bit_rate == 1200 and ('标清' in name or '480' in name):
name = '高清' # 1200kbps对应高清
elif bit_rate == 2000 and name == '原画':
name = '超清' # 修正原画为超清
seen_bitrates.add(bit_rate)
filtered_rates.append({
'sDisplayName': name,
'iBitRate': bit_rate
})
# 按码率从高到低排序
sorted_rates = sorted(filtered_rates, key=lambda x: x['iBitRate'], reverse=True)
# 7. 为每个CDN生成各清晰度的播放URL
play_lines = []
line_names = []
for cdn_idx, cdn in enumerate(cdn_list[:3]): # 最多取3个CDN
cdn_name = cdn['cdn']
line_names.append(f"线路{cdn_idx + 1}({cdn_name})")
qualities = []
for rate in sorted_rates:
quality_name = rate['sDisplayName']
bit_rate = rate['iBitRate']
# 生成该清晰度的URL
quality_url = self._generate_huya_play_url(
cdn, uid, stream_name, bit_rate
)
qualities.extend([quality_name, quality_url])
# 编码该线路的所有清晰度
encoded_qualities = self.e64(json.dumps(qualities))
play_lines.append(f"{live_data.get('introduction', '直播')}${ids[0]}@@{encoded_qualities}")
# 8. 构建播放数据
vod['vod_play_from'] = "$$$".join(line_names)
vod['vod_play_url'] = "$$$".join(play_lines)
return vod
except Exception as e:
return self.handle_exception(e)
def _generate_huya_play_url(self, cdn, uid, stream_name, bit_rate):
"""
生成虎牙播放URL,参考最新三合一.js算法
关键:ratio参数必须正确设置为iBitRate值(如2000、4000等)
"""
# 基础URL构建
flv_base = cdn['flv_base']
stream = cdn['stream_name']
# 生成时间戳和签名参数
timestamp = int(time.time())
seqid = f"{uid}{timestamp}"
ss = hashlib.md5(f"{seqid}|huya_adr|102".encode()).hexdigest()
ws_time = hex(timestamp + 21600)[2:] # 16进制,有效期6小时
# 计算wsSecret
ws_secret = hashlib.md5(
f"DWq8BcJ3h6DJt6TY_{uid}_{stream_name}_{ss}_{ws_time}".encode()
).hexdigest()
# 构建基础URL
base_url = f"{flv_base}/{stream}.flv"
# 关键修复:ratio参数直接使用iBitRate值
# 超清=2000,高清=1200,蓝光=3000/4000/6000/8000
if bit_rate > 0:
ratio_param = f"ratio={bit_rate}"
else:
# 原画/0码率时,使用默认2000或从URL推断
ratio_param = "ratio=2000"
# 构建完整URL
play_url = (
f"{base_url}?{ratio_param}&wsSecret={ws_secret}&wsTime={ws_time}"
f"&ctype=huya_adr&seqid={seqid}&uid={uid}"
f"&fs=bgct&ver=1&t=102"
)
return play_url
def douyuDetail(self, ids):
"""
斗鱼播放详情 - 参考最新三合一.js重构
核心算法:设备ID生成 -> 获取加密密钥 -> 计算签名 -> 获取播放地址
修复:切换分辨率只能播放1秒的问题
方案:存储房间号和码率信息,在playerContent中实时获取对应码率的URL
"""
try:
channel = ids[1]
headers = self.gethr(0, zr=f'{self.hosts[ids[0]]}/{channel}')
# 1. 初始化会话和设备ID (参考JS中的initialize和setupDeviceId)
session = {}
# 请求首页获取Cookie
try:
home_res = self.fetch(f'{self.hosts[ids[0]]}/{channel}', headers=headers)
if home_res.headers.get('Set-Cookie'):
cookie_str = home_res.headers.get('Set-Cookie')
# 解析dy_did
did_match = re.search(r'dy_did=([a-f0-9]{32})', cookie_str)
if did_match:
device_id = did_match.group(1)
else:
device_id = self._generate_random_hex(32)
else:
device_id = self._generate_random_hex(32)
except:
device_id = self._generate_random_hex(32)
session['dy_did'] = device_id
session['mantine-color-scheme-value'] = 'light'
# 2. 获取房间基本信息
betard_res = self.fetch(f'{self.hosts[ids[0]]}/betard/{channel}', headers=headers).json()
if not betard_res or not betard_res.get('room'):
return self.handle_exception(Exception("获取房间信息失败"))
room_info = betard_res['room']
vname = room_info.get('room_name', '斗鱼直播')
vod = self.buildvod(
vod_name=vname,
vod_remarks=room_info.get('second_lvl_name', ''),
vod_director=room_info.get('nickname', ''),
)
# 3. 获取安全密钥 (参考JS中的getSecurityKey)
sec_url = f"{self.hosts[ids[0]]}/wgapi/livenc/liveweb/websec/getEncryption?did={device_id}"
sec_res = self.fetch(sec_url, headers=headers).json()
if not sec_res or sec_res.get('error') != 0:
return self.handle_exception(Exception("获取加密密钥失败"))
security_data = sec_res['data']
secret_key = security_data.get('key')
random_str = security_data.get('rand_str')
enc_time = security_data.get('enc_time', 1)
enc_data = security_data.get('enc_data')
# 4. 计算签名 (参考JS中的computeSignature)
current_time = int(time.time())
# 迭代计算MD5
current = random_str
for _ in range(enc_time):
current = hashlib.md5(f"{current}{secret_key}".encode()).hexdigest()
signature = hashlib.md5(f"{current}{secret_key}{channel}{current_time}".encode()).hexdigest()
# 5. 请求播放地址 (参考JS中的requestStreamData)
play_payload = {
'enc_data': enc_data,
'tt': str(current_time),
'did': device_id,
'auth': signature,
'cdn': '',
'rate': '',
'hevc': '0',
'fa': '0',
'ive': '0'
}
play_api = f"{self.hosts[ids[0]]}/lapi/live/getH5PlayV1/{channel}"
# 构建请求头带Cookie
play_headers = headers.copy()
cookie_str = '; '.join([f"{k}={v}" for k, v in session.items()])
play_headers['Cookie'] = cookie_str
play_headers['Content-Type'] = 'application/x-www-form-urlencoded'
play_res = requests.post(play_api, data=play_payload, headers=play_headers, timeout=10).json()
if not play_res or play_res.get('error') != 0:
# 尝试旧版API
play_res = self._try_legacy_douyu_api(channel, device_id, signature, current_time, play_headers)
if not play_res:
return self.handle_exception(Exception("获取播放地址失败"))
stream_info = play_res.get('data', {})
# 6. 检查并更新设备ID (参考JS中的checkAndUpdateDeviceId)
rtmp_live = stream_info.get('rtmp_live', '')
if rtmp_live:
did_match = re.search(r'did=([a-f0-9]{32})', rtmp_live)
if did_match and did_match.group(1) != device_id:
device_id = did_match.group(1)
session['dy_did'] = device_id
# 重新请求
play_payload['did'] = device_id
play_res = requests.post(play_api, data=play_payload, headers=play_headers, timeout=10).json()
if play_res and play_res.get('error') == 0:
stream_info = play_res.get('data', {})
# 7. 提取播放URL和多码率信息
stream_url = None
if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
stream_url = f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
elif stream_info.get('hls_url'):
stream_url = stream_info['hls_url']
if not stream_url:
return self.handle_exception(Exception("无法获取播放地址"))
# 8. 构建多码率选项
multirates = stream_info.get('multirates', [])
# 关键修复:存储房间号和码率信息,而不是直接存储URL
# 这样在切换清晰度时可以重新获取对应码率的签名URL
qualities = []
if multirates:
# 按码率排序
sorted_rates = sorted(multirates, key=lambda x: x.get('bit', 0), reverse=True)
for rate in sorted_rates:
bit_rate = rate.get('rate', -1)
name = rate.get('name', f"{bit_rate}P")
# 存储格式:码率值,用于playerContent中重新获取URL
# 使用特殊标记#来区分这是码率值而不是URL
qualities.extend([name, f"#{bit_rate}"])
else:
# 只有原画
qualities = ['原画', '#-1']
# 同时存储房间号和设备信息,用于重新获取URL
# 格式:房间号|设备ID|签名信息(base64编码)
session_info = {
'channel': channel,
'device_id': device_id,
'secret_key': secret_key,
'random_str': random_str,
'enc_time': enc_time,
'enc_data': enc_data
}
encoded_session = self.e64(json.dumps(session_info))
# 9. 构建播放数据
# vod_play_url格式:房间名$平台@@base64(清晰度列表)@@base64(会话信息)
encoded_qualities = self.e64(json.dumps(qualities))
vod['vod_play_from'] = '斗鱼直播'
vod['vod_play_url'] = f"{vname}${ids[0]}@@{encoded_qualities}@@{encoded_session}"
return vod
except Exception as e:
return self.handle_exception(e)
def _generate_random_hex(self, length):
"""生成随机十六进制字符串"""
hex_chars = '0123456789abcdef'
return ''.join(random.choice(hex_chars) for _ in range(length))
def _try_legacy_douyu_api(self, channel, device_id, signature, timestamp, headers):
"""尝试使用旧版API获取播放地址"""
try:
legacy_payload = {
'did': device_id,
'tt': str(timestamp),
'sign': signature,
'cdn': '',
'rate': '-1',
'ver': 'Douyu_223061205',
'iar': '1',
'ive': '1',
'hevc': '0',
'fa': '0'
}
legacy_api = f"https://www.douyu.com/lapi/live/getH5Play/{channel}"
res = requests.post(legacy_api, data=legacy_payload, headers=headers, timeout=10)
return res.json() if res.status_code == 200 else None
except:
return None
def _get_douyu_play_url(self, channel, device_id, secret_key, random_str, enc_time, enc_data, rate):
"""
获取斗鱼指定码率的播放URL(带签名)
用于切换清晰度时重新获取URL
"""
try:
current_time = int(time.time())
# 重新计算签名
current = random_str
for _ in range(enc_time):
current = hashlib.md5(f"{current}{secret_key}".encode()).hexdigest()
signature = hashlib.md5(f"{current}{secret_key}{channel}{current_time}".encode()).hexdigest()
# 构建请求
play_payload = {
'enc_data': enc_data,
'tt': str(current_time),
'did': device_id,
'auth': signature,
'cdn': '',
'rate': str(rate) if rate > 0 else '',
'hevc': '0',
'fa': '0',
'ive': '0'
}
play_api = f"https://www.douyu.com/lapi/live/getH5PlayV1/{channel}"
headers = {
'User-Agent': self.headers[0]['User-Agent'],
'Referer': f'https://www.douyu.com/{channel}',
'Origin': 'https://www.douyu.com',
'Cookie': f'dy_did={device_id}; mantine-color-scheme-value=light',
'Content-Type': 'application/x-www-form-urlencoded'
}
play_res = requests.post(play_api, data=play_payload, headers=headers, timeout=10).json()
if not play_res or play_res.get('error') != 0:
# 尝试旧版API
return self._get_douyu_play_url_legacy(channel, device_id, signature, current_time, rate)
stream_info = play_res.get('data', {})
# 检查设备ID是否匹配
if stream_info.get('rtmp_live'):
did_match = re.search(r'did=([a-f0-9]{32})', stream_info['rtmp_live'])
if did_match and did_match.group(1) != device_id:
# 设备ID不匹配,使用新设备ID重新获取
return self._get_douyu_play_url(channel, did_match.group(1), secret_key, random_str, enc_time, enc_data, rate)
if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
return f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
elif stream_info.get('hls_url'):
return stream_info['hls_url']
return None
except Exception as e:
print(f"获取斗鱼播放URL失败: {e}")
return None
def _get_douyu_play_url_legacy(self, channel, device_id, signature, timestamp, rate):
"""使用旧版API获取斗鱼播放URL"""
try:
legacy_payload = {
'did': device_id,
'tt': str(timestamp),
'sign': signature,
'cdn': '',
'rate': str(rate) if rate > 0 else '-1',
'ver': 'Douyu_223061205',
'iar': '1',
'ive': '1',
'hevc': '0',
'fa': '0'
}
legacy_api = f"https://www.douyu.com/lapi/live/getH5Play/{channel}"
headers = {
'User-Agent': self.headers[0]['User-Agent'],
'Referer': f'https://www.douyu.com/{channel}',
'Cookie': f'dy_did={device_id}',
'Content-Type': 'application/x-www-form-urlencoded'
}
res = requests.post(legacy_api, data=legacy_payload, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
if data.get('error') == 0:
stream_info = data.get('data', {})
if stream_info.get('rtmp_url') and stream_info.get('rtmp_live'):
return f"{stream_info['rtmp_url']}/{stream_info['rtmp_live']}"
return None
except:
return None
def searchContent(self, key, quick, pg="1"):
pass
def playerContent(self, flag, id, vipFlags):
try:
ids = id.split('@@')
p = 1
if ids[0] in ['wangyi']:
p, url = 0, json.loads(self.d64(ids[1]))
elif ids[0] == 'bili':
p, url = self.biliplay(ids)
elif ids[0] == 'huya':
p, url = self.huyaplay(ids)
elif ids[0] == 'douyu':
p, url = self.douyuplay(ids)
return {'parse': p, 'url': url, 'header': self.playheaders[ids[0]]}
except Exception as e:
return {'parse': 1, 'url': self.excepturl, 'header': self.headers[0]}
def biliplay(self, ids):
"""
B站播放解析 - 使用playUrl接口获取指定清晰度直播流
ids: [平台, 房间号, 清晰度qn]
支持多线路返回
"""
try:
room_id = ids[1]
qn = ids[2] if len(ids) > 2 else '10000'
# 使用playUrl接口获取直播流
play_url = f'{self.hosts["bili"][0]}/room/v1/Room/playUrl?cid={room_id}&qn={qn}&platform=web'
data = self.fetch(play_url, headers={
**self.headers[0],
'Referer': 'https://live.bilibili.com/',
'Origin': 'https://live.bilibili.com'
}).json()
if data.get('code') != 0:
return 1, self.excepturl
play_data = data['data']
durl_list = play_data.get('durl', [])
if not durl_list:
return 1, self.excepturl
# 构建多线路结果 [线路1, URL1, 线路2, URL2, ...]
urls = []
for idx, item in enumerate(durl_list, 1):
url = item.get('url')
if url:
urls.extend([f'线路{idx}', url])
# 如果只有一条线路,直接返回URL
if len(urls) == 2:
return 0, urls[1] # 直接返回URL字符串
return 0, urls
except Exception as e:
print(f"B站播放错误: {e}")
return 1, self.excepturl
def huyaplay(self, ids):
"""
虎牙播放解析 - 返回所有清晰度选项供用户选择
ids[1] 格式: base64编码的 [清晰度名称1, URL1, 清晰度名称2, URL2, ...]
"""
try:
# ids[1] 是编码后的播放地址列表 [名称1, URL1, 名称2, URL2, ...]
decoded = json.loads(self.d64(ids[1]))
# decoded 是一个列表,奇数索引是名称,偶数索引是URL
return 0, decoded
except Exception as e:
print(f"虎牙播放解析错误: {e}")
return 1, self.excepturl
def douyuplay(self, ids):
"""
斗鱼播放解析 - 实时获取对应码率的播放URL
ids格式: [平台, base64(清晰度列表), base64(会话信息)]
清晰度列表: [名称1, #码率1, 名称2, #码率2, ...]
#表示这是码率值,需要重新获取URL
"""
try:
if len(ids) < 3:
# 兼容旧格式
decoded = json.loads(self.d64(ids[1]))
return 0, decoded
# 解析清晰度列表和会话信息
qualities = json.loads(self.d64(ids[1]))
session_info = json.loads(self.d64(ids[2]))
channel = session_info['channel']
device_id = session_info['device_id']
secret_key = session_info['secret_key']
random_str = session_info['random_str']
enc_time = session_info['enc_time']
enc_data = session_info['enc_data']
# 为每个清晰度实时获取播放URL
result = []
for i in range(0, len(qualities), 2):
name = qualities[i]
rate_marker = qualities[i + 1]
# 解析码率值(去掉#前缀)
if rate_marker.startswith('#'):
rate = int(rate_marker[1:])
else:
rate = -1
# 实时获取对应码率的URL
play_url = self._get_douyu_play_url(
channel, device_id, secret_key, random_str,
enc_time, enc_data, rate
)
if play_url:
result.extend([name, play_url])
if not result:
return 1, self.excepturl
return 0, result
except Exception as e:
print(f"斗鱼播放解析错误: {e}")
return 1, self.excepturl
def localProxy(self, param):
pass
def e64(self, text):
try:
text_bytes = text.encode('utf-8')
encoded_bytes = b64encode(text_bytes)
return encoded_bytes.decode('utf-8')
except Exception as e:
print(f"Base64编码错误: {str(e)}")
return ""
def d64(self, encoded_text):
try:
encoded_bytes = encoded_text.encode('utf-8')
decoded_bytes = b64decode(encoded_bytes)
return decoded_bytes.decode('utf-8')
except Exception as e:
print(f"Base64解码错误: {str(e)}")
return ""
def josn_to_params(self, params, skip_empty=False):
query = []
for k, v in params.items():
if skip_empty and not v:
continue
query.append(f"{k}={v}")
return "&".join(query)
def params_to_json(self, query_string):
parsed_data = parse_qs(query_string)
result = {key: value[0] for key, value in parsed_data.items()}
return result
def buildvod(self, vod_id='', vod_name='', vod_pic='', vod_year='', vod_tag='', vod_remarks='', style='',
type_name='', vod_area='', vod_actor='', vod_director='',
vod_content='', vod_play_from='', vod_play_url=''):
vod = {
'vod_id': vod_id,
'vod_name': vod_name,
'vod_pic': vod_pic,
'vod_year': vod_year,
'vod_tag': 'folder' if vod_tag else '',
'vod_remarks': vod_remarks,
'style': style,
'type_name': type_name,
'vod_area': vod_area,
'vod_actor': vod_actor,
'vod_director': vod_director,
'vod_content': vod_content,
'vod_play_from': vod_play_from,
'vod_play_url': vod_play_url
}
vod = {key: value for key, value in vod.items() if value}
return vod
def getpq(self, url, headers=None, cookies=None):
data = self.fetch(url, headers=headers, cookies=cookies).text
try:
return pq(data)
except Exception as e:
print(f"解析页面错误: {str(e)}")
return pq(data.encode('utf-8'))
def gethr(self, index, rf='', zr=''):
headers = self.headers[index]
if zr:
headers['referer'] = zr
else:
headers['referer'] = f"{self.referers[rf]}/"
return headers
def handle_exception(self, e):
print(f"报错: {str(e)}")
return {'vod_play_from': '哎呀翻车啦', 'vod_play_url': f'翻车啦${self.excepturl}'}
+440
View File
@@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
"""
top3.zgtv.online (追光影视) 爬虫
- MX Pro CMS模板
- 分类: /vodshow/{catId}---{area}--{letter}----{page}---.html
- 详情: /voddetail/{id}.html
- 播放: /vodplay/{id}-{sid}-{nid}.html -> player_aaaa JS变量
- 搜索: /vodsearch/{keyword}-------------.html
- 播放链接是视频站原始URL(芒果/爱奇艺等), 需要解析线路
"""
import sys
import re
import json
from collections import defaultdict
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
def _(x): return x
HOST = "https://top3.zgtv.online"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
# 播放源 -> 解析接口映射 (from playerconfig.js) - 仅供参考, 当前不使用
_PARSE_MAP = {
"wsym3u8": "https://wsyzy.vip/m3u8/?url=",
"bfzym3u8": "https://free.maccms.xyz/?url=",
"360zy": "https://free.maccms.xyz/?url=",
"mtm3u8": "https://free.maccms.xyz/?url=",
"qq": "https://z01.zgtv.online/player/?url=",
"qiyi": "https://z01.zgtv.online/player/?url=",
"youku": "https://z01.zgtv.online/player/?url=",
"mgtv": "https://z01.zgtv.online/player/?url=",
"bilibili": "https://z01.zgtv.online/player/?url=",
"mjzy": "https://svip.qlplayer.cyou/?url=",
}
# 备用解析接口 (仅保留供参考)
_BACKUP_PARSERS = [
("极速", "https://jx.2s0.cn/player/?url="),
("super", "https://super.playr.top/?url="),
("fongmi", "https://json.fongmi.cc/web?url="),
("Jn1", "https://yparse.jn1.cc/index.php?url="),
("PlayerJY", "https://jx.playerjy.com/?url="),
("冰豆", "https://bd.jx.cn/?url="),
("剖元", "https://www.pouyun.com/?url="),
("七哥", "https://jx.nnxv.cn/tv.php?url="),
("夜幕", "https://www.yemu.xyz/?url="),
("Yparse", "https://jx.yparse.com/index.php?url="),
("ik9", "https://yparse.ik9.cc/index.php?url="),
("花旗", "https://www.huaqi.live/?url="),
("网站z01", "https://z01.zgtv.online/player/?url="),
("svip2", "https://svip.qlplayer.cyou/?url="),
("maccms", "https://free.maccms.xyz/?url="),
("wsyzy", "https://wsyzy.vip/m3u8/?url="),
]
# from字段 -> 中文显示名
FROM_NAMES = {
"qq": "腾讯",
"qiyi": "奇艺",
"youku": "优酷",
"mgtv": "芒果",
"bilibili": "B站",
"wsym3u8": "自营线路",
"bfzym3u8": "暴风资源",
"360zy": "无广告",
"mtm3u8": "芒果资源",
"mjzy": "自营蓝光",
"dplayer": "DPlayer",
"videojs": "VideoJS",
}
# 需要解析的from类型 (视频站链接, 需要通过第三方解析接口)
NEED_PARSE_FROM = {"qq", "qiyi", "youku", "mgtv", "bilibili"}
# 主分类
CLASS_MAP = {
"20": "电影",
"37": "连续剧",
"43": "动漫",
"45": "综艺",
}
class Spider(Spider):
def init(self, extend=""):
self._session = rq.Session()
self._session.headers.update({"User-Agent": UA})
try:
self._session.get(HOST, timeout=10)
except:
pass
def getName(self):
return "zgtv"
def isVideoFormat(self, url):
return ".m3u8" in url or ".mp4" in url
def manualVideoCheck(self):
return False
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):
try:
items = self._fetch_list(f"{HOST}/vodtype/20.html")
return {"list": items}
except:
return {"list": []}
def categoryContent(self, tid, pg=1, filter=False, extend=None):
try:
pn = max(int(str(pg)), 1)
# /vodshow/{catId}--------{page}---.html
url = f"{HOST}/vodshow/{tid}--------{pn}---.html"
items = self._fetch_list(url)
return {"list": items, "page": pn, "pagecount": pn + 10, "limit": 24, "total": 0}
except:
return {"list": [], "page": pg, "pagecount": 1, "limit": 24, "total": 0}
def detailContent(self, ids):
try:
vid = str(ids[0]) if ids else ""
if not vid:
return {"list": []}
url = f"{HOST}/voddetail/{vid}.html"
r = self._get(url, timeout=15000)
html = r.text
# 标题
title = self._extract(html, r'<h1[^>]*class="page-title"[^>]*>([^<]+)</h1>') or ""
if not title:
title = self._extract(html, r'<h1[^>]*>([^<]+)</h1>') or ""
# 封面
cover = self._extract(html, r'data-original="([^"]+\.(?:jpg|png|webp))"') or ""
if not title:
return {"list": []}
# 播放列表 - 只匹配剧集链接(module-play-list-link类)
all_links = re.findall(
r'class="module-play-list-link"[^>]+href="(/vodplay/(\d+)-(\d+)-(\d+)\.html)"[^>]*>[\s\S]*?<span>([^<]+)</span>',
html
)
# Fallback: 宽松匹配
if not all_links:
all_links = re.findall(
r'href="(/vodplay/(\d+)-(\d+)-(\d+)\.html)"[^>]*>[\s\S]*?<span>([^<]+)</span>',
html
)
# 按sid分组 (同一播放源)
by_sid = defaultdict(list)
for match in all_links:
if len(match) == 5:
href, v, sid, nid, name = match
if v == vid:
by_sid[sid].append((int(nid), name.strip(), href))
# 播放源标签
heading = re.search(
r'id="y-playList"[^>]*>([\s\S]*?)</div>\s*</div>\s*</div>', html
)
source_names = []
if heading:
source_names = re.findall(r'data-dropdown-value="([^"]+)">\s*<span>([^<]+)</span>', heading.group(1))
pf_list = []
pu_list = []
src_idx = 0
for sid in sorted(by_sid.keys()):
eps = sorted(by_sid[sid])
src_idx += 1
# 获取该源的from字段(只请求第一集)
sid_from = self._get_from_source(vid, sid)
# 跳过需要解析的视频站源 (qq/qiyi/youku/mgtv/bilibili)
if sid_from in NEED_PARSE_FROM:
continue
# 直链源: 优先用网站的source_names, 否则用from映射名
if src_idx - 1 < len(source_names):
friendly_name = source_names[src_idx - 1][1]
else:
friendly_name = FROM_NAMES.get(sid_from, f"线路{src_idx}")
# 直链源
ep_list = []
for nid, name, href in eps:
ep_list.append(f"{name}${href}|{sid_from}")
if ep_list:
pf_list.append(friendly_name)
pu_list.append("#".join(ep_list))
if not pf_list:
return {"list": []}
vod = {
"vod_id": vid,
"vod_name": title,
"vod_pic": cover,
"vod_play_from": "$$$".join(pf_list),
"vod_play_url": "$$$".join(pu_list),
}
return {"list": [vod]}
except:
return {"list": []}
def searchContent(self, key, quick=False, pg=1):
try:
pn = max(int(str(pg)), 1)
url = f"{HOST}/vodsearch/{quote(key)}-------------.html"
items = self._fetch_list(url)
return {"list": items, "page": pn}
except:
return {"list": [], "page": 1}
def playerContent(self, flag, id, vipFlags=None):
url = str(id) if id else str(flag)
if not url:
return {"url": ""}
if "$" in url:
parts = url.split("$", 1)
url = parts[1]
# m3u8/mp4直链直接播
if url.startswith("http") and (".m3u8" in url or ".mp4" in url):
return {"url": url}
# 播放页路径 /vodplay/{vid}-{sid}-{nid}.html|from
from_src = ""
parts = url.split("|")
if len(parts) == 2:
url = parts[0]
from_src = parts[1]
if url.startswith("/vodplay/"):
play_data = self._extract_player_data(url)
if play_data:
raw_url = play_data.get("url", "")
# 如果已经是m3u8/mp4直链, 直接播放
if raw_url and (".m3u8" in raw_url or ".mp4" in raw_url):
return {"url": raw_url}
# 如果是视频站链接(qq/youku等), 返回原始URL让壳子解析
if raw_url and raw_url.startswith("http"):
return {"url": raw_url, "parse": 1, "header": {"User-Agent": UA}}
return {"url": ""}
return {"url": url}
def _get(self, url, timeout=20):
r = self._session.get(url, timeout=timeout)
# MX Pro CMS可能返回不同编码
if r.apparent_encoding:
r.encoding = r.apparent_encoding
return r
def _extract(self, text, pattern):
m = re.search(pattern, text)
return m.group(1) if m else ""
def _fetch_list(self, url):
"""从HTML抓取影片列表"""
r = self._get(url, timeout=15000)
html = r.text if hasattr(r, 'text') else str(r)
items = []
seen = set()
# Pattern1: MX Pro - <a href="/voddetail/{id}.html" title="名称">...<img data-original="封面">
pattern1 = re.compile(
r'<a\s+href="/voddetail/(\d+)\.html"[^>]+title="([^"]+)"'
r'[\s\S]*?'
r'<img[^>]+data-original="([^"]+\.(?:jpg|png|webp))"'
)
for vid, title, cover in pattern1.findall(html):
if vid in seen:
continue
seen.add(vid)
items.append({
"vod_id": vid,
"vod_name": title.strip(),
"vod_pic": cover,
"vod_remarks": "",
})
# Pattern1b: 搜索结果 - data-original在alt前面
if not items:
pattern1b = re.compile(
r'<a\s+href="/voddetail/(\d+)\.html"[^>]*>'
r'[\s\S]*?'
r'<img[^>]+data-original="([^"]+\.(?:jpg|png|webp))"[^>]+alt="([^"]+)"'
)
for vid, cover, title in pattern1b.findall(html):
if vid in seen:
continue
seen.add(vid)
items.append({
"vod_id": vid,
"vod_name": title.strip(),
"vod_pic": cover,
"vod_remarks": "",
})
# Pattern1c: alt在data-original前面
if not items:
pattern1c = re.compile(
r'<a\s+href="/voddetail/(\d+)\.html"[^>]*>'
r'[\s\S]*?'
r'<img[^>]+alt="([^"]+)"[^>]+data-original="([^"]+\.(?:jpg|png|webp))"'
)
for vid, title, cover in pattern1c.findall(html):
if vid in seen:
continue
seen.add(vid)
items.append({
"vod_id": vid,
"vod_name": title.strip(),
"vod_pic": cover,
"vod_remarks": "",
})
# Pattern2: 首页/推荐页 - <a href="/vodplay/{id}-1-1.html" title="名称">...<img data-original="封面">
if not items:
pattern2 = re.compile(
r'<a\s+href="/vodplay/(\d+)-\d+-\d+\.html"[^>]+title="([^"]+)"'
r'[\s\S]*?'
r'<img[^>]+data-original="([^"]+\.(?:jpg|png|webp))"'
)
for vid, title, cover in pattern2.findall(html):
if vid in seen:
continue
seen.add(vid)
items.append({
"vod_id": vid,
"vod_name": title.strip(),
"vod_pic": cover,
"vod_remarks": "",
})
# Pattern3: 备用 - 任何含title和data-original的a标签
if not items:
pattern3 = re.compile(
r'<a[^>]+title="([^"]+)"[^>]*>'
r'[\s\S]*?'
r'<img[^>]+data-original="([^"]+\.(?:jpg|png|webp))"'
)
for title, cover in pattern3.findall(html):
items.append({
"vod_id": "",
"vod_name": title.strip(),
"vod_pic": cover,
"vod_remarks": "",
})
return items
def _extract_player_url_from_path(self, path):
"""从播放页路径提取真实m3u8 URL"""
try:
url = f"{HOST}{path}"
r = self._get(url, timeout=15000)
html = r.text
m = re.search(r'player_aaaa\s*=\s*(\{[^}]+\})', html)
if m:
data = json.loads(m.group(1))
return data.get("url", "")
except:
pass
return ""
def _extract_player_data(self, path):
"""从播放页路径提取完整播放数据(url, from等)"""
try:
url = f"{HOST}{path}"
r = self._get(url, timeout=15000)
html = r.text
m = re.search(r'player_aaaa\s*=\s*(\{[^}]+\})', html)
if m:
data = json.loads(m.group(1))
return data
except:
pass
return None
def _get_from_source(self, vid, sid):
"""获取某播放源的from字段(播放源类型)"""
try:
url = f"{HOST}/vodplay/{vid}-{sid}-1.html"
r = self._get(url, timeout=10000)
html = r.text
m = re.search(r'player_aaaa\s*=\s*(\{[^}]+\})', html)
if m:
data = json.loads(m.group(1))
return data.get("from", "")
except:
pass
return ""
def _extract_player_url(self, vid, sid, nid):
"""从播放页提取player_aaaa中的URL"""
try:
url = f"{HOST}/vodplay/{vid}-{sid}-{nid}.html"
r = self._get(url, timeout=15000)
html = r.text
# 匹配 player_aaaa={"url":"..."}
m = re.search(r'player_aaaa\s*=\s*(\{[^}]+\})', html, re.DOTALL)
if m:
data = json.loads(m.group(1))
play_url = data.get("url", "")
return play_url
except:
pass
return ""
def localProxy(self, param):
pass
@@ -0,0 +1,446 @@
# -*- coding: utf-8 -*-
"""
黄豆短剧爬虫
站点: https://www.hdmgdj.com
"""
import json
import urllib.parse
import requests
try:
from base.spider import Spider as BaseSpider
except ImportError:
class BaseSpider:
pass
class Spider(BaseSpider):
"""黄豆短剧爬虫"""
BASE_URL = 'https://www.hdmgdj.com'
API_BASE = 'https://hdmgdj.com/api'
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': 'application/json, text/plain, */*',
'Referer': 'https://www.hdmgdj.com/',
'Origin': 'https://www.hdmgdj.com',
}
_filter_cache = {} # 分类筛选缓存
def __init__(self):
super().__init__()
self.name = ""
self.error_play_url = "https://kjjsaas-sh.oss-cn-shanghai.aliyuncs.com/u/3401405881/20240818-936952-fc31b16575e80a7562cdb1f81a39c6b0.mp4"
self.session = requests.Session()
self.session.headers.update(self.HEADERS)
# ==================== 标准接口 ====================
def init(self, extend="{}"):
"""初始化"""
if extend:
try:
self.extend = json.loads(extend)
if 'name' in self.extend:
self.name = self.extend['name']
if 'base_url' in self.extend:
self.BASE_URL = self.extend['base_url']
self.API_BASE = self.extend['base_url'] + '/api'
except Exception as e:
print(e)
return None
def getName(self):
"""获取爬虫名称"""
return "黄豆短剧"
def homeContent(self, filter):
"""首页"""
result = {
"class": [],
"filters": {},
"list": [],
"parse": 0,
"jx": 0,
}
try:
# 获取分类
genres_data = self._get('/genres')
if genres_data and isinstance(genres_data, list):
for g in genres_data:
gid = str(g.get('id', ''))
if not gid:
continue
result["class"].append({
"type_id": gid,
"type_name": g.get('name', ''),
})
# 获取首页推荐
home_data = self._get('/home')
if home_data and isinstance(home_data, dict):
# guess 猜你喜欢
guess_list = home_data.get('guess', [])
if isinstance(guess_list, list):
for item in guess_list:
result["list"].append(self._parse_vod(item))
# feature 精选
feature_list = home_data.get('feature', [])
if isinstance(feature_list, list):
for item in feature_list:
result["list"].append(self._parse_vod(item))
# 如果列表为空,用首页第一页数据
if not result["list"]:
dramas_data = self._get('/dramas?page=1&size=20')
if dramas_data and isinstance(dramas_data, dict):
for item in dramas_data.get('list', []):
result["list"].append(self._parse_vod(item))
except Exception as e:
print(e)
return result
def categoryContent(self, tid, pg, filter, extend):
"""分类页"""
result = {
"page": pg,
"pagecount": 999,
"limit": 20,
"total": 99999,
"list": [],
"parse": 0,
"jx": 0,
}
try:
# 分类ID是genre id
data = self._get(f'/dramas?genreId={tid}&page={pg}&size=20')
if data and isinstance(data, dict):
lst = data.get('list', [])
total = data.get('total', 0)
result["total"] = total
result["pagecount"] = (total + 19) // 20 if total else 999
for item in lst:
result["list"].append(self._parse_vod(item))
except Exception as e:
print(e)
return result
def detailContent(self, ids):
"""详情页"""
result = {
"list": [],
"parse": 0,
"jx": 0,
}
try:
vid = ids[0]
data = self._get(f'/dramas/{vid}')
if data and isinstance(data, dict):
episodes = data.get('episodes', [])
# 组装播放地址
play_url_parts = []
for ep in episodes:
ep_title = ep.get('title', f"{ep.get('ep', 0)}")
play_url = ep.get('playUrl', '')
if play_url:
play_url_parts.append(f"{ep_title}${play_url}")
cover = data.get('cover', '')
# 加密海报走本地代理解密
if cover and ('encryptimages' in cover or '.bng' in cover):
try:
cover = f"{self.getProxyUrl()}&url={urllib.parse.quote(cover)}"
except Exception:
pass
vod = {
"vod_id": str(data['id']),
"vod_name": data.get('t', ''),
"vod_pic": cover,
"type_name": data.get('sub', ''),
"vod_year": '',
"vod_area": '',
"vod_remarks": f"{data.get('serial', '')}·{data.get('plays', '')}播放",
"vod_actor": '',
"vod_director": '嗷呜 / 唐三',
"vod_content": data.get('summary', '') or data.get('t', ''),
"vod_play_from": '黄豆短剧',
"vod_play_url": '#'.join(play_url_parts),
}
result["list"].append(vod)
except Exception as e:
print(e)
return result
def searchContent(self, key, quick, pg="1"):
"""搜索"""
result = {
"page": pg,
"pagecount": 999,
"limit": 20,
"total": 99999,
"list": [],
"parse": 0,
"jx": 0,
}
try:
data = self._get(f'/search?kw={urllib.parse.quote(key)}&page={pg}&size=20')
if data and isinstance(data, dict):
lst = data.get('list', [])
total = data.get('total', 0)
result["total"] = total
result["pagecount"] = (total + 19) // 20 if total else 0
for item in lst:
result["list"].append(self._parse_vod(item))
except Exception as e:
print(e)
return result
def playerContent(self, flag, id, vipFlags):
"""播放页 - 直接返回 m3u8 data URI"""
result = {
"parse": 0,
"playUrl": "",
"url": self.error_play_url,
"jx": 0,
"header": "",
}
if id:
# 直接在 playerContent 里生成解密后的 m3u8,用 data URI 返回
# 这样播放地址就不是 127.0.0.1 代理了
m3u8_content = self._build_m3u8_with_key(id)
if m3u8_content:
import base64
m3u8_b64 = base64.b64encode(m3u8_content.encode('utf-8')).decode('ascii')
result["url"] = "data:application/vnd.apple.mpegurl;base64," + m3u8_b64
result["parse"] = 0
return result
def _build_m3u8_with_key(self, url):
"""构建 m3u8 内容(key 内嵌为 base64 data URIts 用原始绝对地址)"""
import hashlib
import re
import base64
if not url:
return None
try:
r = self.session.get(url, timeout=15, verify=False)
content = r.text
# 计算 key
key_bytes = self._get_key_bytes(url)
if key_bytes:
key_b64 = base64.b64encode(key_bytes).decode('ascii')
key_data_uri = "data:text/plain;base64," + key_b64
content = re.sub(
r'(#EXT-X-KEY:.*?URI=")[^"]*(")',
r'\1' + key_data_uri + r'\2',
content
)
# 把相对路径的 ts 改成绝对路径
base_url = url.rsplit('/', 1)[0] + '/'
lines = content.split('\n')
new_lines = []
for line in lines:
line = line.strip()
if line and not line.startswith('#'):
if line.startswith('http'):
new_lines.append(line)
else:
new_lines.append(base_url + line)
else:
new_lines.append(line)
content = '\n'.join(new_lines)
return content
except Exception as e:
print(f"_build_m3u8_with_key error: {e}")
return None
def _get_key_bytes(self, url):
"""从 m3u8 URL 计算解密 key"""
import hashlib
import re
m = re.search(r'/hls/([0-9a-f]{64})/', url)
if not m:
return None
video_id = m.group(1)
ver_match = re.search(r'[?&]version=([^&#]+)', url)
version = ver_match.group(1) if ver_match else 'v1'
prefix = "xnaichanping"
key_str = prefix + video_id + version
return hashlib.md5(key_str.encode()).digest()
def localProxy(self, param):
"""本地代理 - 解密海报图片"""
try:
url = param['url']
r = self.session.get(url, timeout=15, verify=False)
decrypted = self._aes_decrypt_img(r.content, url)
# 确定图片类型
content_type = "image/jpeg"
if decrypted[:8] == b'\x89PNG\r\n\x1a\n':
content_type = "image/png"
elif decrypted[:6] in (b'GIF87a', b'GIF89a'):
content_type = "image/gif"
elif decrypted[:4] == b'RIFF' and decrypted[8:12] == b'WEBP':
content_type = "image/webp"
return [200, content_type, decrypted]
except Exception as e:
print(f"localProxy error: {e}")
return [500, 'text/html', b'']
def _aes_decrypt_img(self, encrypted, url):
"""AES 解密图片 - 网站自定义 CBC 算法"""
import hashlib
import re
from Crypto.Cipher import AES
# 提取 imageId (64位哈希)
m = re.search(r'([0-9a-f]{64})', url)
if not m:
return encrypted
image_id = m.group(1)
# 提取 version
ver_match = re.search(r'[?&]version=([^&#]+)', url)
version = ver_match.group(1) if ver_match else 'v1'
# 计算解密 key
prefix = "xnaichanping"
key_str = prefix + image_id + version
key_bytes = hashlib.md5(key_str.encode()).digest()
# 网站自定义 CBC 解密 (mC 函数)
t = len(encrypted) // 16
if t < 1:
return encrypted
iv = bytes(16) # IV=0
# 取最后一块 XOR 16
last_block = encrypted[(t - 1) * 16:t * 16]
a = bytes([b ^ 16 for b in last_block])
# 加密 a
cipher_enc = AES.new(key_bytes, AES.MODE_CBC, iv)
o = cipher_enc.encrypt(a)[:16]
# 扩展密文并解密
extended = encrypted + o
cipher_dec = AES.new(key_bytes, AES.MODE_CBC, iv)
c = cipher_dec.decrypt(extended)
# 自定义 CBC:每块 XOR 前一块密文
u = bytearray(len(c))
u[:16] = c[:16]
for f in range(1, t):
for h in range(16):
u[f * 16 + h] = c[f * 16 + h] ^ encrypted[(f - 1) * 16 + h]
# 去掉 padding
d = u[-1]
if 1 <= d <= 16:
u = u[:-d]
return bytes(u)
# ==================== 内部方法 ====================
def _parse_vod(self, item):
"""解析视频条目 - 海报用 getProxyUrl 代理解密"""
cover = item.get('cover', '')
# 加密海报走本地代理解密
if cover and ('encryptimages' in cover or '.bng' in cover):
try:
cover = f"{self.getProxyUrl()}&url={urllib.parse.quote(cover)}"
except Exception:
pass
return {
"vod_id": str(item['id']),
"vod_name": item.get('t', ''),
"vod_pic": cover,
"vod_remarks": f"{item.get('serial', '')}·{item.get('eps', 0)}",
}
def _get(self, path):
"""发送 GET 请求"""
url = self.API_BASE + path
try:
r = self.session.get(url, timeout=15, verify=False)
resp = r.json()
if resp.get('code') == 0 and resp.get('data') is not None:
return resp['data']
return None
except Exception as e:
print(e)
return None
# 调试用
if __name__ == '__main__':
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
s = Spider()
s.init()
print('=== 首页 ===')
home = s.homeContent(True)
print(f'分类: {len(home["class"])}')
for c in home['class']:
print(f' {c["type_id"]}: {c["type_name"]}')
print(f'推荐: {len(home["list"])}')
for v in home['list'][:5]:
print(f' {v["vod_id"]}: {v["vod_name"]} - {v["vod_remarks"]}')
print()
print('=== 分类1(都市)第1页 ===')
cr = s.categoryContent('1', 1, True, {})
print(f'总数: {cr["total"]}, 本页: {len(cr["list"])}')
for v in cr['list'][:5]:
print(f' {v["vod_id"]}: {v["vod_name"]}')
print()
print('=== 搜索 穿越 ===')
sr = s.searchContent('穿越', False, '1')
print(f'结果: {len(sr["list"])}个, 总数: {sr["total"]}')
for v in sr['list'][:5]:
print(f' {v["vod_id"]}: {v["vod_name"]}')
print()
if sr['list']:
vid = sr['list'][0]['vod_id']
print(f'=== 详情 {vid} ===')
dr = s.detailContent([vid])
if dr['list']:
v = dr['list'][0]
print(f'标题: {v["vod_name"]}')
print(f'分类: {v["type_name"]}')
print(f'备注: {v["vod_remarks"]}')
print(f'播放源: {v["vod_play_from"]}')
play_urls = v["vod_play_url"].split('#')
print(f'集数: {len(play_urls)}')
print(f'第一集: {play_urls[0][:80]}...')