"""
91短剧 (91crdj.com / 91crdj.net) - TVBox 爬虫源
============================================================
接口：homeContent / homeVideoContent / categoryContent(分类+标签+排序)
     / detailContent / playerContent(视频/漫画/小说三态) / searchContent

站点特性（实勘确认 2026-09-16）：
1. 双线路同构：91crdj.com / 91crdj.net（页面请求依次尝试并缓存成功线路）
2. 分类列表：GET /api/list/fragment?scope={scope}&key={key}&sort={new|hot}&page={pg}
   主分类 scope/key = category/duanju、category/dongman-sm(漫剧)、category/zhibo-huifang(真人剧)、
   category/videos(成人视频)、comic/(漫画 空key)、novel/(小说 空key)；标签 scope=tag&key=标签slug
3. 列表页卡片：<a class="card" href="/{cate}/{id-slug}/" data-track-item-name="...">
   封面在 img data-src（2026-09-16 实测图床迁址 pic.zdmhyg.cn → pic.tuafjz.cn，
   泛 pic.* 域名直连返回 AES-128-CBC 密文，需本地代理解密；src 均为 data: 占位图）
4. 详情页 /{cate}/{id-slug}/：h1 标题、meta description 简介、JSON-LD(TVSeries)→年份/类型、
   class="score">★ 评分、/biaoqian/ 链接标签、选集链接 href="/{cate}/{id-slug}/{n}/"
5. 播放页 /{cate}/{id-slug}/{n}/：内嵌 <script id="playInitialData"> JSON → current.src = m3u8
   备用取流 API：/videos/{videoId}/episodes/{ep}/playback → data.src
6. 漫画（manhua）：详情页 comic-page / data-src / src 收集图片 → pics:// + 本地代理 b64 图
7. 小说（xiaoshuo）：详情页 <article>（或 content/article/chapter/text/reader 容器）→ novel://JSON
8. 搜索 /search/{关键词}[/{pg}]/：共 {n} 部 → total，每页 20
9. 封面 AES：key=f5d965df75336270 iv=97b60394abc2fbe1（与 51短剧 同款，实测仍适用新图床），
   localProxy 解码明文 quote URL → 拉取二进制 → AES-128-CBC 解密 → [200, mime, bytes]
   （3 元组，PyramidStore/国色天香 壳子协议）
   附：图床 auth_key 鉴权串实测可剥离（去 ? 后 200 同密文），剥离后 URL 永久稳定不过期
10. 合规：站点含 2257 声明 / 18+ 年龄门槛 / 明确禁止涉未成年内容条款（实勘确认）

依赖：requests；AES 三级 fallback（pycryptodome → ctypes libcrypto → 纯 Python），零硬依赖

ruff 说明：爬虫网络层需要捕获全部请求异常（DNS/超时/SSL 等），
故 BLE001（盲捕获）与 S110/S112（吞异常）在此场景为有意设计。
"""
# ruff: noqa: BLE001, S110, S112

import base64
import ctypes
import ctypes.util
import json
import re
import sys
import threading
import time
from urllib.parse import quote

import requests

try:
    from Crypto.Cipher import AES as _PycryptoAES
except Exception:  # pragma: no cover
    _PycryptoAES = None

# 兼容 PyramidStore 直推 / FongMiTV / 本地调试：
# 壳子注入 base.spider 基类则继承(提供 getName 等)，否则用自带占位基类。
for _p in ('../../', '..'):
    _p_abs = __file__.rsplit('/', 1)[0] + '/' + _p if '__file__' in dir() else _p
    try:
        sys.path.insert(0, _p_abs)
        from base.spider import Spider as _BaseSpider
        break
    except (ImportError, NameError):
        _BaseSpider = None


class _BaseSpiderPlaceholder:
    """无壳子基类时的占位（本地自测/纯脚本运行）"""
    def init(self, extend=""):
        pass

    def getName(self):
        return "91短剧"

    def destroy(self):
        pass

    def close(self):
        pass


if _BaseSpider is None:
    _BaseSpider = _BaseSpiderPlaceholder


# ============================================================
# 常量
# ============================================================
UA = (
    "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
)

HOSTS = [
    "https://91crdj.com",
    "https://91crdj.net",
]

# 封面/漫画图 AES-128-CBC 密钥（与 51短剧/51暗 相同, 实测可解 pic.zdmhyg.cn 密文）
AES_KEY = b"f5d965df75336270"
AES_IV = b"97b60394abc2fbe1"

# 本地代理入口（PY 协议：url 为 base64.urlsafe 编码的原图 URL）
PROXY_PREFIX = "http://127.0.0.1:9978/proxy?do=py&action=proxy&type=cover&url="

TIMEOUT_PAGE = 10
TIMEOUT_API = 8

# 主分类（type_id 与站点路径 /{cate}/ 一致）
CATES = [
    {"type_id": "duanju",    "type_name": "成人短剧"},
    {"type_id": "manju",     "type_name": "成人漫剧"},
    {"type_id": "zhenrenju", "type_name": "真人剧"},
    {"type_id": "shipin",    "type_name": "成人视频"},
    {"type_id": "manhua",    "type_name": "成人漫画"},
    {"type_id": "xiaoshuo",  "type_name": "成人小说"},
]

# 标签全量（实勘标签榜 2026-09-16，含热度；剔除身份词 JK/女子高生 与「全部」占位）
# (type_slug, type_name)
TAG_LIST = [
    ("aishipin", "AI视频"), ("aiduanju", "AI短剧"), ("gaoyanzhi", "高颜值"),
    ("juqing", "剧情"), ("juru", "巨乳"), ("koujiao", "口交"), ("houru", "后入"),
    ("meiru", "美乳"), ("aimanju", "AI漫剧"), ("nvshen", "女神"), ("fancha", "反差"),
    ("youhuo", "诱惑"), ("91duanju", "91短剧"), ("zhongchu", "中出"),
    ("diaojiao", "调教"), ("doushiduanju", "都市短剧"), ("mote", "模特"),
    ("xinggan", "性感"), ("aimogai", "AI魔改"), ("meitui", "美腿"),
    ("diaojiaoduanju", "调教短剧"), ("aichengrenxiaoshuo", "AI成人小说"),
    ("mugou", "母狗"), ("guzhuang", "古装"), ("doushi", "都市"), ("aimeinv", "AI美女"),
    ("renqi", "人妻"), ("aiqing", "爱情"), ("nvshangwei", "女上位"), ("nvnu", "女奴"),
    ("qicheng", "骑乘"), ("baihu", "白虎"), ("chenfu", "臣服"),
    ("doushiaiqing", "都市爱情"), ("neishe", "内射"), ("chugui", "出轨"),
    ("gufeng", "古风"), ("bianda", "鞭打"), ("qingqu", "情趣"),
    ("gufengduanju", "古风短剧"), ("touqing", "偷情"), ("ziwei", "自慰"),
    ("hougong", "后宫"), ("lvmao", "绿帽"), ("aichengrenduanju", "AI成人短剧"),
    ("ai", "AI"), ("shufu-2", "束缚"), ("shunv", "熟女"), ("mingxing", "明星"),
    ("xuanhuanduanju", "玄幻短剧"), ("dantimogai", "单体魔改"), ("zipai", "自拍"),
    ("jipin", "极品"), ("nixi", "逆袭"), ("shenhou", "深喉"), ("siwa", "丝袜"),
    ("xianxiaduanju", "仙侠短剧"), ("duoluo", "堕落"), ("xiangquan", "项圈"),
    ("shuangxiu", "双修"), ("xiaoyuanduanju", "校园短剧"), ("tongren", "同人"),
    ("oumei", "欧美"), ("gangjiao", "肛交"), ("yanshe", "颜射"), ("xiaoyuan", "校园"),
    ("chaopen", "潮喷"), ("rijiushengqing", "日久生情"), ("r18", "R18"),
    ("heisi", "黑丝"), ("cos", "cos"), ("ntr", "NTR"), ("zhifu", "制服"),
    ("luanlun", "乱伦"), ("chaochui", "潮吹"), ("chengzhang", "成长"),
    ("qingchun", "清纯"), ("gaochao", "高潮"), ("chengrenmanhua", "成人漫画"),
    ("shaofu", "少妇"), ("chengrenduanju", "成人短剧"), ("aimei", "暧昧"),
    ("pibian", "皮鞭"), ("nvxingchengzhang", "女性成长"), ("xingai", "性爱"),
    ("chuanyue", "穿越"), ("3p", "3P"), ("chunai", "纯爱"), ("zhajing", "榨精"),
    ("mogaiduanju", "魔改短剧"), ("jianjinqinmi", "渐进亲密"),
    ("chengrenshipin", "成人视频"), ("kaitui", "开腿"), ("chenlun", "沉沦"),
    ("nannu", "男奴"), ("nifengfanpan", "逆风翻盘"), ("xitong", "系统"),
    ("xianhunhouai", "先婚后爱"), ("seqing", "色情"), ("roubianqihua", "肉便器化"),
    ("xuexiao", "学校"), ("chengren", "成人"), ("jiaoshi", "教师"),
    ("cosplay", "Cosplay"), ("tiaowu", "跳舞"), ("huwai", "户外"),
    ("siwameitui", "丝袜美腿"), ("yuwangjuexing", "欲望觉醒"), ("hushi", "护士"),
    ("duoren", "多人"), ("aimeirichang", "暧昧日常"), ("qingganshengwen", "情感升温"),
    ("chixuhuanyu", "持续欢愉"), ("jiliechanmian", "激烈缠绵"), ("shengsuo", "绳索"),
    ("wannong", "玩弄"), ("shengfu", "绳缚"), ("dadiao", "大屌"),
    ("doushirichang", "都市日常"), ("jiating", "家庭"), ("shijin", "失禁"),
    ("shanhun", "闪婚"), ("zuoai", "做爱"), ("rujiao", "乳交"), ("jinji", "禁忌"),
    ("jiqingchanmian", "激情缠绵"), ("jiqing", "激情"), ("yuwang", "欲望"),
    ("wanghong", "网红"), ("jinjiqingyu", "禁忌情欲"), ("baoru", "爆乳"),
    ("qihuanduanju", "奇幻短剧"), ("roubian", "肉便"), ("guanjing", "灌精"),
    ("xiandai", "现代"), ("nvm", "女M"), ("jiatinglunli", "家庭伦理"),
    ("danvzhu", "大女主"), ("jinjikuaigan", "禁忌快感"), ("fengmanshencai", "丰满身材"),
    ("chengshunvren", "成熟女人"), ("yuwangshifang", "欲望释放"), ("qihuan", "奇幻"),
    ("gonggong", "公公"), ("erxi", "儿媳"), ("choucha", "抽插"), ("fuchou", "复仇"),
    ("niandai", "年代"), ("hunwaiqing", "婚外情"), ("toukui", "偷窥"),
    ("chengrenxiang", "成人向"), ("tongju", "同居"), ("laoshi", "老师"),
    ("zhongsheng", "重生"), ("91chengrenduanju", "91成人短剧"), ("yuepao", "约炮"),
    ("xiuxianduanju", "修仙短剧"), ("aiyuanchuangduanju", "AI原创短剧"),
    ("yuwangchenlun", "欲望沉沦"), ("jiqinghuanai", "激情欢爱"),
    ("dalianfanpai", "打脸反派"), ("biantun", "鞭臀"), ("wuyan", "呜咽"),
    ("langjiao", "浪叫"), ("luanlunduanju", "乱伦短剧"), ("fuqiduanju", "夫妻短剧"),
    ("suolian", "锁链"), ("jinjiduanju", "禁忌短剧"), ("shunvrenqi", "熟女人妻"),
    ("xuanhuan", "玄幻"), ("nixifanshen", "逆袭翻身"), ("zhenxiangdabai", "真相大白"),
    ("ouxiang", "偶像"), ("qingse", "情色"), ("nvyou", "女友"), ("yanjing", "眼镜"),
    ("muziluanlun", "母子乱伦"), ("heisimeitui", "黑丝美腿"), ("kunbang", "捆绑"),
    ("huanqi", "换妻"), ("yinqi", "淫妻"), ("meitun", "美臀"), ("nvpu", "女仆"),
    ("zhichang", "职场"), ("luchu", "露出"), ("quancai", "全彩"),
    ("bangongshi", "办公室"), ("koujiaoshenhou", "口交深喉"), ("sanrenxing", "三人行"),
    ("jinjiyuwang", "禁忌欲望"), ("jugen", "巨根"), ("yongzhuang", "泳装"),
    ("shiyi", "失忆"), ("humei", "狐媚"), ("nvezha", "虐渣"),
    ("tianmichanmian", "甜蜜缠绵"), ("bangongshilianqing", "办公室恋情"),
    ("menggan", "猛干"),
]

ALL_CATES = CATES + [{"type_id": "tag_" + slug, "type_name": name}
                     for slug, name in TAG_LIST]

# scope/key 映射
CATE_API = {
    "duanju":    ("category", "duanju"),
    "manju":     ("category", "dongman-sm"),
    "zhenrenju": ("category", "zhibo-huifang"),
    "shipin":    ("category", "videos"),
    "manhua":    ("comic", ""),
    "xiaoshuo":  ("novel", ""),
}
VIDEO_CATES = {"duanju", "manju", "zhenrenju", "shipin"}
COMIC_CATES = {"manhua"}
NOVEL_CATES = {"xiaoshuo"}

SORT_OPTS = [{"key": "sort", "name": "排序",
              "value": [{"n": "最新", "v": "new"}, {"n": "最热", "v": "hot"}]}]
FILTERS = {c["type_id"]: SORT_OPTS for c in ALL_CATES}

# 无效卡片标题（按钮/占位）
BAD_NAMES = ["立即播放", "追剧", "选集", "从头看", "开始阅读", "从头读"]
# 选集按钮文本（详情页提取剧集时过滤）
BTN_TEXTS = {"\u25b6 立即观看", "从头看", "\u25b6 开始阅读", "从头读"}


# ============================================================
# 工具函数
# ============================================================

# ==================== AES-128-CBC 解密（三级 fallback） ====================
# 优先级: 1) pycryptodome(若壳子已装) 2) ctypes 调 libcrypto(EVP, 最快且无依赖)
#         3) 纯 Python 实现(兜底)。封面图均可用。
_AES_CTX = None  # None=未加载, True=可用, False=不可用
_AES_LIB = None


def _aes_ctypes_load():
    """加载 libcrypto 并绑定 EVP 接口"""
    global _AES_CTX, _AES_LIB
    if _AES_CTX is not None:
        return _AES_CTX
    names = [ctypes.util.find_library('crypto'),
             'libcrypto.so.3', 'libcrypto.so.1.1', 'libcrypto.so',
             'libcrypto.1.1.dylib', 'libcrypto.dylib']
    for name in names:
        if not name:
            continue
        try:
            lib = ctypes.CDLL(name)
        except OSError:
            continue
        try:
            lib.EVP_aes_128_cbc.restype = ctypes.c_void_p
            lib.EVP_aes_128_cbc.argtypes = []
            lib.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
            lib.EVP_CIPHER_CTX_new.argtypes = []
            lib.EVP_DecryptInit_ex.restype = ctypes.c_int
            lib.EVP_DecryptInit_ex.argtypes = [ctypes.c_void_p, ctypes.c_void_p,
                                               ctypes.c_void_p, ctypes.c_void_p,
                                               ctypes.c_void_p]
            lib.EVP_DecryptUpdate.restype = ctypes.c_int
            lib.EVP_DecryptUpdate.argtypes = [ctypes.c_void_p, ctypes.c_void_p,
                                              ctypes.POINTER(ctypes.c_int),
                                              ctypes.c_void_p, ctypes.c_int]
            lib.EVP_DecryptFinal_ex.restype = ctypes.c_int
            lib.EVP_DecryptFinal_ex.argtypes = [ctypes.c_void_p, ctypes.c_void_p,
                                                ctypes.POINTER(ctypes.c_int)]
            lib.EVP_CIPHER_CTX_free.restype = None
            lib.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
            _AES_LIB = lib
            _AES_CTX = True
            return True
        except (AttributeError, OSError):
            continue
    _AES_CTX = False
    return False


def _aes_ctypes_decrypt(raw, key, iv):
    if not _aes_ctypes_load():
        return None
    ctx = _AES_LIB.EVP_CIPHER_CTX_new()
    if not ctx:
        return None
    try:
        cipher = _AES_LIB.EVP_aes_128_cbc()
        kbuf = (ctypes.c_ubyte * 16).from_buffer_copy(bytearray(key))
        ibuf = (ctypes.c_ubyte * 16).from_buffer_copy(bytearray(iv))
        if (_AES_LIB.EVP_DecryptInit_ex(ctx, cipher, None,
                                        ctypes.cast(kbuf, ctypes.c_void_p),
                                        ctypes.cast(ibuf, ctypes.c_void_p)) != 1):
            return None
        inbuf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(bytearray(raw))
        outbuf = ctypes.create_string_buffer(len(raw) + 16)
        outl = ctypes.c_int(0)
        if (_AES_LIB.EVP_DecryptUpdate(ctx, outbuf, ctypes.byref(outl),
                                       ctypes.cast(inbuf, ctypes.c_void_p),
                                       len(raw)) != 1):
            return None
        total = outl.value
        finl = ctypes.c_int(0)
        if (_AES_LIB.EVP_DecryptFinal_ex(ctx, ctypes.byref(outbuf, total),
                                         ctypes.byref(finl)) != 1):
            return None
        total += finl.value
        return outbuf.raw[:total]
    finally:
        _AES_LIB.EVP_CIPHER_CTX_free(ctx)


# ----- 纯 Python AES（S-box + 仿射用循环移位）-----
def _aes_build_sbox():
    exp = [0] * 256
    log = [0] * 256
    x = 1
    for i in range(255):
        exp[i] = x
        log[x] = i
        x ^= (x << 1) ^ (0x11B if (x & 0x80) else 0)
        x &= 0xFF
    sbox = [0] * 256
    for i in range(256):
        inv = 0 if i == 0 else exp[(255 - log[i]) % 255]
        b = inv
        r = (b
             ^ ((b << 1) | (b >> 7))
             ^ ((b << 2) | (b >> 6))
             ^ ((b << 3) | (b >> 5))
             ^ ((b << 4) | (b >> 4))) & 0xFF
        sbox[i] = r ^ 0x63
    return sbox


_AES_SBOX = _aes_build_sbox()
_AES_RSBOX = [0] * 256
for _i, _v in enumerate(_AES_SBOX):
    _AES_RSBOX[_v] = _i


def _aes_gf_mul(a, b):
    p = 0
    for _ in range(8):
        if b & 1:
            p ^= a
        hi = a & 0x80
        a = (a << 1) & 0xFF
        if hi:
            a ^= 0x1B
        b >>= 1
    return p


def _aes_expand_key(key):
    w = [[key[4 * i + j] for j in range(4)] for i in range(4)]
    rcon = 1
    for i in range(4, 44):
        t = list(w[i - 1])
        if i % 4 == 0:
            t = [t[1], t[2], t[3], t[0]]
            t = [_AES_SBOX[b] for b in t]
            t[0] ^= rcon
            rcon = (rcon << 1) ^ (0x11B if (rcon & 0x80) else 0)
        w.append([w[i - 4][j] ^ t[j] for j in range(4)])
    return [w[4 * r:4 * r + 4] for r in range(11)]


def _aes_add_rk(s, rk):
    for c in range(4):
        for r in range(4):
            s[r][c] ^= rk[c][r]


def _aes_dec_block(block, rk):
    s = [[block[4 * c + r] for c in range(4)] for r in range(4)]
    _aes_add_rk(s, rk[10])
    for rnd in range(9, 0, -1):
        for r in range(4):
            s[r] = s[r][-r:] + s[r][:-r]
        for r in range(4):
            for c in range(4):
                s[r][c] = _AES_RSBOX[s[r][c]]
        _aes_add_rk(s, rk[rnd])
        for c in range(4):
            a = [s[r][c] for r in range(4)]
            s[0][c] = (_aes_gf_mul(a[0], 14) ^ _aes_gf_mul(a[1], 11)
                       ^ _aes_gf_mul(a[2], 13) ^ _aes_gf_mul(a[3], 9))
            s[1][c] = (_aes_gf_mul(a[0], 9) ^ _aes_gf_mul(a[1], 14)
                       ^ _aes_gf_mul(a[2], 11) ^ _aes_gf_mul(a[3], 13))
            s[2][c] = (_aes_gf_mul(a[0], 13) ^ _aes_gf_mul(a[1], 9)
                       ^ _aes_gf_mul(a[2], 14) ^ _aes_gf_mul(a[3], 11))
            s[3][c] = (_aes_gf_mul(a[0], 11) ^ _aes_gf_mul(a[1], 13)
                       ^ _aes_gf_mul(a[2], 9) ^ _aes_gf_mul(a[3], 14))
    for r in range(4):
        s[r] = s[r][-r:] + s[r][:-r]
    for r in range(4):
        for c in range(4):
            s[r][c] = _AES_RSBOX[s[r][c]]
    _aes_add_rk(s, rk[0])
    return bytes(s[r][c] for c in range(4) for r in range(4))


def _aes_pure_decrypt(raw, key, iv):
    if not raw or len(raw) % 16 != 0 or len(key) != 16 or len(iv) != 16:
        return None
    try:
        rk = _aes_expand_key(key)
        prev = list(iv)
        out = bytearray()
        for i in range(0, len(raw), 16):
            blk = raw[i:i + 16]
            dec = _aes_dec_block(blk, rk)
            for j in range(16):
                out.append(dec[j] ^ prev[j])
            prev = list(blk)
        pad = out[-1]
        if 1 <= pad <= 16 and all(b == pad for b in out[-pad:]):
            del out[-pad:]
        return bytes(out)
    except Exception:
        return None


def _aes_decrypt_128_cbc(raw, key, iv):
    """AES-128-CBC 解密(PKCS7)，三级 fallback，任何环境都能解"""
    if not raw or not key or not iv:
        return None
    # 1) pycryptodome
    if _PycryptoAES is not None:
        try:
            return _PycryptoAES.new(key, _PycryptoAES.MODE_CBC, iv).decrypt(raw)
        except Exception:
            pass
    # 2) ctypes libcrypto
    try:
        out = _aes_ctypes_decrypt(raw, key, iv)
        if out is not None:
            return out
    except Exception:
        pass
    # 3) 纯 Python
    return _aes_pure_decrypt(raw, key, iv)


def _b64_encode(text):
    return base64.b64encode(text.encode('utf-8')).decode('ascii')





# ============================================================
# Spider 主类
# ============================================================
_Base = _BaseSpider


class Spider(_Base):
    siteUrl = HOSTS[0]
    headers = {  # noqa: RUF012 - 只读共享头，不改写
        'User-Agent': UA,
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'zh-CN,zh;q=0.9',
        'Accept-Encoding': 'gzip, deflate',
    }

    # ===== 初始化 =====
    def __init__(self):
        self.siteUrl = HOSTS[0]
        self.userAgent = UA
        self.timeout = 10
        self.session = requests.Session()
        self.session.headers.update(self.headers)
        self.session.headers['Connection'] = 'keep-alive'
        self.session.verify = False

        self._lock = threading.Lock()
        self._cur_host = ""
        self._host_ok = {}        # host -> 最近成功时间戳
        self._detail_cache = {}   # id -> (ts, content)
        self._ttl = 60

    def init(self, extend=""):
        self.extend = extend or ""

    def getName(self):
        """PyramidStore 直推引擎读取源名称"""
        return "91短剧"

    # ===== 多线路 =====
    def _hosts_order(self):
        order = []
        if self._cur_host:
            order.append(self._cur_host)
        for h in HOSTS:
            if h != self._cur_host:
                order.append(h)
        return order

    def _get_text(self, path, referer='', timeout=TIMEOUT_PAGE):
        """带线路容错的页面请求: 依次尝试 HOSTS, 成功后缓存线路"""
        last_err = None
        for host in self._hosts_order():
            url = host + path
            hdrs = {"User-Agent": UA}
            if referer:
                hdrs["Referer"] = referer
            try:
                r = self.session.get(url, headers=hdrs, timeout=timeout)
                if r.status_code == 200 and len(r.text) > 120:
                    r.encoding = r.apparent_encoding or 'utf-8'
                    self._cur_host = host
                    return r.text
                last_err = f"{r.status_code} len={len(r.text)}"
            except Exception as e:  # DNS/超时/SSL 等，继续试其他线路
                last_err = repr(e)[:120]
        return ""

    def _get_text_with_headers(self, path, extra_headers, timeout=TIMEOUT_PAGE):
        """带额外头的请求（API fragment / playback 需要 X-Requested-With）"""
        last_err = None
        for host in self._hosts_order():
            url = host + path
            hdrs = {"User-Agent": UA}
            if extra_headers:
                hdrs.update(extra_headers)
            try:
                r = self.session.get(url, headers=hdrs, timeout=timeout)
                if r.status_code == 200 and len(r.text) > 0:
                    r.encoding = r.apparent_encoding or 'utf-8'
                    self._cur_host = host
                    return r.text
                last_err = f"{r.status_code} len={len(r.text)}"
            except Exception as e:
                last_err = repr(e)[:120]
        return ""

    # ===== AES 封面解密（localProxy） =====
    @staticmethod
    def _aes_decrypt_image(raw):
        """AES-128-CBC 解密 pic.zdmhyg.cn 封面密文（固定 key/iv, PKCS7）"""
        if not raw:
            return None
        try:
            # 优先 ctypes/libcrypto（无 pycryptodome 环境也能解）
            out = _aes_decrypt_128_cbc(raw, AES_KEY, AES_IV)
            return out
        except Exception:
            return None

    # ===== HTML 解析 =====
    @staticmethod
    def _clean_text(s):
        if not s:
            return ""
        return re.sub(r'\s+', ' ', s).strip()

    # 懒加载属性优先级匹配（海报修复模板 §2.1）：data-src > data-original > data-lazy > src，
    # 泛 pic.* 域名（图床会换域名，写死域名=海报全灭，2026-09-16 实测教训）
    _PIC_ATTR_RE = re.compile(
        r'(?:data-src|data-original|data-lazy|src)="(https://pic\.[^"]+)"')

    @classmethod
    def _extract_pic(cls, frag):
        """从卡片/img 片段提取封面原图 URL（懒加载属性优先，占位图天然过滤）"""
        if not frag:
            return ""
        for m in cls._PIC_ATTR_RE.finditer(frag):
            u = m.group(1).strip()
            # 双保险：data: 内联占位图 / 1x1 图直接跳过
            if u.startswith("data:") or "1x1" in u:
                continue
            return u
        return ""

    @staticmethod
    def _norm_pic(url):
        """封面 URL 规范化：反斜杠修复 → 剥离 pic.* 图床的 auth_key 鉴权串
        （实测去 ? 后 200 返回相同密文；剥离后 URL 稳定不过期，壳子缓存不失效）"""
        if not url:
            return ""
        u = str(url).strip().replace("\\/", "/").replace("&amp;", "&")
        if not u.startswith("http"):
            return ""
        if "pic." in u:
            u = u.split("?")[0]
        return u

    def build_vod_pic(self, src_url):
        """封面 vod_pic（v1.1.0）：封面 CDN 直连返回 AES 密文，
        vod_pic = 本地代理前缀 + quote(规范化封面原图URL) —— 国色天香(PyramidStore)同款明文URL"""
        u = self._norm_pic(src_url)
        if not u:
            return ""
        return PROXY_PREFIX + quote(u, safe='')

    def parse_cards(self, html, limit=36):
        """通用卡片解析（首页/分类API/搜索均为此结构）"""
        if not html:
            return []
        re_card = re.compile(
            r'<a[^>]*class="card"[^>]*href="(?:https://[^"]*)?'
            r'/([^/]+)/([^"]+)"[^>]*data-track-item-name="([^"]*)"[^>]*>([\s\S]*?)</a>'
        )
        list_ = []
        seen = set()
        for m in re_card.finditer(html):
            cate = m.group(1)
            item_path = m.group(2).rstrip('/')
            if not item_path or item_path in seen:
                continue
            name = m.group(3).strip()
            if not name or name in BAD_NAMES:
                continue
            seen.add(item_path)
            inner = m.group(4)
            img = self._extract_pic(inner)
            rem = ""
            rm = re.search(r'class="[^"]*eps-flag[^"]*">([^<]+)<', inner)
            if rm:
                rem = rm.group(1).strip()
            list_.append({
                "vod_id": f"{cate}/{item_path}",
                "vod_name": name,
                "vod_pic": self.build_vod_pic(img),
                "vod_remarks": rem,
            })
            if len(list_) >= limit:
                break
        return list_

    # ==================== TVBox 标准接口 ====================

    def homeContent(self, filter):
        """首页分类 + 筛选（排序：最新/最热）"""
        return {"class": ALL_CATES, "filters": FILTERS}

    def homeVideoContent(self):
        """首页推荐视频（PyramidStore 直推为无参调用）"""
        html = self._get_text("/")
        return {"list": self.parse_cards(html, limit=30)}

    def categoryContent(self, tid, pg, filter, extend):
        """分类/标签列表，支持排序筛选（sort=new|hot）"""
        pg = int(pg or 1)
        sort = "new"
        if extend and isinstance(extend, dict) and extend.get("sort"):
            sort = str(extend["sort"])
        if str(tid).startswith("tag_"):
            scope, key = "tag", tid[4:]
        else:
            mapping = CATE_API.get(tid, ("category", tid))
            scope, key = mapping
        path = f"/api/list/fragment?scope={scope}&key={key}&sort={sort}&page={pg}"
        html = self._get_text_with_headers(
            path,
            {"X-Requested-With": "fetch", "Referer": self.siteUrl + "/" + tid + "/"},
            TIMEOUT_API,
        )
        list_ = self.parse_cards(html)

        pagecount = pg
        pm = re.search(r'data-pages="(\d+)"', html or "")
        if pm:
            pagecount = int(pm.group(1)) or pg
        else:
            pages = re.findall(r'data-page="(\d+)"', html or "")
            if pages:
                maxp = max(int(v) for v in pages)
                if maxp > 0:
                    pagecount = maxp
        limit = len(list_)
        return {
            "page": pg,
            "pagecount": pagecount,
            "limit": limit,
            "total": pagecount * 24,
            "list": list_,
        }

    def searchContent(self, key, quick, pg=1):
        """搜索：/search/{关键词}[/{pg}]/，total=共{n}部"""
        pg = int(pg or 1)
        path = "/search/" + quote(str(key)) + (f"/{pg}" if pg > 1 else "") + "/"
        html = self._get_text(path)
        list_ = self.parse_cards(html)
        total_m = re.search(r'共\s*(\d+)\s*部', html or "")
        total = int(total_m.group(1)) if total_m else len(list_)
        pagecount = max(1, -(-total // 20)) if total > 0 else 1
        return {
            "page": pg,
            "pagecount": pagecount,
            "limit": len(list_),
            "total": total,
            "list": list_,
        }

    def searchContentPage(self, key, quick, pg=1):
        """分页搜索（PyramidStore 直推兼容接口）"""
        return self.searchContent(key, quick, pg)

    def detailContent(self, ids):
        """详情：标题/海报/简介/年份/类型/评分/标签/剧集列表"""
        if isinstance(ids, list):
            id_ = ids[0]
        else:
            id_ = ids
        id_ = str(id_)
        vod = {
            "vod_id": id_,
            "vod_name": "",
            "vod_pic": "",
            "type_name": "",
            "vod_year": "",
            "vod_area": "",
            "vod_remarks": "",
            "vod_actor": "",
            "vod_director": "",
            "vod_content": "",
            "vod_play_from": "91短剧",
            "vod_play_url": "",
        }
        cate = id_.split("/")[0] if "/" in id_ else ""
        if cate in COMIC_CATES:
            vod["vod_player"] = "pics"
        if cate in NOVEL_CATES:
            vod["vod_player"] = "novel"

        html = self._get_text("/" + id_ + "/")
        if not html:
            return {"list": [vod]}

        t = re.search(r'<h1[^>]*>([\s\S]*?)</h1>', html)
        if t:
            vod["vod_name"] = self._clean_text(re.sub(r'<[^>]+>', '', t.group(1)))

        # 详情主海报: 优先详情区 p-img 的懒加载 data-src（泛 pic.* 域名），
        # 兜底全页第一个 pic 图 / og:image（og 域名常变且实测返回空，仅兜底）
        p = re.search(r'<img[^>]*class="p-img"[^>]*data-src="([^"]+)"', html)
        if not p:
            p = re.search(r'data-src="(https://pic\.[^"]+)"', html)
        if not p:
            p = re.search(r'<meta\s+property="og:image"\s+content="([^"]+)"', html)
        if p:
            vod["vod_pic"] = self.build_vod_pic(p.group(1))

        d = re.search(r'<meta\s+name="description"\s+content="([^"]*)"', html)
        if d:
            vod["vod_content"] = d.group(1).replace("&amp;", "&").replace("&quot;", '"')

        # JSON-LD: year / genre
        ld = re.search(r'<script[^>]+type="application/ld\+json"[^>]*>([\s\S]*?)</script>', html)
        if ld:
            try:
                parsed = json.loads(ld.group(1))
                graph = parsed.get("@graph") or []
                for node in graph:
                    if node.get("@type") == "TVSeries":
                        if node.get("datePublished"):
                            vod["vod_year"] = str(node["datePublished"])[:4]
                        g = node.get("genre")
                        if isinstance(g, list) and g:
                            vod["type_name"] = ",".join(g)
                        break
            except Exception:
                pass

        sc = re.search(r'class="score"[^>]*>\u2605([\d.]+)', html)
        if not sc:
            sc = re.search(r'\u2605([\d.]+)', html)
        if sc:
            vod["vod_score"] = sc.group(1)

        tags = []
        tre = re.compile(r'<a[^>]+href="https://91crdj\.com/biaoqian/[^"]+"[^>]*>([^<]+)</a>')
        for tm in tre.finditer(html):
            tag = tm.group(1).strip()
            if tag and tag not in tags:
                tags.append(tag)
        if tags:
            vod["vod_tag"] = ",".join(tags)

        # 剧集列表
        esc_id = re.sub(r'/', r'\\/', id_)
        ere = re.compile(
            r'<a[^>]+href="https://91crdj\.com/' + esc_id + r'/(\d+)/"[^>]*>([^<]*)</a>'
        )
        eps = []
        seen_ep = set()
        for em in ere.finditer(html):
            num = em.group(1)
            nm = em.group(2).strip()
            if not nm or nm in BTN_TEXTS or num in seen_ep:
                continue
            seen_ep.add(num)
            eps.append([nm, num])
        if not eps:
            eps = [["第1集", "1"]]

        urls = []
        for nm, num in eps:
            urls.append(f"{nm}${id_}/{num}")
        vod["vod_play_url"] = "#".join(urls)
        if eps:
            vod["vod_remarks"] = f"共{len(eps)}集"

        return {"list": [vod]}

    # ==================== 播放 ====================
    def playerContent(self, flag, id_, vipFlags):
        parts = str(id_).split("/")
        cate = parts[0] if parts else ""
        video_id = ""
        if len(parts) >= 2:
            video_id = parts[1].split("-")[0]
        ep = parts[2] if len(parts) >= 3 else "1"

        if cate in COMIC_CATES:
            return self._play_comic(id_)
        if cate in NOVEL_CATES:
            return self._play_novel(id_)
        if cate not in VIDEO_CATES:
            return {"parse": 0, "url": "", "header": {}}

        url = ""
        # 1) 播放页 playInitialData
        html = self._get_text("/" + id_ + "/")
        pm = re.search(r'<script[^>]+id="playInitialData"[^>]*>([\s\S]*?)</script>', html or "")
        if pm:
            try:
                data = json.loads(pm.group(1).strip())
                if data and data.get("current") and data["current"].get("src"):
                    url = data["current"]["src"]
            except Exception:
                pass
        # 2) playback API
        if not url and video_id:
            try:
                api = self._get_text_with_headers(
                    f"/videos/{video_id}/episodes/{ep}/playback",
                    {"X-Requested-With": "fetch", "Referer": self.siteUrl + "/" + id_ + "/"},
                    TIMEOUT_API,
                )
                jd = json.loads(api or "{}")
                if jd.get("data") and jd["data"].get("src"):
                    url = jd["data"]["src"]
            except Exception:
                pass
        if not url:
            return {"parse": 0, "url": "", "header": {}}
        return {
            "parse": 0,
            "url": url,
            "header": {"User-Agent": UA, "Referer": self.siteUrl + "/" + cate + "/"},
        }

    def _play_comic(self, id_):
        """漫画: pics:// + 本地代理（AES 解密在 localProxy）"""
        html = self._get_text("/" + id_ + "/")
        if not html:
            return {"parse": 0, "url": "", "header": {}}
        imgs = []
        # 1) comic-page 的懒加载 data-src
        r1 = re.compile(r'class="comic-page[^"]*"[^>]*data-src="([^"]+)"')
        for m in r1.finditer(html):
            u = self._norm_pic(m.group(1))
            if u and u not in imgs:
                imgs.append(u)
        # 2) 任意 pic.* 的懒加载属性（data-src/data-original/data-lazy）
        if not imgs:
            r2 = re.compile(r'(?:data-src|data-original|data-lazy)="(https://pic\.[^"]+)"')
            for m in r2.finditer(html):
                u = self._norm_pic(m.group(1))
                if u and u not in imgs:
                    imgs.append(u)
        # 3) 任意 pic.* 的 src（兜底）
        if not imgs:
            r3 = re.compile(r'src="(https://pic\.[^"]+)"')
            for m in r3.finditer(html):
                u = self._norm_pic(m.group(1))
                if u and u not in imgs:
                    imgs.append(u)
        if not imgs:
            return {"parse": 0, "url": "", "header": {}}
        proxied = [PROXY_PREFIX + quote(u, safe='') for u in imgs]
        return {"parse": 0, "url": "pics://" + "&&".join(proxied), "header": {}}

    def _play_novel(self, id_):
        """小说: novel:// JSON({title, content})"""
        html = self._get_text("/" + id_ + "/")
        if not html:
            return {"parse": 0, "url": "", "header": {}}
        am = re.search(r'<article[^>]*>([\s\S]*?)</article>', html)
        if not am:
            am2 = re.search(
                r'<div[^>]*class="[^"]*(?:content|article|chapter|text|reader)[^"]*"[^>]*>([\s\S]*?)</div>',
                html,
            )
            if am2:
                am = am2
        if not am:
            return {"parse": 0, "url": "", "header": {}}
        raw = am.group(1)
        raw = re.sub(r'<script[^>]*>[\s\S]*?</script>', '', raw)
        raw = re.sub(r'<style[^>]*>[\s\S]*?</style>', '', raw)
        raw = re.sub(r'<h2[^>]*>[\s\S]*?</h2>', '', raw)
        raw = re.sub(r'<br\s*/?>', '\n', raw)
        raw = re.sub(r'</p>', '\n\n', raw)
        text = re.sub(r'<[^>]+>', '', raw)
        text = (text.replace('&nbsp;', ' ').replace('&lt;', '<')
                    .replace('&gt;', '>').replace('&amp;', '&')
                    .replace('&quot;', '"').replace('&#39;', "'"))
        text = text.strip()
        text = re.sub(r'\n{3,}', '\n\n', text)
        ch_title = ""
        cm = re.search(r'<h2[^>]*class="novel-h"[^>]*>([\s\S]*?)</h2>', html)
        if cm:
            ch_title = re.sub(r'<[^>]+>', '', cm.group(1)).strip()
        if not ch_title:
            tm = re.search(r'<title>([^<]+)</title>', html)
            ch_title = tm.group(1).strip() if tm else "正文"
        payload = json.dumps({"title": ch_title, "content": text}, ensure_ascii=False)
        return {"parse": 0, "url": "novel://" + payload, "header": {}}

    # ==================== 本地代理（封面/漫画图 AES 解密） ====================
    def localProxy(self, params):
        """壳子请求: 127.0.0.1:9978/proxy?do=py&action=proxy&type=cover&url={quote(原图URL)}

        国色天香(PyramidStore 直推)协议: 返回 3 元组 [status, mime, 原始图片字节]。
        流程: 解析 URL(明文/兼容旧 b64) -> 拉取二进制 -> 标准图直通 / AES-128-CBC 解密
              -> 按字节前缀判 mime -> 返回 [200, mime, bytes]
        """
        # 兼容壳子传参: Map<String,String[]> 或 dict（值可能为 list）
        def _first(v):
            if isinstance(v, list):
                return v[0].strip() if v and v[0] else ""
            return (v or "").strip()

        url = ""
        if isinstance(params, dict):
            url = _first(params.get("url")) or _first((params.get("params") or {}).get("url"))
        elif isinstance(params, str):
            url = params.strip()
            # 若整串是 query 形式（如 "url=xxx&type=cover"），提取 url 值
            if "url=" in url:
                for seg in url.split("&"):
                    if seg.startswith("url="):
                        url = seg[4:]
                        break
        url = (url or "").strip()
        if not url:
            return [404, "text/plain", b"no url"]

        # 兼容: 明文 URL / urlencoded URL / 旧版 b64(url)
        decoded = url
        if not (url.startswith("http://") or url.startswith("https://")):
            from urllib.parse import unquote as _unquote
            dec_q = _unquote(url)
            if dec_q.startswith("http://") or dec_q.startswith("https://"):
                decoded = dec_q
            else:
                try:
                    cand = base64.urlsafe_b64decode(url.encode('utf-8')).decode('utf-8')
                    if cand.startswith("http://") or cand.startswith("https://"):
                        decoded = cand
                except Exception:
                    pass
        url = decoded

        raw = None
        try:
            r = self.session.get(url, timeout=TIMEOUT_API,
                                 headers={"User-Agent": UA,
                                          "Referer": self.siteUrl + "/",
                                          "Accept": "image/webp,image/apng,image/*,*/*;q=0.8"})
            if r.status_code == 200 and r.content:
                raw = r.content
        except Exception:
            try:
                r = self.session.get(url, timeout=TIMEOUT_API)
                if r.status_code == 200 and r.content:
                    raw = r.content
            except Exception:
                raw = None
        if not raw:
            return [404, "text/plain", b"req fail"]

        # 标准图直通 / AES-128-CBC 解密（固定 key/iv，实测可解）
        data = raw
        if not (raw[:2] == b'\xff\xd8' or raw[:8] == b'\x89PNG\r\n\x1a\n'
                or raw[:4] == b'RIFF' or raw[:4] == b'GIF8'):
            dec = self._aes_decrypt_image(raw)
            if dec and (dec[:2] == b'\xff\xd8' or dec[:8] == b'\x89PNG\r\n\x1a\n'
                        or dec[:4] == b'RIFF' or dec[:4] == b'GIF8'):
                data = dec

        mime = "image/webp"
        if data[:2] == b'\xff\xd8':
            mime = "image/jpeg"
        elif data[:8] == b'\x89PNG\r\n\x1a\n':
            mime = "image/png"
        elif data[:4] == b'GIF8':
            mime = "image/gif"
        return [200, mime, data]

    # ==================== 其他 ====================
    def isVideoFormat(self, url):
        if not url or not isinstance(url, str):
            return False
        if not url.startswith("http"):
            return False
        fmt = ['.mp4', '.m3u8', '.ts', '.mkv', '.avi', '.webm', '.flv']
        return any(f in url.lower() for f in fmt)

    def manualVideoCheck(self):
        return False

    def destroy(self):
        try:
            self.session.close()
        except Exception:
            pass

    def close(self):
        self.destroy()


# ============================================================
# 自测入口（python 91短剧.py 直接运行）
# ============================================================
def _selftest():
    print("=" * 64)
    print("91短剧.py 自测（{}）".format(time.strftime('%Y-%m-%d %H:%M:%S')))
    print("=" * 64)
    sp = Spider()
    sp.init()

    # 1) home
    home = sp.homeContent({})
    print(f"[home] 分类数={len(home['class'])} "
          f"主分类={[c['type_id'] for c in home['class'][:6]]}")
    assert len(home["class"]) == len(ALL_CATES) == 6 + len(TAG_LIST)

    # 2) homeVideo
    hv = sp.homeVideoContent()
    print(f"[homeVideo] {len(hv.get('list') or [])} 条, "
          f"首条={((hv.get('list') or [{}])[0].get('vod_name'), (hv.get('list') or [{}])[0].get('vod_pic', '')[:50])}")
    assert len(hv.get("list") or []) > 0

    # 2.1) 海报断言：vod_pic 必须非空且走本地代理（修复项回归）
    for it in (hv.get("list") or [])[:5]:
        assert it.get("vod_pic", "").startswith(PROXY_PREFIX), f"海报缺失: {it.get('vod_name')}"
    print("[poster] 首页海报 vod_pic 全部非空 ✓")

    # 3) category（主分类 + 标签 + 排序）
    cat = sp.categoryContent("duanju", 1, None, {"sort": "new"})
    print(f"[category duanju] 返回={len(cat.get('list') or [])} "
          f"page={cat.get('page')} pagecount={cat.get('pagecount')} "
          f"total={cat.get('total')} 首条={((cat.get('list') or [{}])[0].get('vod_name'),)}")
    assert len(cat.get("list") or []) > 0
    cat_hot = sp.categoryContent("duanju", 1, None, {"sort": "hot"})
    print(f"[category duanju hot] 返回={len(cat_hot.get('list') or [])}, "
          f"首条={((cat_hot.get('list') or [{}])[0].get('vod_name'),)}")
    cat_tag = sp.categoryContent("tag_xiaoyuanduanju", 1, None, None)
    print(f"[category tag_xiaoyuanduanju] 返回={len(cat_tag.get('list') or [])}, "
          f"首条={((cat_tag.get('list') or [{}])[0].get('vod_name'),)}")
    assert len(cat_tag.get("list") or []) > 0 or cat_tag.get("total", 0) > 0
    cat_manhua = sp.categoryContent("manhua", 1, None, None)
    print(f"[category manhua] 返回={len(cat_manhua.get('list') or [])}, "
          f"首条={((cat_manhua.get('list') or [{}])[0].get('vod_name'),)}")
    cat_xiaoshuo = sp.categoryContent("xiaoshuo", 1, None, None)
    print(f"[category xiaoshuo] 返回={len(cat_xiaoshuo.get('list') or [])}, "
          f"首条={((cat_xiaoshuo.get('list') or [{}])[0].get('vod_name'),)}")

    # 4) search
    sr = sp.searchContent("校花", "", 1)
    print(f"[search 校花] total={sr.get('total')} list={len(sr.get('list') or [])} "
          f"pagecount={sr.get('pagecount')} 首条={((sr.get('list') or [{}])[0].get('vod_name'),)}")
    assert len(sr.get("list") or []) > 0

    # 5) detail + play（视频）
    item = (cat.get("list") or [{}])[0]
    vid = item.get("vod_id")
    det = sp.detailContent([vid])["list"][0]
    print(f"[detail {vid}] name={det.get('vod_name')} year={det.get('vod_year')} "
          f"score={det.get('vod_score')} eps={det.get('vod_play_url', '')[:80]}")
    assert det.get("vod_pic", "").startswith(PROXY_PREFIX), "详情海报缺失"
    print(f"[poster] 详情海报={det.get('vod_pic', '')[:60]}...")

    # 5.1) 海报端到端：localProxy 拉密文 → AES 解密 → 必须是真图字节
    from urllib.parse import unquote as _unq
    pic_url = _unq(det["vod_pic"][len(PROXY_PREFIX):])
    rc = sp.localProxy({"url": pic_url})
    assert isinstance(rc, list) and len(rc) == 3, f"localProxy 返回形状错: {type(rc)}"
    assert rc[0] == 200, f"localProxy 状态码 {rc[0]}"
    body = rc[2]
    ok_magic = (body[:2] == b'\xff\xd8' or body[:8] == b'\x89PNG\r\n\x1a\n'
                or body[:4] == b'RIFF' or body[:4] == b'GIF8')
    assert ok_magic and len(body) > 1000, f"解密结果不是图片: {body[:16]!r} len={len(body)}"
    print(f"[poster] localProxy 端到端 ✓ mime={rc[1]} 大小={len(body)}B")

    play = sp.playerContent("", vid, {})
    print(f"[play {vid}] url={play.get('url', '')[:90]} parse={play.get('parse')}")

    sp.close()


if __name__ == "__main__":
    _selftest()
