哔哩哔哩听书
https://www.bilibili.com
mingwuyan (15428)3天前
把 B 站当有声书库:搜索/发现 -> 视频 = 一本书,分P = 一章,播放该分P的音频轨。 逻辑严格对齐开源项目 abts(github.com/fengqiao57/abts):WBI 签名、听书过滤规则同源。 免登录可用。首次使用会先预热 Cookie 并做 gaia 风控激活,稍等即可。 未登录最高 64K 音质(dash id 30216);导入含 SESSDATA 的 Cookie 可解锁更高音质。 音频流有时效(约 2 小时),但每章播放前都会重新取流,不存在过期问题。 遇到 -412「网络繁忙」已内置自动退避重试;持续报错可稍后再试。
{
"bookSourceComment": "把 B 站当有声书库:搜索\/发现 -> 视频 = 一本书,分P = 一章,播放该分P的音频轨。\n逻辑严格对齐开源项目 abts(github.com\/fengqiao57\/abts):WBI 签名、听书过滤规则同源。\n免登录可用。首次使用会先预热 Cookie 并做 gaia 风控激活,稍等即可。\n未登录最高 64K 音质(dash id 30216);导入含 SESSDATA 的 Cookie 可解锁更高音质。\n音频流有时效(约 2 小时),但每章播放前都会重新取流,不存在过期问题。\n遇到 -412「网络繁忙」已内置自动退避重试;持续报错可稍后再试。",
"bookSourceGroup": "听书",
"bookSourceName": "哔哩哔哩听书",
"bookSourceType": 1,
"bookSourceUrl": "https:\/\/www.bilibili.com",
"concurrentRate": "1\/400",
"customButton": false,
"customOrder": 16,
"enabled": true,
"enabledCookieJar": true,
"enabledExplore": true,
"eventListener": false,
"exploreUrl": "<js>biliExploreUrl()<\/js>",
"header": "{\"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\",\"Origin\":\"https:\/\/www.bilibili.com\"}",
"jsLib": "\/* =====================================================================\n * 哔哩哔哩听书 · Legado 书源公共库 (jsLib)\n * ---------------------------------------------------------------------\n * 逻辑严格对齐 abts (github.com\/fengqiao57\/abts) 的 lib\/core\/network\/\n * wbi_signer.dart 与 lib\/services\/bili_api.dart。\n *\n * 注意:Rhino 环境下访问不到 java.md5Encode,因此这里自带纯 JS MD5。\n * 所有函数均为纯计算(不触碰 java\/http),网络动作在各 rule 的 JS 里完成。\n * ===================================================================== *\/\n\nvar BILI_HOME = 'https:\/\/www.bilibili.com';\nvar BILI_API = 'https:\/\/api.bilibili.com';\nvar BILI_SEARCH_HOME = 'https:\/\/search.bilibili.com';\nvar BILI_UA = 'Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/126.0.0.0 Safari\/537.36';\n\n\/* ---------- 状态码:命中这些视为风控,需要重试 ---------- *\/\nvar BILI_RISK_CODES = { '-412': 1, '-352': 1, '-509': 1 };\n\n\/* =====================================================================\n * 一、纯 JS MD5(UTF-8 友好,中文关键词必须走 UTF-8)\n * ===================================================================== *\/\n\nfunction biliUtf8Bytes(str) {\n var out = [], i = 0, c, c2, cc;\n var s = String(str);\n while (i < s.length) {\n c = s.charCodeAt(i++);\n if (c < 0x80) {\n out.push(c);\n } else if (c < 0x800) {\n out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));\n } else if (c >= 0xd800 && c < 0xdc00 && i < s.length) {\n \/\/ 代理对\n c2 = s.charCodeAt(i++);\n cc = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);\n out.push(\n 0xf0 | (cc >> 18),\n 0x80 | ((cc >> 12) & 0x3f),\n 0x80 | ((cc >> 6) & 0x3f),\n 0x80 | (cc & 0x3f)\n );\n } else {\n out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));\n }\n }\n return out;\n}\n\nvar BILI_MD5_S = [\n 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,\n 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,\n 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,\n 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21\n];\n\nvar BILI_MD5_K = (function () {\n var k = [], i;\n for (i = 0; i < 64; i++) {\n k[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) & 0xffffffff;\n }\n return k;\n})();\n\nfunction biliHex32(n) {\n var r = '', v, k;\n for (k = 0; k < 4; k++) {\n v = (n >>> (k * 8)) & 0xff;\n r += (v < 16 ? '0' : '') + v.toString(16);\n }\n return r;\n}\n\nfunction biliMd5(str) {\n var b = biliUtf8Bytes(str);\n var len = b.length;\n var i, j;\n \/\/ 填充\n b.push(0x80);\n while (b.length % 64 !== 56) b.push(0);\n var bitHi = Math.floor((len * 8) \/ 4294967296);\n var bitLo = (len * 8) >>> 0;\n b.push(bitLo & 0xff, (bitLo >>> 8) & 0xff, (bitLo >>> 16) & 0xff, (bitLo >>> 24) & 0xff);\n b.push(bitHi & 0xff, (bitHi >>> 8) & 0xff, (bitHi >>> 16) & 0xff, (bitHi >>> 24) & 0xff);\n\n var v1 = 0x67452301, v2 = 0xefcdab89, v3 = 0x98badcfe, v4 = 0x10325476;\n var M = [], A, B, C, D, F, g;\n\n function rotl(x, n) {\n return ((x << n) | (x >>> (32 - n))) >>> 0;\n }\n\n for (i = 0; i < b.length; i += 64) {\n for (j = 0; j < 16; j++) {\n M[j] = (b[i + j * 4] |\n (b[i + j * 4 + 1] << 8) |\n (b[i + j * 4 + 2] << 16) |\n (b[i + j * 4 + 3] << 24)) >>> 0;\n }\n A = v1; B = v2; C = v3; D = v4;\n for (j = 0; j < 64; j++) {\n if (j < 16) {\n F = (B & C) | (~B & D); g = j;\n } else if (j < 32) {\n F = (D & B) | (~D & C); g = (5 * j + 1) % 16;\n } else if (j < 48) {\n F = B ^ C ^ D; g = (3 * j + 5) % 16;\n } else {\n F = C ^ (B | ~D); g = (7 * j) % 16;\n }\n F = (F + A + BILI_MD5_K[j] + M[g]) >>> 0;\n A = D; D = C; C = B;\n B = (B + rotl(F, BILI_MD5_S[j])) >>> 0;\n }\n v1 = (v1 + A) >>> 0;\n v2 = (v2 + B) >>> 0;\n v3 = (v3 + C) >>> 0;\n v4 = (v4 + D) >>> 0;\n }\n return biliHex32(v1) + biliHex32(v2) + biliHex32(v3) + biliHex32(v4);\n}\n\n\/* =====================================================================\n * 二、WBI 签名 —— 对齐 wbi_signer.dart\n * ===================================================================== *\/\n\nvar BILI_MIXIN_TAB = [\n 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,\n 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,\n 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,\n 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52\n];\n\nfunction biliMixinKey(orig) {\n var s = '', i;\n for (i = 0; i < 32; i++) s += orig.charAt(BILI_MIXIN_TAB[i]);\n return s;\n}\n\n\/** 从 https:\/\/i0.hdslb.com\/bfs\/wbi\/xxx.png 提取 xxx *\/\nfunction biliKeyOf(url) {\n var name = url.substring(url.lastIndexOf('\/') + 1);\n var dot = name.lastIndexOf('.');\n return dot > 0 ? name.substring(0, dot) : name;\n}\n\n\/** 对齐 Dart: 先剔除 !'()* 再做 encodeURIComponent *\/\nfunction biliWbiEncode(v) {\n var s = String(v).replace(\/[!'()*]\/g, '');\n return encodeURIComponent(s);\n}\n\n\/**\n * 对参数对象做 WBI 签名,返回可直接拼接的 query string(不含前导 ?)\n * params: 普通对象;会就地加入 wts 与 w_rid\n *\/\nfunction biliSignQuery(params, imgUrl, subUrl) {\n var mk = biliMixinKey(biliKeyOf(imgUrl) + biliKeyOf(subUrl));\n params.wts = Math.floor(Date.now() \/ 1000);\n var keys = [], k, i;\n for (k in params) {\n if (params.hasOwnProperty(k) && params[k] !== null && params[k] !== undefined) keys.push(k);\n }\n keys.sort();\n var buf = '';\n for (i = 0; i < keys.length; i++) {\n buf += biliWbiEncode(keys[i]) + '=' + biliWbiEncode(params[keys[i]]) + '&';\n }\n var noTail = buf.substring(0, buf.length - 1);\n params.w_rid = biliMd5(noTail + mk);\n var qs = '', head = '';\n for (i = 0; i < keys.length; i++) {\n qs += head + encodeURIComponent(keys[i]) + '=' + encodeURIComponent(params[keys[i]]);\n head = '&';\n }\n qs += '&w_rid=' + params.w_rid;\n return qs;\n}\n\n\/* =====================================================================\n * 三、听书内容判定 —— 对齐 bili_api.dart 的 _isAudiobookItem\n * ===================================================================== *\/\n\nvar BILI_AUDIO_HINTS = [\n '有声小说', '有声书', '有声剧', '有声读物', '有声', '听书', '评书', '相声',\n '广播剧', '演播', '朗读', '诵读', '长书', '连播', '说书', '单口', '播讲',\n '多人剧', '小说剧'\n];\nvar BILI_NOISE_HINTS = [\n '一口气看完', '漫推', '漫画', '解说', '讲解', '解读', '动画', '鬼畜', '混剪',\n '沙雕', '速看', 'reaction', '预告', '游戏实况', '配音秀'\n];\nvar BILI_GENRE_HINTS = [\n '评书', '相声', '广播剧', '听书', '演播', '朗读', '诵读', '长书', '连播',\n '说书', '单口', '播讲'\n];\nvar BILI_AUDIO_TYPE_ID = 195;\n\nfunction biliHas(str, arr) {\n var i;\n for (i = 0; i < arr.length; i++) {\n if (str.indexOf(arr[i]) >= 0) return true;\n }\n return false;\n}\n\n\/** 去掉搜索结果标题里的 <em class=\"keyword\"> 高亮标签 *\/\nfunction biliCleanTitle(t) {\n return String(t == null ? '' : t)\n .replace(\/<em class=\"keyword\">\/g, '')\n .replace(\/<\\\/em>\/g, '');\n}\n\n\/** 封面协议归一化:\/\/i0.hdslb.com 与 http:\/\/ 统一转 https *\/\nfunction biliNormalizePic(u) {\n var s = String(u == null ? '' : u).trim();\n if (!s) return s;\n if (s.indexOf('\/\/') === 0) return 'https:' + s;\n if (s.indexOf('http:\/\/') === 0) return 'https:\/\/' + s.substring(7);\n return s;\n}\n\n\/** duration 可能是秒数,也可能是 \"1:02:03\" \/ \"5152:33\" 字符串 *\/\nfunction biliDurSec(v) {\n if (v === null || v === undefined) return 0;\n if (typeof v === 'number') return v;\n var s = String(v).trim();\n if (!s) return 0;\n var p = s.split(':');\n if (p.length >= 2) {\n var t = 0, i;\n for (i = 0; i < p.length; i++) t = t * 60 + (parseInt(p[i].trim(), 10) || 0);\n return t;\n }\n return parseInt(s, 10) || 0;\n}\n\n\/**\n * 是否算听书内容。\n * title\/tag\/desc\/typeid\/duration 来自搜索条目,keyword 为搜索词(可空)。\n *\/\nfunction biliIsAudiobook(title, tag, desc, typeid, duration, keyword) {\n title = biliCleanTitle(title);\n var meta = String(tag == null ? '' : tag) + ' ' + String(desc == null ? '' : desc);\n if (biliHas(title + ' ' + meta, BILI_NOISE_HINTS)) return false;\n\n var sec = biliDurSec(duration);\n if (biliHas(title, BILI_AUDIO_HINTS) && sec >= 300) return true;\n if (parseInt(typeid, 10) === BILI_AUDIO_TYPE_ID && sec >= 600) return true;\n if (biliHas(meta, BILI_AUDIO_HINTS) && sec >= 1800) return true;\n if (keyword && biliHas(String(keyword), BILI_GENRE_HINTS) && sec >= 1800) return true;\n return false;\n}\n\n\/** 时长秒 -> \"1:02:03\" \/ \"12:34\" *\/\nfunction biliDurText(sec) {\n var n = parseInt(sec, 10) || 0;\n if (n <= 0) return '';\n var h = Math.floor(n \/ 3600), m = Math.floor((n % 3600) \/ 60), s = n % 60;\n var mm = m < 10 ? '0' + m : '' + m;\n var ss = s < 10 ? '0' + s : '' + s;\n if (h > 0) return h + ':' + mm + ':' + ss;\n return mm + ':' + ss;\n}\n\n\/** 从 JSON 字符串安全取对象 *\/\nfunction biliJson(s) {\n return JSON.parse(String(s));\n}\n\n\/* =====================================================================\n * 四、需要网络的函数\n * ---------------------------------------------------------------------\n * Rhino 中 jsLib 被挂拼成 prototype scope,函数体里访问不到调用者的\n * java\/cache binding(词法作用域在 sharedScope)。因此这里统一把\n * java(实现了 JsExtensions 的对象) 与 cache(CacheManager) 显式入参。\n * 这样规则里写 <js>fn(java,cache,...)<\/js> 一行即可调用,也便于离线测试。\n * ===================================================================== *\/\n\n\/* ---- 缓存 key ---- *\/\nvar BILI_CK_WBI = 'bili_wbi_keys';\nvar BILI_CK_READY = 'bili_warmup_ready';\nvar BILI_CK_KW = 'bili_search_keyword';\nvar BILI_CK_PAGE = 'bili_search_page';\n\n\/* =====================================================================\n * 发现页分类:[显示名, 搜索关键词, 排序方式]\n * 严格对齐 abts lib\/data\/seed_books.dart 的 DiscoverSeeds.categories\n * 与 hotKeys。\n * ===================================================================== *\/\nvar BILI_CATEGORIES = [\n ['热门推荐', '有声小说', 'click', 1],\n ['广播剧', '广播剧 全集', '', 1],\n ['评书', '评书 单田芳', '', 1],\n ['相声', '相声 德云社', '', 1],\n ['悬疑', '悬疑有声小说', '', 1],\n ['玄幻仙侠', '玄幻小说听书', '', 1],\n ['名著悦读', '世界名著朗读', '', 1],\n ['名家主播', '', '', 1],\n ['周建龙', '周建龙 有声小说', ''],\n ['紫襟', '紫襟 有声小说', ''],\n ['王明军', '王明军 有声小说', ''],\n ['艾宝良', '艾宝良 有声小说', ''],\n ['李野默', '李野默 有声小说', ''],\n ['青雪', '青雪 有声小说', ''],\n ['单田芳', '单田芳 评书', ''],\n ['刘兰芳', '刘兰芳 评书', ''],\n ['袁阔成', '袁阔成 评书', ''],\n ['田连元', '田连元 评书', ''],\n ['郭德纲', '郭德纲 相声', ''],\n ['马三立', '马三立 相声', ''],\n ['光合积木', '光合积木 广播剧', ''],\n ['729声工场', '729声工场 广播剧', ''],\n ['边江工作室', '边江工作室 广播剧', ''],\n ['头陀渊', '头陀渊 有声小说', ''],\n ['桑梓', '桑梓 有声小说', ''],\n ['道听途说', '道听途说 有声小说', '']\n];\n\n\/* 分类项默认占满一行;主播类一行排 4 个,避免发现页过长 *\/\nvar BILI_ANCHOR_BASIS = 0.25;\n\n\/* 发现页搜索框的标题,同时也是 infoMap 里的取值 key。\n * ⚠️ 改这里时必须同步改下面 biliExploreUrl 里 action 中的 '🔍 关键词' 字面量\n * (那段必须与番茄短剧保持逐字一致,不能改成变量拼接以外的差异写法)。 *\/\nvar BILI_SEARCH_KEY = '🔍 关键词';\n\n\/**\n * 生成发现页(ExploreKind 数组,JSON 字符串)。\n *\n * 与「名::url」纯文本格式不同,这里返回 JSON 数组,从而支持:\n * 1. type:'text' 输入框,输入值会落进 infoMap[title]\n * 2. type:'button' 按钮,点击时执行 action\n * 两者配合即可在发现页内直接发起搜索(跳过手输 pencil)。\n *\n * 分类的 url 仍是一段延迟执行的 <js>,真正请求时才由 AnalyzeUrl 计算签名,\n * 避免 exploreKinds 把已签名的 w_rid 缓存下来导致过期。\n *\/\nfunction biliExploreUrl() {\n var items = [];\n\n \/\/ ── 搜索区:输入框 + 搜索按钮 ──\n items.push({\n title: BILI_SEARCH_KEY,\n type: 'text',\n style: { layout_flexGrow: 1, layout_flexBasisPercent: 0.65 }\n });\n items.push({\n title: '📤 搜索',\n type: 'button',\n \/\/ ⚠️ 这一段与番茄短剧的实现逐字保持一致,不要擅自加 .replace() 之类改造:\n \/\/ source.bookSourceName 拿到的是 java.lang.String,Rhino 里对它调\n \/\/ replace(\/:\/g,'') 会命中 Java 的 replace(CharSequence,CharSequence)\n \/\/ 而非 ECMAScript 的 String.prototype.replace,RegExp 传不进去会抛错,\n \/\/ 而 evalButtonClick 会静默吞掉异常,表现就是「点按钮没反应」。\n \/\/ scope 取「书源名::书源地址」,与 RssJsExtensions 里的做法一致。\n action:\n \"java.searchBook(infoMap['🔍 关键词']||'',source.bookSourceName+'::'+source.getKey())\",\n style: { layout_flexGrow: 1, layout_flexBasisPercent: -1 }\n });\n\n \/\/ ── 分类区 \/ 主播区 ──\n for (var i = 0; i < BILI_CATEGORIES.length; i++) {\n var c = BILI_CATEGORIES[i];\n \/\/ 第 4 项为布局宽度;主分类显式给 1(占满一行),主播缺省则用 0.25(一行四个)\n var basis = c.length > 3 ? c[3] : BILI_ANCHOR_BASIS;\n if (!c[1]) {\n \/\/ 纯标题行,url 为空 -> 点击无响应,仅作分组标签\n items.push({\n title: c[0],\n url: '',\n style: { layout_flexGrow: 1, layout_flexBasisPercent: basis }\n });\n continue;\n }\n items.push({\n title: c[0],\n url:\n '<js>biliSearchUrl(java,cache,' +\n JSON.stringify(c[1]) +\n ',1,' +\n JSON.stringify(c[2]) +\n ')<\/js>',\n style: { layout_flexGrow: 1, layout_flexBasisPercent: basis }\n });\n }\n\n return JSON.stringify(items);\n}\n\nfunction biliSleep(ms) {\n var t = Date.now() + ms;\n while (Date.now() < t) {\n \/* 退避等待,规避 B 站风控 *\/\n }\n}\n\n\/* ---- CacheManager 薄封装 ----\n * 不依赖 put(key,value,saveTime) 的 Int 形参(Rhino 传 double 易踩类型转换坑),\n * 改为自行存储时间戳来判断有效期。\n *\/\nfunction biliCacheGet(cache, k) {\n try {\n var v = cache.get(k);\n return v === null || v === undefined ? '' : String(v);\n } catch (e) {\n return '';\n }\n}\n\nfunction biliCacheSet(cache, k, v) {\n try {\n cache.put(k, String(v));\n } catch (e) {\n try {\n cache.put(k, String(v), 0);\n } catch (e2) {}\n }\n}\n\nfunction biliCacheDel(cache, k) {\n try {\n cache.delete(k);\n } catch (e) {}\n}\n\n\/**\n * 预热:访问首页种 buvid3\/b_nut,激活一次 gaia 风控闭环。\n * 成功后在 1 小时内不再重复。\n *\/\nfunction biliWarmup(java, cache) {\n var ready = biliCacheGet(cache, BILI_CK_READY);\n var rp = ready.split('|');\n if (rp.length === 2 && Date.now() - parseInt(rp[1], 10) < 3600000) return true;\n try {\n java.ajax(BILI_HOME + '\/');\n } catch (e) {\n \/* 预热失败不阻塞 *\/\n }\n try {\n \/\/ gaia:Buvid 激活,显著压低 v_voucher \/ -412 概率\n var png = biliBase64Bytes(biliRandomPngBytes());\n var payload = JSON.stringify({\n '3064': 1,\n '39c8': '333.1387.fp.risk',\n '3c43': { adca: 'Linux', bfe9: png.substring(png.length - 50) }\n });\n java.ajax(\n BILI_API +\n '\/x\/internal\/gaia-gateway\/ExClimbWuzhi,{\"method\":\"POST\",\"body\":' +\n JSON.stringify({ payload: payload }) +\n '}'\n );\n } catch (e) {\n \/* 静默 *\/\n }\n biliCacheSet(cache, BILI_CK_READY, '1|' + Date.now());\n return true;\n}\n\n\/** 拼接一段伪造 PNG 的字节(gaia 上报用,对齐 BiliClient._ensureBuvidActivated) *\/\nfunction biliRandomPngBytes() {\n var bytes = [], i;\n for (i = 0; i < 32; i++) bytes.push(Math.floor(Math.random() * 256));\n bytes = bytes.concat([0, 0, 0, 0, 73, 69, 78, 68]); \/\/ IEND 尾块\n for (i = 0; i < 4; i++) bytes.push(Math.floor(Math.random() * 256));\n return bytes;\n}\n\n\/** base64 编码字节数组(Rhino 无 btoa,手写一版) *\/\nfunction biliBase64Bytes(bytes) {\n var T =\n 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+\/';\n var out = '', i, b1, b2, b3;\n for (i = 0; i < bytes.length; i += 3) {\n b1 = bytes[i];\n b2 = bytes[i + 1];\n b3 = bytes[i + 2];\n var triplet = (b1 << 16) | ((b2 === undefined ? 0 : b2) << 8) | (b3 === undefined ? 0 : b3);\n out += T.charAt((triplet >> 18) & 0x3f) + T.charAt((triplet >> 12) & 0x3f);\n if (b2 === undefined) {\n out += '==';\n } else {\n out += T.charAt((triplet >> 6) & 0x3f);\n out += b3 === undefined ? '=' : T.charAt(triplet & 0x3f);\n }\n }\n return out;\n}\n\n\/**\n * 取 WBI 公钥对 [imgUrl, subUrl],按 24 小时缓存。\n * 对齐 BiliClient._refreshWbiKeys + BiliApi 的按天刷新策略。\n *\/\nfunction biliWbiKeys(java, cache, force) {\n if (!force) {\n var p = biliCacheGet(cache, BILI_CK_WBI).split('|');\n if (p.length === 3 && p[0] && p[1]) {\n if (Date.now() - parseInt(p[2], 10) < 86400000) return [p[0], p[1]];\n }\n }\n var txt;\n try {\n txt = java.ajax(BILI_API + '\/x\/web-interface\/nav');\n } catch (e) {\n return null;\n }\n if (!txt) return null;\n var j;\n try {\n j = JSON.parse(String(txt));\n } catch (e) {\n return null;\n }\n var d = j.data || {};\n var w = d.wbi_img;\n if (!w || !w.img_url || !w.sub_url) return null;\n biliCacheSet(cache, BILI_CK_WBI, w.img_url + '|' + w.sub_url + '|' + Date.now());\n return [w.img_url, w.sub_url];\n}\n\n\/**\n * 生成带 WBI 签名的 search\/type 完整 URL\n * @param order 可选:'click' 按播放排序(榜单),'' 综合排序\n *\/\nfunction biliSearchUrl(java, cache, word, page, order) {\n biliWarmup(java, cache);\n var ks = biliWbiKeys(java, cache);\n if (!ks) throw 'B站初始化失败:取不到 WBI 公钥';\n var n = parseInt(page, 10);\n if (!n || n < 1) n = 1;\n var p = {\n search_type: 'video',\n keyword: word,\n page: n,\n page_size: 20,\n platform: 'pc',\n web_location: 1430654\n };\n if (order) p.order = order;\n biliCacheSet(cache, BILI_CK_KW, word);\n biliCacheSet(cache, BILI_CK_PAGE, String(n));\n return BILI_API + '\/x\/web-interface\/wbi\/search\/type?' + biliSignQuery(p, ks[0], ks[1]);\n}\n\n\/**\n * 解析并过滤 search\/type 响应。\n * @param body 响应体字符串\n * @param word 搜索词(用于“题材放宽”判定,可为空)\n * @param page 页码(补搜时会用到)\n * @return 数组,每项为一个 JSON 字符串\n *\/\nfunction biliSearchBody(java, cache, body, word, page) {\n var j;\n try {\n j = JSON.parse(String(body));\n } catch (e) {\n return [];\n }\n var d = j.data || {};\n\n \/\/ 风控兜底:v_voucher \/ -412 \/ -352 时刷新公钥后重试一次\n if (j.code !== 0 || !d.result) {\n if (d.v_voucher || j.code === -412 || j.code === -352) {\n if (java && cache) {\n biliSleep(1200);\n var ks2 = biliWbiKeys(java, cache, true);\n if (ks2) {\n var n = parseInt(page, 10);\n if (!n || n < 1) n = 1;\n var p2 = {\n search_type: 'video',\n keyword: word,\n page: n,\n page_size: 20,\n platform: 'pc',\n web_location: 1430654\n };\n var u2 =\n BILI_API + '\/x\/web-interface\/wbi\/search\/type?' + biliSignQuery(p2, ks2[0], ks2[1]);\n try {\n j = JSON.parse(String(java.ajax(u2)));\n d = j.data || {};\n } catch (e2) {\n \/* 保持原值 *\/\n }\n }\n }\n }\n }\n\n var list = d.result || [];\n var out = [];\n var i;\n for (i = 0; i < list.length; i++) {\n var m = list[i];\n var bvid = m.bvid || '';\n if (!bvid) continue;\n if (!biliIsAudiobook(m.title, m.tag, m.description, m.typeid, m.duration, word)) continue;\n out.push(m);\n }\n\n \/\/ 对齐 BiliApi.search:命中太少且关键词本身不含听书词时,补搜 “关键词 有声小说”\n if (out.length < 6 && java && cache && !biliHas(String(word), BILI_AUDIO_HINTS)) {\n try {\n var extras = biliSearchRawList(java, cache, word + ' 有声小说', page);\n var seen = {};\n for (i = 0; i < out.length; i++) seen[out[i].bvid] = 1;\n for (i = 0; i < extras.length; i++) {\n var e3 = extras[i];\n if (e3.bvid && !seen[e3.bvid]) {\n seen[e3.bvid] = 1;\n out.push(e3);\n }\n }\n } catch (e4) {\n \/* 补搜失败不影响主结果 *\/\n }\n }\n\n \/\/ 输出成 Legado bookList 可直接消费的形态:每项一个 JSON 字符串\n var res = [];\n for (i = 0; i < out.length; i++) {\n var it = out[i];\n res.push(\n JSON.stringify({\n bvid: it.bvid,\n aid: it.aid,\n mid: it.mid,\n name: biliCleanTitle(it.title),\n author: it.author || '',\n cover: biliNormalizePic(it.pic),\n intro: it.description || '',\n kind: it.typename || '',\n tag: it.tag || '',\n play: it.play || 0,\n pubdate: it.pubdate || 0,\n sdur: biliDurSec(it.duration),\n dur: biliDurText(biliDurSec(it.duration)),\n bookUrl: BILI_API + '\/x\/web-interface\/view?bvid=' + it.bvid\n })\n );\n }\n return res;\n}\n\n\/** 内部:取某个关键词的已过滤列表(补搜用) *\/\nfunction biliSearchRawList(java, cache, kw, page) {\n var ks = biliWbiKeys(java, cache);\n if (!ks) return [];\n var n = parseInt(page, 10);\n if (!n || n < 1) n = 1;\n var p = {\n search_type: 'video',\n keyword: kw,\n page: n,\n page_size: 20,\n platform: 'pc',\n web_location: 1430654\n };\n var u = BILI_API + '\/x\/web-interface\/wbi\/search\/type?' + biliSignQuery(p, ks[0], ks[1]);\n var body = java.ajax(u);\n var j;\n try {\n j = JSON.parse(String(body));\n } catch (e) {\n return [];\n }\n var d = j.data || {};\n var list = d.result || [];\n var out = [];\n for (var i = 0; i < list.length; i++) {\n var m = list[i];\n if (!m.bvid) continue;\n if (!biliIsAudiobook(m.title, m.tag, m.description, m.typeid, m.duration, kw)) continue;\n out.push(m);\n }\n return out;\n}\n\n\/**\n * 取某个分P的音频地址(DASH audio)。\n * 对齐 BiliApi.playUrl + BookAudio.pickPreferred。\n *\/\nfunction biliAudioUrl(java, cache, bvid, cid) {\n if (!bvid) throw '缺少 bvid';\n if (!cid) throw '缺少 cid';\n biliWarmup(java, cache);\n var p = {\n bvid: bvid,\n cid: cid,\n qn: 80,\n fnval: 4048,\n fnver: 0,\n fourk: 1,\n try_look: 1,\n gaia_source: 'prefer-ua'\n };\n var ks = biliWbiKeys(java, cache);\n if (!ks) throw 'B站初始化失败:取不到 WBI 公钥';\n var j = biliPlayurl(java, cache, p, ks);\n if (j.code !== 0) {\n \/\/ 风控:刷新公钥重试一次\n biliSleep(1200);\n ks = biliWbiKeys(java, cache, true);\n if (ks) j = biliPlayurl(java, cache, p, ks);\n }\n if (j.code !== 0) throw '取流失败 ' + j.code + ' ' + j.message;\n\n var d = j.data || {};\n var dash = d.dash || {};\n \/\/ 优先无损\/杜比,其次按带宽降序\n if (dash.flac && dash.flac.audio && dash.flac.audio.baseUrl) return dash.flac.audio.baseUrl;\n if (dash.dolby && dash.dolby.audio && dash.dolby.audio.length && dash.dolby.audio[0].baseUrl) {\n return dash.dolby.audio[0].baseUrl;\n }\n var arr = dash.audio || [];\n if (!arr.length) throw '该分P没有可用音轨';\n arr = arr.slice(0).sort(function (a, b) {\n return (b.bandwidth || 0) - (a.bandwidth || 0);\n });\n return arr[0].baseUrl;\n}\n\n\/** 内部:调 playurl 并返回 parsed json *\/\nfunction biliPlayurl(java, cache, p, ks) {\n var u = BILI_API + '\/x\/player\/wbi\/playurl?' + biliSignQuery(p, ks[0], ks[1]);\n var txt = java.ajax(u);\n try {\n return JSON.parse(String(txt));\n } catch (e) {\n return { code: -1, message: '解析失败' };\n }\n}\n\n\/**\n * 从任意包含 bvid \/ cid 的 URL 中抽取参数。\n * chapterUrl 形如 https:\/\/api.bilibili.com\/x\/player\/pagelist?bvid=BV1xx&cid=123\n *\/\nfunction biliParseUrl(urlStr) {\n var s = String(urlStr);\n var b = \/[?&]bvid=([^&]+)\/.exec(s);\n var c = \/[?&]cid=([^&]+)\/.exec(s);\n return {\n bvid: b ? decodeURIComponent(b[1]) : '',\n cid: c ? decodeURIComponent(c[1]) : ''\n };\n}\n\n\/**\n * 解析分P列表(pagelist 响应)成 Legado 章节数组。\n * pagelist 的每一项不含 bvid,因此从当前请求 URL(baseUrl) 里补出来,\n * 并预先拼好 chapterUrl,让 ruleToc 只需走纯 JsonPath。\n * @return 数组,每项为一个 JSON 字符串\n *\/\nfunction biliChapterList(body, base) {\n var j;\n try {\n j = JSON.parse(String(body));\n } catch (e) {\n return [];\n }\n var arr = j.data || [];\n var bv = biliParseUrl(base).bvid;\n var out = [], i;\n for (i = 0; i < arr.length; i++) {\n var p = arr[i];\n if (!p || !p.cid) continue;\n out.push(\n JSON.stringify({\n cid: p.cid,\n page: p.page,\n part: p.part || '',\n sdur: biliDurSec(p.duration),\n dur: biliDurText(biliDurSec(p.duration)),\n bvid: bv,\n url: BILI_API + '\/x\/player\/pagelist?bvid=' + bv + '&cid=' + p.cid\n })\n );\n }\n return out;\n}\n",
"lastUpdateTime": "1794394409800",
"respondTime": 180000,
"ruleBookInfo": {
"author": "$.data.owner.name",
"coverUrl": "$.data.pic@js:biliNormalizePic(result)",
"intro": "$.data.desc",
"kind": "<js>biliJson(result).data.tname||'有声书'<\/js>",
"lastChapter": "<js>biliJson(result).data.videos+' 集'<\/js>",
"name": "$.data.title",
"tocUrl": "https:\/\/api.bilibili.com\/x\/player\/pagelist?bvid={$.data.bvid}",
"wordCount": "$.data.duration"
},
"ruleContent": {
"content": "<js>biliAudioUrl(java,cache,biliParseUrl(baseUrl).bvid,biliParseUrl(baseUrl).cid)<\/js>"
},
"ruleExplore": {
"author": "$.author",
"bookList": "<js>biliSearchBody(java,cache,result,cache.get('bili_search_keyword')||'',cache.get('bili_search_page')||1)<\/js>",
"bookUrl": "$.bookUrl",
"coverUrl": "$.cover",
"intro": "$.intro",
"kind": "$.kind",
"lastChapter": "$.dur",
"name": "$.name",
"wordCount": "$.sdur"
},
"ruleSearch": {
"author": "$.author",
"bookList": "<js>biliSearchBody(java,cache,result,cache.get('bili_search_keyword')||'',cache.get('bili_search_page')||1)<\/js>",
"bookUrl": "$.bookUrl",
"coverUrl": "$.cover",
"intro": "$.intro",
"kind": "$.kind",
"lastChapter": "$.dur",
"name": "$.name",
"wordCount": "$.sdur"
},
"ruleToc": {
"chapterList": "<js>biliChapterList(result,baseUrl)<\/js>",
"chapterName": "$.part",
"chapterUrl": "$.url",
"updateTime": "$.dur"
},
"searchUrl": "<js>biliSearchUrl(java,cache,key,page,'')<\/js>",
"weight": 0
}