哔哩哔哩听书

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
}
广告