Sync all projects
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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' ', ' ', 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' ', ' ', 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'}
|
||||
@@ -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': '妄想族30%OFF第1弾', 'v': 'chinese_category/390/妄想族30%OFF第1弾'},
|
||||
{'n': 'ブランドストア30%OFF☆', 'v': 'chinese_category/391/ブランドストア30%OFF☆'},
|
||||
{'n': 'ながえスタイル30%OFF', 'v': 'chinese_category/392/ながえスタイル30%OFF'},
|
||||
{'n': 'MERCURY(マーキュリー)他30%OFF', 'v': 'chinese_category/393/MERCURY(マーキュリー)他30%OFF'},
|
||||
{'n': 'ブランドストア30%OFF!', 'v': 'chinese_category/394/ブランドストア30%OFF!'},
|
||||
{'n': '妄想族30%OFF', 'v': 'chinese_category/395/妄想族30%OFF'},
|
||||
{'n': 'BALTAN<バルタン>他30%OFF', 'v': 'chinese_category/396/BALTAN<バルタン>他30%OFF'},
|
||||
{'n': 'ながえスタイル他30%OFF', 'v': 'chinese_category/397/ながえスタイル他30%OFF'},
|
||||
{'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' ', ' ', 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' ', ' ', 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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_id,area_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}'}
|
||||
@@ -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 URI,ts 用原始绝对地址)"""
|
||||
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]}...')
|
||||
Reference in New Issue
Block a user