diff --git a/README.md b/README.md index c35f485..d507e85 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - **解锁章节**:网页端被屏蔽的章节可以正常阅读 - **漫画阅读**:图片章节支持懒加载,加密图片滚动到视口时自动解密 - **章内注释**:自动处理 EPUB footnote,悬浮查看或点击定位,末尾保留注释列表 -- **字体反混淆**:还原网页自绘字体渲染的混淆文本,动态插入的内容同样生效 +- **字体反混淆**:还原网页自绘字体渲染的混淆文本,动态插入的内容同样生效,多套码表(正文 / 搜索页)自动识别 - **书籍样式**:支持随正文下发的 CSS,同时限制作用域避免污染全站 - **章节信息补全**:补上卷名、精确到秒的更新时间,并修正页面标题 @@ -31,6 +31,14 @@ - **结果卡片**:显示评分、字数、在读人数、最新章节等信息,右键可加入书架 - **个人化推荐**:可选携带登录态获取个人化结果,默认关闭 +### 书籍下载 + +- **两种格式**:EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本,可选 UTF-8 / GBK 编码 +- **多个入口**:书籍详情页按钮、书架右键菜单、搜索结果右键菜单,右键可单次指定格式 +- **EPUB 选项**:插图下载、书籍自带排版、每卷卷页均可单独开关;章节内联样式自动去重 +- **进度与取消**:抓取、组装、压缩全程可见进度,随时可以取消 +- **抓取调度**:批量接口按 30 章一批串行请求,可调间隔与重试轮数,失败章节自动补抓 + ### 书架优化 - **完整书架**:展示所有书籍(包括因版权等原因网页端不可见的),支持跳转阅读 @@ -46,6 +54,7 @@ - **屏蔽埋点上报**:拦截发往字节、百度统计域名的埋点请求 - **设备管理**:首次使用自动注册匿名设备并激活会员,支持手动填写设备信息 +- **接口可选**:正文与详情默认走番茄 APP 接口,可切换到红烛(风控更低),某一侧数据不全时自动互为补充 ## 开发计划 @@ -53,7 +62,7 @@ 1. 短剧网页端播放 2. 更完善的用户详情界面(自己的和他人的) -3. 段评、章评、书评功能 +3. 段评、章评、书评功能(书评接口已接通,界面待做) 4. 阅读数据上报(与安卓端同步阅读进度与时长) 5. APP 端带推荐的排行榜 6. 分类页与书城页面替换 @@ -69,6 +78,8 @@ 安装构建产物 `fanqie-assistant.user.js` 即可。 +部分功能(书评点赞)需要脚本读到 HttpOnly 的 `sessionid`,Tampermonkey 默认不给,需手动放开,见 [关于账号安全](#关于账号安全)。其余功能开箱可用。 + ## 开发 ```bash @@ -100,28 +111,43 @@ npm run build src/ ├── main.ts 入口,安装导航钩子并按顺序初始化各模块 ├── config.ts 设备配置与全局常量 -├── types.ts 书籍、卷、章节等数据模型 +├── types.ts 书籍、卷、章节、书评等数据模型 ├── settings.ts 设置项定义与持久化 ├── settingsPanel.ts 设置面板的挂载/卸载 +├── downloadPanel.ts 下载进度弹窗的挂载/卸载 ├── userStyle.ts 用户自定义样式(字体、CSS) ├── cssInject.ts 基础样式注入 -├── fontDecrypt.ts 自绘字体文本还原 +├── fontDecrypt.ts 自绘字体文本还原(多套码表) ├── localStorage.ts 存储读写封装 ├── api/ 接口相关内容 ├── crypto/ 加密算法相关内容 +├── download/ 下载流程 +│ ├── index.ts 任务编排(详情+目录 -> 抓正文 -> 组装 -> 保存) +│ ├── chapters.ts 批量抓取调度(分批、限流、重试) +│ ├── content.ts 正文解析(XHTML 片段与纯文本) +│ ├── epub.ts EPUB 组装 +│ ├── txt.ts TXT 组装 +│ ├── gbk.ts GBK 编码 +│ ├── meta.ts 书籍元信息整理 +│ ├── save.ts 文件落盘与文件名清理 +│ └── task.ts 进度与取消 +├── epub/ EPUB 打包库(llepub-saver 的 TypeScript 移植) ├── hooks/ 页面钩子 │ ├── index.ts 钩子调度器 │ ├── readerHook.ts 阅读页正文替换 │ ├── bookshelfHook.ts 书架页面接管 │ ├── searchHook.ts 搜索页面接管 +│ ├── downloadHook.ts 详情页下载按钮注入 │ ├── userHook.ts 用户菜单增强 │ └── fetchHook.ts 请求拦截(屏蔽埋点) ├── utils/ 通用工具 │ ├── index.ts DOM 等待、数组分块等 │ ├── request.ts GM_xmlhttpRequest 封装 +│ ├── cookie.ts Cookie 读取(含 HttpOnly) │ ├── time.ts 时间格式化 │ ├── compress.ts Gzip 压缩/解压 -│ └── footnote.ts 章内注释处理 +│ ├── footnote.ts 章内注释处理 +│ └── zipfix.ts 修复 JSZip 在脚本沙箱里的异步调度 ├── views/ Vue 组件 │ ├── SettingsView.vue 设置面板 │ ├── BookshelfView.vue 书架主视图 @@ -134,12 +160,15 @@ src/ │ ├── SearchBookCard.vue 搜索结果卡片 │ ├── SearchLanding.vue 搜索落地页(热搜与榜单) │ ├── searchRoute.ts 搜索词与 URL 的同步 -│ └── useSearch.ts 搜索数据逻辑 +│ ├── useSearch.ts 搜索数据逻辑 +│ └── DownloadProgress.vue 下载进度弹窗 └── assets/ 样式与图标 ├── script.css 阅读器样式 ├── bookshelf.css 书架样式 ├── search.css 搜索页样式 ├── settings.css 设置面板样式 + ├── download.css 下载弹窗与按钮样式 + ├── default.css 番茄 APP 正文基础样式(EPUB 导出用) └── *.svg 图标资源 ``` @@ -151,9 +180,20 @@ src/ ## 关于账号安全 -脚本**没有申请 `GM_cookie` 权限**。`sessionid` 是 HttpOnly 的,脚本读不到、也没打算读。所有需要登录凭据的请求都用页面原生 `fetch` 发送的同源请求完成,由浏览器自动带上 Cookie,凭据不经过脚本,也不会被发往任何其他地方。 -搜索走的是番茄网页站同源挂载的 APP 接口(`fanqienovel.com/reading/bookapi/*`),签名由脚本本地生成。默认以匿名方式请求(`credentials: 'omit'`),只有在设置里主动打开「个人化推荐」后才会带上登录态,此时 Cookie 同样由浏览器附加,脚本不接触。 -如果后续同源接口无法满足脚本需求,会考虑使用 `GM_cookie` 权限。但我们确保您的凭据不会被发送到番茄小说及其相关平台以外的任何第三方。后期加入第三方接口时,接口由您自己输入。此时,我们可能会将凭据发往 **您自己填写的 API** 。使用即应确认风险,我们不对任何第三方接口的行为负责。 +绝大多数需要登录凭据的请求都用页面原生 `fetch` 发同源请求完成,由浏览器自动带上 Cookie,凭据不经过脚本。搜索走番茄网页站同源挂载的 APP 接口(`fanqienovel.com/reading/bookapi/*`),签名由脚本本地生成,默认以匿名方式请求(`credentials: 'omit'`),只有在设置里主动打开「个人化推荐」后才会带上登录态。 + +**脚本现在申请了 `GM_cookie` 权限。** 原因是书评相关接口挂在字节网关的根路径(`reading.snssdk.com/novel/commentapi/*`),而番茄网页站只把 `/reading` 反代到了网关,这部分没有同源可用,点赞等写操作必须由脚本自己带上 `sessionid`。该 Cookie 只随请求发往番茄/字节官方域名,不会发往任何第三方。 + +`sessionid` 是 HttpOnly 的,Tampermonkey 稳定版默认不把它交给脚本。需要用到相关功能时,请手动放开: + +1. 设置 → 通用 → 配置模式 → 改为「高级」 +2. 设置 → 高级 → 安全 → 「允许脚本访问 Cookie」 → 改为 `All` + +不放开也不影响阅读、搜索、书架、下载等功能,只是书评的点赞与「我赞过没」的状态会不可用。 + +后期加入第三方接口时,接口由您自己输入。此时,我们可能会将凭据发往 **您自己填写的 API**。使用即应确认风险,我们不对任何第三方接口的行为负责。 + +另外解释一下 `@connect` 里的 `jxbhmy.com`:那是番茄旗下红烛小说的接口,与番茄数据基本通用、风控更低,属于第一方而非第三方。脚本不会把您的凭据发往番茄及其关联平台以外的任何地方。 代码全部开源,欢迎审查。 diff --git a/dist/fanqie-assistant.user.js b/dist/fanqie-assistant.user.js index b7a3b45..94d50c5 100644 --- a/dist/fanqie-assistant.user.js +++ b/dist/fanqie-assistant.user.js @@ -16,7 +16,11 @@ // @connect byteimg.com // @connect fqnovelpic.com // @connect bytecdn.cn +// @connect fqnovelvod.com +// @connect novelfmvod.com +// @connect volcautovod.com // @grant GM_addStyle +// @grant GM_cookie // @grant GM_deleteValue // @grant GM_getValue // @grant GM_setValue @@ -42,7 +46,7 @@ const _config = { currentConfig: defaultConfig }; - const scriptcss = "/* 移除章节锁定图标 */\n.muyeicon-lock {\n display: none;\n}\n/* 移除APP推广图标 */\n.muye-to-fanqie {\n display: none!important;\n}\n.reader-toolbar-item-download {\n display: none!important;\n}\n.download-btn {\n display: none!important;\n}\n.download-icon {\n display: none!important;\n}\n\n.fqa-hide {\n display: none!important;\n}\n/* 404 */\n.no-content {\n display: none!important;\n}\n\n.fqa-comic-img {\n width: 100%!important;\n height: 100%!important;\n max-width: 100%!important;\n max-height: 100%!important;\n padding-top: 0!important;\n padding-bottom: 0!important;\n margin-top: 0!important;\n margin-bottom: 0!important;\n}\n\n.fqa-comic-reader {\n line-height: 0!important;\n}\n\n.fqa-menu-item,\n.arco-menu-item {\n width: 100%!important;\n}\n\n#dynamic-el {\n display: none!important;\n}\n\n.fqa-footnote-ref {\n display: inline-block;\n margin: 0 0.15em;\n padding: 0 0.25em;\n font-size: 0.7em;\n line-height: 1.4;\n vertical-align: super;\n color: var(--web-brand_normal, #f14646);\n cursor: pointer;\n user-select: none;\n border-radius: 3px;\n text-indent: 0;\n}\n\n.fqa-footnote-ref:hover,\n.fqa-footnote-ref:focus-visible {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n outline: none;\n}\n\n\n.fqa-footnote {\n margin-top: 2em;\n padding-top: 1em;\n border-top: 1px solid var(--web-gray_20, rgba(128, 128, 128, 0.25));\n font-size: var(--fqa-body-size, 1.6rem);\n}\n\n.muye-reader-content-16 .fqa-footnote { font-size: var(--fqa-body-size, 1.6rem); }\n.muye-reader-content-20 .fqa-footnote { font-size: var(--fqa-body-size, 2rem); }\n.muye-reader-content-24 .fqa-footnote { font-size: var(--fqa-body-size, 2.4rem); }\n.muye-reader-content-28 .fqa-footnote { font-size: var(--fqa-body-size, 2.8rem); }\n.muye-reader-content-32 .fqa-footnote { font-size: var(--fqa-body-size, 3.2rem); }\n\n.fqa-footnote-title {\n margin-bottom: 0.6em;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--web-gray_40, #8a8a8a);\n text-indent: 0;\n}\n\n.fqa-footnote-list {\n margin: 0;\n padding-left: 1.6em;\n font-size: 0.85em;\n line-height: 1.7;\n color: var(--web-gray_40, #8a8a8a);\n}\n\n.fqa-footnote-list li {\n margin-bottom: 0.5em;\n text-indent: 0;\n transition: background-color 0.3s ease;\n}\n\n.fqa-footnote-list li.fqa-footnote-active {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n border-radius: 4px;\n}\n\n.muye-reader-content > body {\n background-color: var(--web-bg)!important;\n}\n\n.fqa-icon-dark {\n color: #B3B3B3\n}\n\n/* ----------------------------- 右键菜单 / Toast ----------------------------- */\n\n/*\n * 书架与搜索共用。两者都把菜单 Teleport 到 body,\n * 拿不到各自根节点上的变量,所以在这里声明一份全局色板。\n */\n.fqa-menu {\n --fqa-menu-bg: #fff;\n --fqa-menu-text: #1f2329;\n --fqa-menu-sub: #8f959e;\n --fqa-menu-hover: rgba(31, 35, 41, 0.06);\n --fqa-menu-danger: #f5222d;\n\n position: fixed;\n z-index: 2147483001;\n min-width: 132px;\n max-width: 240px;\n padding: 4px;\n box-sizing: border-box;\n background: var(--fqa-menu-bg);\n border: 1px solid rgba(31, 35, 41, 0.08);\n border-radius: 8px;\n box-shadow: 0 6px 24px rgba(31, 35, 41, 0.16);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n font-size: 13px;\n color: var(--fqa-menu-text);\n user-select: none;\n}\n\n/* 二级面板:分组数量多时可滚动 */\n.fqa-menu-sub {\n max-height: 320px;\n overflow-y: auto;\n}\n\n.fqa-menu-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 7px 10px;\n border-radius: 5px;\n line-height: 1.4;\n cursor: pointer;\n white-space: nowrap;\n overflow: hidden;\n}\n\n.fqa-menu-row > span:first-child {\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.fqa-menu-row:hover,\n.fqa-menu-row.fqa-menu-open {\n background: var(--fqa-menu-hover);\n}\n\n.fqa-menu-arrow {\n color: var(--fqa-menu-sub);\n font-size: 15px;\n line-height: 1;\n}\n\n.fqa-menu-danger {\n color: var(--fqa-menu-danger);\n}\n\n.fqa-menu-disabled {\n color: var(--fqa-menu-sub);\n cursor: not-allowed;\n}\n\n.fqa-menu-disabled:hover {\n background: transparent;\n}\n\n/* 操作结果提示 */\n.fqa-toast {\n position: fixed;\n left: 50%;\n bottom: 48px;\n transform: translateX(-50%);\n z-index: 2147483002;\n max-width: 80vw;\n padding: 10px 18px;\n box-sizing: border-box;\n background: rgba(31, 35, 41, 0.88);\n color: #fff;\n border-radius: 8px;\n font-size: 13px;\n line-height: 1.4;\n box-shadow: 0 6px 24px rgba(31, 35, 41, 0.24);\n pointer-events: none;\n}\n\n/* 骨架屏微光。书架与搜索共用同一个动画名 */\n@keyframes fqa-shimmer {\n 100% {\n transform: translateX(100%);\n }\n}\n\n@media (prefers-color-scheme: dark) {\n .fqa-menu {\n --fqa-menu-bg: #23272e;\n --fqa-menu-text: #e5e6eb;\n --fqa-menu-sub: #8f959e;\n --fqa-menu-hover: rgba(255, 255, 255, 0.08);\n border-color: rgba(255, 255, 255, 0.1);\n }\n}\n\n.info {\n width: 100%!important;\n}"; + const scriptcss = "/* 移除章节锁定图标 */\n.muyeicon-lock {\n display: none;\n}\n/* 移除APP推广图标 */\n.muye-to-fanqie {\n display: none!important;\n}\n.reader-toolbar-item-download {\n display: none!important;\n}\n.download-btn {\n display: none!important;\n}\n.download-icon {\n display: none!important;\n}\n\n.fqa-hide {\n display: none!important;\n}\n/* 404 */\n.no-content {\n display: none!important;\n}\n\n.fqa-comic-img {\n width: 100%!important;\n height: 100%!important;\n max-width: 100%!important;\n max-height: 100%!important;\n padding-top: 0!important;\n padding-bottom: 0!important;\n margin-top: 0!important;\n margin-bottom: 0!important;\n}\n\n.fqa-comic-reader {\n line-height: 0!important;\n}\n\n.fqa-menu-item,\n.arco-menu-item {\n width: 100%!important;\n}\n\n#dynamic-el {\n display: none!important;\n}\n\n.fqa-footnote-ref {\n display: inline-block;\n margin: 0 0.15em;\n padding: 0 0.25em;\n font-size: 0.7em;\n line-height: 1.4;\n vertical-align: super;\n color: var(--web-brand_normal, #f14646);\n cursor: pointer;\n user-select: none;\n border-radius: 3px;\n text-indent: 0;\n}\n\n.fqa-footnote-ref:hover,\n.fqa-footnote-ref:focus-visible {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n outline: none;\n}\n\n\n.fqa-footnote {\n margin-top: 2em;\n padding-top: 1em;\n border-top: 1px solid var(--web-gray_20, rgba(128, 128, 128, 0.25));\n font-size: var(--fqa-body-size, 1.6rem);\n}\n\n.muye-reader-content-16 .fqa-footnote { font-size: var(--fqa-body-size, 1.6rem); }\n.muye-reader-content-20 .fqa-footnote { font-size: var(--fqa-body-size, 2rem); }\n.muye-reader-content-24 .fqa-footnote { font-size: var(--fqa-body-size, 2.4rem); }\n.muye-reader-content-28 .fqa-footnote { font-size: var(--fqa-body-size, 2.8rem); }\n.muye-reader-content-32 .fqa-footnote { font-size: var(--fqa-body-size, 3.2rem); }\n\n.fqa-footnote-title {\n margin-bottom: 0.6em;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--web-gray_40, #8a8a8a);\n text-indent: 0;\n}\n\n.fqa-footnote-list {\n margin: 0;\n padding-left: 1.6em;\n font-size: 0.85em;\n line-height: 1.7;\n color: var(--web-gray_40, #8a8a8a);\n}\n\n.fqa-footnote-list li {\n margin-bottom: 0.5em;\n text-indent: 0;\n transition: background-color 0.3s ease;\n}\n\n.fqa-footnote-list li.fqa-footnote-active {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n border-radius: 4px;\n}\n\n.muye-reader-content > body {\n background-color: var(--web-bg)!important;\n}\n\n.fqa-icon-dark {\n color: #B3B3B3\n}\n\n/* ----------------------------- 右键菜单 / Toast ----------------------------- */\n\n/*\n * 书架与搜索共用。两者都把菜单 Teleport 到 body,\n * 拿不到各自根节点上的变量,所以在这里声明一份全局色板。\n */\n.fqa-menu {\n --fqa-menu-bg: #fff;\n --fqa-menu-text: #1f2329;\n --fqa-menu-sub: #8f959e;\n --fqa-menu-hover: rgba(31, 35, 41, 0.06);\n --fqa-menu-danger: #f5222d;\n\n position: fixed;\n z-index: 2147483001;\n min-width: 132px;\n max-width: 240px;\n padding: 4px;\n box-sizing: border-box;\n background: var(--fqa-menu-bg);\n border: 1px solid rgba(31, 35, 41, 0.08);\n border-radius: 8px;\n box-shadow: 0 6px 24px rgba(31, 35, 41, 0.16);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n font-size: 13px;\n color: var(--fqa-menu-text);\n user-select: none;\n}\n\n/* 二级面板:分组数量多时可滚动 */\n.fqa-menu-sub {\n max-height: 320px;\n overflow-y: auto;\n}\n\n.fqa-menu-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 7px 10px;\n border-radius: 5px;\n line-height: 1.4;\n cursor: pointer;\n white-space: nowrap;\n overflow: hidden;\n}\n\n.fqa-menu-row > span:first-child {\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.fqa-menu-row:hover,\n.fqa-menu-row.fqa-menu-open {\n background: var(--fqa-menu-hover);\n}\n\n.fqa-menu-arrow {\n color: var(--fqa-menu-sub);\n font-size: 15px;\n line-height: 1;\n}\n\n.fqa-menu-danger {\n color: var(--fqa-menu-danger);\n}\n\n.fqa-menu-disabled {\n color: var(--fqa-menu-sub);\n cursor: not-allowed;\n}\n\n.fqa-menu-disabled:hover {\n background: transparent;\n}\n\n/* 操作结果提示 */\n.fqa-toast {\n position: fixed;\n left: 50%;\n bottom: 48px;\n transform: translateX(-50%);\n z-index: 2147483002;\n max-width: 80vw;\n padding: 10px 18px;\n box-sizing: border-box;\n background: rgba(31, 35, 41, 0.88);\n color: #fff;\n border-radius: 8px;\n font-size: 13px;\n line-height: 1.4;\n box-shadow: 0 6px 24px rgba(31, 35, 41, 0.24);\n pointer-events: none;\n}\n\n/* 骨架屏微光。书架与搜索共用同一个动画名 */\n@keyframes fqa-shimmer {\n 100% {\n transform: translateX(100%);\n }\n}\n\n@media (prefers-color-scheme: dark) {\n .fqa-menu {\n --fqa-menu-bg: #23272e;\n --fqa-menu-text: #e5e6eb;\n --fqa-menu-sub: #8f959e;\n --fqa-menu-hover: rgba(255, 255, 255, 0.08);\n border-color: rgba(255, 255, 255, 0.1);\n }\n}\n\n.info {\n width: 100%!important;\n}\n\n.reader-toolbar {\n user-select: none;\n}"; async function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -132,6 +136,8 @@ readerFont: "", customCssEnabled: false, customCss: "", + // 原站书架点封面是继续阅读,保持一致 + bookshelfClickAction: "read", enhanceSearch: true, // 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启 searchPersonalized: false, @@ -146,6 +152,8 @@ downloadVolumePage: false, downloadImages: true, downloadBookCss: true, + audiobookChapterEnd: "next", + audiobookFollow: true, apiPreference: "app", deviceId: "", installId: "", @@ -171,6 +179,12 @@ if (s.downloadCharset !== "utf-8" && s.downloadCharset !== "gbk") { s.downloadCharset = DEFAULT_SETTINGS.downloadCharset; } + if (s.bookshelfClickAction !== "read" && s.bookshelfClickAction !== "detail") { + s.bookshelfClickAction = DEFAULT_SETTINGS.bookshelfClickAction; + } + if (s.audiobookChapterEnd !== "next" && s.audiobookChapterEnd !== "stop") { + s.audiobookChapterEnd = DEFAULT_SETTINGS.audiobookChapterEnd; + } s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize); s.downloadInterval = clampInt(s.downloadInterval, 0, 1e4, DEFAULT_SETTINGS.downloadInterval); s.downloadRetries = clampInt(s.downloadRetries, 0, 10, DEFAULT_SETTINGS.downloadRetries); @@ -377,7 +391,7 @@ } ); } - const STYLE_ID$5 = "fqa-user-style"; + const STYLE_ID$6 = "fqa-user-style"; const READER_SCOPE = "#fqa-reader-content, .muye-reader-content"; function buildCss() { const parts = []; @@ -393,14 +407,14 @@ } function apply() { const css = buildCss(); - let el = document.getElementById(STYLE_ID$5); + let el = document.getElementById(STYLE_ID$6); if (!css) { el == null ? void 0 : el.remove(); return; } if (!el) { el = document.createElement("style"); - el.id = STYLE_ID$5; + el.id = STYLE_ID$6; document.head.appendChild(el); } el.textContent = css; @@ -412,6 +426,18 @@ apply ); } + class EmptyResponseError extends Error { + constructor(status) { + super(`服务端返回了空响应体(HTTP ${status})`); + __publicField(this, "status"); + this.name = "EmptyResponseError"; + this.status = status; + } + } + function isEmptyResponse(res) { + const text = res.responseText; + return typeof text !== "string" || text === ""; + } const supportedMethods = /* @__PURE__ */ new Set([ "GET", "HEAD", @@ -426,7 +452,7 @@ reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); return; } - const headers = normalizeHeaders(options.headers); + const headers = normalizeHeaders$1(options.headers); const data = normalizeBody(options.body); const method = options.method ?? (data ? "POST" : "GET"); if (!supportedMethods.has(method)) { @@ -449,6 +475,9 @@ cleanup(); resolve(Object.assign(response, { json() { + if (isEmptyResponse(this)) { + throw new EmptyResponseError(this.status); + } return JSON.parse(this.responseText); } })); @@ -505,7 +534,7 @@ }); }); } - function normalizeHeaders(headers) { + function normalizeHeaders$1(headers) { if (!headers) { return void 0; } @@ -697,7 +726,7 @@ message[paddedLength - 1 - i2] = Number(bitLength >> BigInt(i2 * 8) & 0xffn); } const dataView = new DataView(message.buffer); - const state = new Uint32Array([ + const state2 = new Uint32Array([ 1937774191, 1226093241, 388252375, @@ -720,14 +749,14 @@ for (let i2 = 0; i2 < 64; i2++) { expandedWords[i2] = words[i2] ^ words[i2 + 4]; } - let a = state[0]; - let b = state[1]; - let c = state[2]; - let d = state[3]; - let e = state[4]; - let f2 = state[5]; - let g2 = state[6]; - let h2 = state[7]; + let a = state2[0]; + let b = state2[1]; + let c = state2[2]; + let d = state2[3]; + let e = state2[4]; + let f2 = state2[5]; + let g2 = state2[6]; + let h2 = state2[7]; for (let i2 = 0; i2 < 64; i2++) { const t = i2 <= 15 ? 2043430169 : 2055708042; const ss1 = rotl(rotl(a, 12) + e + rotl(t, i2), 7); @@ -743,18 +772,18 @@ f2 = e; e = p0(tt2); } - state[0] = state[0] ^ a; - state[1] = state[1] ^ b; - state[2] = state[2] ^ c; - state[3] = state[3] ^ d; - state[4] = state[4] ^ e; - state[5] = state[5] ^ f2; - state[6] = state[6] ^ g2; - state[7] = state[7] ^ h2; + state2[0] = state2[0] ^ a; + state2[1] = state2[1] ^ b; + state2[2] = state2[2] ^ c; + state2[3] = state2[3] ^ d; + state2[4] = state2[4] ^ e; + state2[5] = state2[5] ^ f2; + state2[6] = state2[6] ^ g2; + state2[7] = state2[7] ^ h2; } const result = new Uint8Array(32); - for (let i2 = 0; i2 < state.length; i2++) { - const word = state[i2]; + for (let i2 = 0; i2 < state2.length; i2++) { + const word = state2[i2]; result[i2 * 4] = word >>> 24; result[i2 * 4 + 1] = word >>> 16; result[i2 * 4 + 2] = word >>> 8; @@ -831,7 +860,7 @@ } return result; } - function toBytes(input) { + function toBytes$1(input) { if (typeof input === "string") { return new TextEncoder().encode(input); } @@ -843,30 +872,30 @@ const hash = { sha256: async (input) => { const subtle = getSubtle(); - const digest = await subtle.digest("SHA-256", toBytes(input)); + const digest = await subtle.digest("SHA-256", toBytes$1(input)); return hex(digest); }, sha256bytes: async (input) => { const subtle = getSubtle(); - return subtle.digest("SHA-256", toBytes(input)); + return subtle.digest("SHA-256", toBytes$1(input)); }, sha512: async (input) => { const subtle = getSubtle(); - const digest = await subtle.digest("SHA-512", toBytes(input)); + const digest = await subtle.digest("SHA-512", toBytes$1(input)); return hex(digest); }, sha512bytes: async (input) => { const subtle = getSubtle(); - return subtle.digest("SHA-512", toBytes(input)); + return subtle.digest("SHA-512", toBytes$1(input)); }, md5: async (input) => md5( - typeof input === "string" ? input : toBytes(input).buffer + typeof input === "string" ? input : toBytes$1(input).buffer ), md5bytes: async (input) => unhex(md5( - typeof input === "string" ? input : toBytes(input).buffer + typeof input === "string" ? input : toBytes$1(input).buffer )), - sm3: async (input) => hex(sm3(toBytes(input)).buffer), - sm3bytes: async (input) => sm3(toBytes(input)).buffer + sm3: async (input) => hex(sm3(toBytes$1(input)).buffer), + sm3bytes: async (input) => sm3(toBytes$1(input)).buffer }; const WIRE_VARINT = 0; const WIRE_BYTES = 2; @@ -1149,94 +1178,6 @@ } return headers; } - const appBaseUrl = "https://reading.snssdk.com/reading"; - const redcandleBaseUrl = "https://api5-sinfonlinec.jxbhmy.com/reading"; - const webBaseUrl = "https://fanqienovel.com/reading"; - const appUserAgent = "com.dragon.read"; - function buildAppQuery(extra) { - const c = _config.currentConfig; - return new URLSearchParams({ - iid: c.install_id, - device_id: c.device_id, - ac: "wifi", - channel: "43536163a", - aid: "1967", - app_name: "novelapp", - version_code: "70132", - version_name: "7.0.1.32", - device_platform: "android", - os: "android", - ssmix: "a", - os_version: "10", - device_type: c.device_type || "P30", - device_brand: c.device_brand || "realme", - update_version_code: "70132", - manifest_version_code: "70132", - ...extra - }); - } - async function webGet(path, query, credentials2 = "omit") { - const url = `${webBaseUrl}${path}?${buildAppQuery(query).toString()}`; - const signed = await signRequest(url); - const res = await fetch$1(url, { headers: signed, credentials: credentials2 }); - if (!res.ok) { - throw new Error(`请求失败(${res.status})`); - } - return res.json(); - } - function isUsable(res) { - if (!res || res.status !== 200) return false; - try { - const j = res.json(); - return !j || j.code === void 0 || j.code === 0; - } catch { - return false; - } - } - async function requestApp(path, query, headers) { - const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}`; - const signed = await signRequest(url); - return apiFetch(url, { - method: "GET", - headers: { ...signed, "User-Agent": appUserAgent, ...headers } - }); - } - async function requestRedcandle(path, query, headers) { - const url = `${redcandleBaseUrl}${path}?${buildAppQuery(query).toString()}`; - return apiFetch(url, { - method: "GET", - headers: { "User-Agent": appUserAgent, ...headers } - }); - } - async function appGet(path, query, headers) { - if (settings$1.apiPreference === "redcandle") { - try { - const res = await requestRedcandle(path, query, headers); - if (isUsable(res)) return res; - console.warn(`[fqa:api] 红烛接口数据不全,回落到番茄 APP: ${path}`); - } catch (e) { - console.warn(`[fqa:api] 红烛接口请求失败,回落到番茄 APP: ${path}`, e); - } - } - return requestApp(path, query, headers); - } - async function appPost(path, body, query, headers) { - const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}`; - const signed = await signRequest(url, body); - console.log("---start--- APP POST ", url); - const res = await apiFetch(url, { - method: "POST", - headers: { - ...signed, - "User-Agent": appUserAgent, - "Content-Type": "application/json; charset=utf-8", - ...headers - }, - body - }); - console.log("---complete--- APP POST ", url, res); - return res; - } async function gzip(data) { if (typeof data === "string") { data = new TextEncoder().encode(data).buffer; @@ -1252,1014 +1193,6 @@ const decompressed = new Response(stream).arrayBuffer(); return decompressed; } - async function decryptChapter(encrypted, rawData, config = defaultConfig) { - var _a; - if (!encrypted) { - throw new Error("Invalid encrypted chapter"); - } - const buf = b64decode(encrypted); - const iv = buf.slice(0, 16); - const data = buf.slice(16); - const key = (_a = config.key_info) == null ? void 0 : _a.key; - if (!key) { - throw new Error("Missing decrypt key"); - } - const subtle = getSubtle(); - const cryptoKey = await subtle.importKey( - "raw", - key, - { name: "AES-CBC" }, - false, - ["decrypt"] - ); - return subtle.decrypt( - { name: "AES-CBC", iv }, - cryptoKey, - data - ).then(async (decrypted) => { - if (rawData && (rawData == null ? void 0 : rawData.compress_status) === 1) { - decrypted = await gunzip(decrypted); - } - const decoder = new TextDecoder(); - const plain = decoder.decode(decrypted); - if (plain.trim().startsWith("<")) { - return plain; - } - try { - return JSON.parse(plain); - } catch (e) { - console.warn("Invalid chapter content: ", plain, e); - return void 0; - } - }); - } - async function decryptComicImage(image, key) { - const subtle = getSubtle(); - const cryptoKey = await subtle.importKey( - "raw", - unhex(key), - { name: "AES-GCM" }, - false, - ["decrypt"] - ); - const iv = image.slice(0, 12); - const data = image.slice(12); - return await subtle.decrypt( - { name: "AES-GCM", iv }, - cryptoKey, - data - ); - } - function reverseHex(value) { - const be = BigInt(value).toString(16).padStart(32, "0"); - let result = ""; - for (let i2 = be.length; i2 > 0; i2 -= 2) result += be.slice(i2 - 2, i2); - return result; - } - async function encryptKeyinfoBody(config) { - const deviceId = config.device_id; - const iv = new TextEncoder().encode(randomString(16)); - const data = new Uint8Array(unhex(reverseHex(deviceId))).slice(0, 8); - console.log(data); - const subtle = getSubtle(); - const k = await subtle.importKey( - "raw", - shared_key, - { name: "AES-CBC" }, - false, - ["encrypt"] - ); - const encrypted = await subtle.encrypt( - { name: "AES-CBC", iv }, - k, - data - ); - const final = new Uint8Array(iv.length + encrypted.byteLength); - console.log(final); - final.set(iv, 0); - final.set(new Uint8Array(encrypted), iv.length); - return JSON.stringify({ - content: b64encode(final.buffer) - }); - } - async function decryptKeyinfoResponse(encrypted) { - const buf = b64decode(encrypted); - const iv = buf.slice(0, 16); - const data = buf.slice(16); - const subtle = getSubtle(); - const k = await subtle.importKey( - "raw", - shared_key, - { name: "AES-CBC" }, - false, - ["decrypt"] - ); - return subtle.decrypt( - { name: "AES-CBC", iv }, - k, - data - ); - } - async function refreshKeyinfo() { - var _a, _b, _c; - const b = await encryptKeyinfoBody(_config.currentConfig); - const res = await appPost("/crypt/registerkey", b); - const j = res.json(); - const ek = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.key; - if (!ek) { - throw new Error(`Failed to get key info: ${res.responseText}`); - } - const key = await decryptKeyinfoResponse(ek); - const keyinfo = { - key, - keyver: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.keyver - }; - console.log("Refreshed key info:", keyinfo); - _config.currentConfig.key_info = keyinfo; - write("keyinfo", { - key: b64encode(key), - keyver: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.keyver - }); - } - let refreshInflight = null; - function refreshKey() { - if (!refreshInflight) { - refreshInflight = refreshKeyinfo().finally(() => { - refreshInflight = null; - }); - } - return refreshInflight; - } - async function ensureKeyinfo(expectedKeyVersion) { - const keyinfo = _config.currentConfig.key_info; - const cachedKeyInfo = read("keyinfo"); - console.log("cached key info: ", cachedKeyInfo); - if (cachedKeyInfo) { - const cki = { - key: b64decode(cachedKeyInfo.key), - keyver: cachedKeyInfo.keyver - }; - if (typeof expectedKeyVersion === "undefined" || cki.keyver === expectedKeyVersion) { - _config.currentConfig.key_info = cki; - return; - } - } - if (!keyinfo) { - return await refreshKey(); - } - if ((keyinfo == null ? void 0 : keyinfo.keyver) !== expectedKeyVersion) { - return await refreshKey(); - } - } - async function getChapter(itemId, _retry) { - var _a, _b; - if (typeof _retry === "undefined") _retry = 0; - if (_retry > 5) { - throw new Error(`Failed to get chapter: ${itemId}`); - } - if (!_config.currentConfig.key_info) { - await ensureKeyinfo(); - } - const res = await appGet("/reader/full/v", { item_id: itemId, req_type: "1" }); - const j = (_a = res.json()) == null ? void 0 : _a.data; - if (!j) { - console.warn("Failed to get chapter: ", itemId, ", response: ", res.responseText); - return await getChapter(itemId, _retry + 1); - } - if ((j == null ? void 0 : j.content) === "Invalid" || (j == null ? void 0 : j.key_version) !== ((_b = _config.currentConfig.key_info) == null ? void 0 : _b.keyver)) { - console.warn("Key reg expired, regster again and retrying..."); - if ((j == null ? void 0 : j.content) === "Invalid") { - await refreshKey(); - } else { - await ensureKeyinfo(parseInt(j == null ? void 0 : j.key_version)); - } - return await getChapter(itemId, _retry + 1); - } - j.content = await decryptChapter(j == null ? void 0 : j.content, j, _config.currentConfig); - return j; - } - async function getChapters(itemIds, bookId = "0", _retry = 0) { - var _a, _b; - if (itemIds.length === 0) return {}; - if (!_config.currentConfig.key_info) { - await ensureKeyinfo(); - } - const res = await appGet("/reader/batch_full/v", { - item_ids: itemIds.join(","), - book_id: bookId, - novel_text_type: "1", - req_type: "1" - }); - const raw = (_a = res.json()) == null ? void 0 : _a.data; - const entries = raw && typeof raw === "object" ? Array.isArray(raw) ? raw.map((it) => { - var _a2; - return [String((it == null ? void 0 : it.item_id) ?? ((_a2 = it == null ? void 0 : it.novel_data) == null ? void 0 : _a2.item_id) ?? ""), it]; - }) : Object.entries(raw) : []; - if (entries.length === 0) { - throw new Error(`Failed to batch get chapters: ${res.responseText}`); - } - const localKeyver = (_b = _config.currentConfig.key_info) == null ? void 0 : _b.keyver; - const expired = entries.filter( - ([, item]) => (item == null ? void 0 : item.code) === 0 || (item == null ? void 0 : item.code) === void 0 ? (item == null ? void 0 : item.content) === "Invalid" || (item == null ? void 0 : item.key_version) !== void 0 && Number(item.key_version) !== localKeyver : false - ); - if (expired.length > 0 && _retry < 2) { - const [, sample] = expired[0]; - console.warn( - `[fqa:api] 批量正文密钥失效(${expired.length}/${entries.length} 章),重新注册后重试。本地 keyver=${localKeyver},服务端=${sample == null ? void 0 : sample.key_version}` - ); - await refreshKey(); - await sleep(800); - return await getChapters(itemIds, bookId, _retry + 1); - } - const results = {}; - for (const [id, item] of entries) { - if (!id) continue; - if ((item == null ? void 0 : item.code) !== void 0 && item.code !== 0) { - results[id] = { ...item, item_id: id, error: `code ${item.code}` }; - continue; - } - if (!(item == null ? void 0 : item.content) || item.content === "Invalid") { - results[id] = { ...item, item_id: id, error: "Invalid content" }; - continue; - } - try { - results[id] = { - ...item, - item_id: id, - novel_data: item.novel_data, - content: await decryptChapter(item.content, item, _config.currentConfig) - }; - } catch (e) { - results[id] = { ...item, item_id: id, error: String(e) }; - } - } - return results; - } - async function getCatalogRaw(bookId) { - var _a; - const response = await appGet("/bookapi/directory/all_items/v", { book_id: bookId }); - const j = response.json(); - const items = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.item_data_list; - if ((j == null ? void 0 : j.code) !== 0 || !Array.isArray(items) || items.length === 0) { - throw new Error("Empty catalog"); - } - return [items, items.map((it) => String(it.item_id))]; - } - async function webCatalog(bookId) { - const url = `https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId}`; - const response = await apiFetch(url); - const rj = response.json(); - const d = rj.data; - const allItems = d.allItemIds; - const volmap = {}; - const vname = d.volumeNameList; - for (let i2 = 0; i2 < vname.length; i2++) { - const volumeName = vname[i2]; - if (volumeName !== void 0) { - volmap[volumeName] = d.chapterListWithVolume[i2]; - } - } - return [volmap, allItems]; - } - async function getCatalog(bookId) { - const r = await getCatalogRaw(bookId); - let catalogRaw = r[0]; - let allItemIds = r[1]; - if (!catalogRaw || !allItemIds) { - const rw = await webCatalog(bookId); - catalogRaw = rw[0]; - allItemIds = rw[1]; - } - const vmap = {}; - const chapters = []; - catalogRaw.forEach((item) => { - const volumeName = item.volume_name ?? ""; - const chapterItem = { - item_id: String(item.item_id || item.itemId), - title: item.title, - // YYYY-MM-DD HH:mm:ss - update_time: moment((item.first_pass_time || item.firstPassTime) * 1e3).format("YYYY-MM-DD HH:mm:ss"), - char_count: item.chapter_word_number || 0, - volume_title: volumeName - }; - chapters.push(chapterItem); - if (!vmap[volumeName]) { - vmap[volumeName] = { - title: volumeName, - book_id: bookId, - chapter_list: [] - }; - } - vmap[volumeName].chapter_list.push(chapterItem); - }); - return { - book_id: bookId, - volume_list: Object.values(vmap), - chapter_list: chapters, - all_item_ids: allItemIds - }; - } - function mappingCreationStatus(status) { - switch (status) { - case "0": - return "完结"; - case "1": - return "连载"; - case "4": - return "断更"; - default: - return "未知"; - } - } - async function getBookInfoRaw(bookId) { - const response = await appGet("/bookapi/detail/v", { book_id: bookId }); - const j = response.json(); - console.log("Book Info:", j); - return j.data; - } - async function getBookInfo(bookId) { - const bookInfo = await getBookInfoRaw(bookId); - if (!bookInfo) { - throw new Error("Book not found"); - } - return { - book_id: bookInfo.book_id, - title: bookInfo.book_name || bookInfo.original_book_name, - author: bookInfo.author, - cover_url: bookInfo.thumb_url, - summary: bookInfo.abstract, - // volume_list: bookInfo.volume_list, - update_time: moment(bookInfo.last_chapter_first_pass_time * 1e3).format("YYYY-MM-DD HH:mm:ss"), - status: mappingCreationStatus(bookInfo.creation_status) - // chapter_count: bookInfo.chapter_count, - }; - } - async function getBookInfoAndCatalog(book) { - if (typeof book !== "string") { - book = book.book_id; - } - const bookInfo = await getBookInfo(book); - if (!bookInfo) { - throw new Error("Book not found"); - } - const catalog = await getCatalog(bookInfo.book_id); - console.log("Catalog:", catalog); - bookInfo.volume_list = catalog.volume_list; - bookInfo.chapter_list = catalog.chapter_list; - return bookInfo; - } - const CDN_PREFIX = "https://p3-novel.byteimg.com/origin/"; - const cssCache = /* @__PURE__ */ new Map(); - function stripComments(css) { - return css.replace(/\/\*[\s\S]*?\*\//g, ""); - } - function scopeSelector(selector, scope) { - const s = selector.trim(); - if (!s) return ""; - const where = `:where(${scope})`; - if (/^(body|html|:root)$/i.test(s)) return where; - const m = s.match(/^(body|html)\b([\s\S]*)$/i); - if (m) return `${where}${m[2]}`; - return `${where} ${s}`; - } - function stripRootDecls(body) { - return body.split(";").filter((decl) => !/^\s*(color|background|background-color|font-size)\s*:/i.test(decl)).join(";"); - } - function scopeCss(css, scope) { - const src = stripComments(css); - let out = ""; - let buf = ""; - let i2 = 0; - while (i2 < src.length) { - const ch = src[i2]; - if (ch === "{") { - const prelude = buf.trim(); - buf = ""; - i2++; - if (prelude.startsWith("@")) { - if (/^@(media|supports|document)\b/i.test(prelude)) { - const inner2 = readBlock(src, i2); - out += `${prelude}{${scopeCss(inner2.text, scope)}}`; - i2 = inner2.end; - } else { - const inner2 = readBlock(src, i2); - out += `${prelude}{${inner2.text}}`; - i2 = inner2.end; - } - continue; - } - const inner = readBlock(src, i2); - const selectors = prelude.split(",").map((s) => scopeSelector(s, scope)).filter(Boolean); - const isRoot = selectors.length === 1 && selectors[0] === `:where(${scope})`; - const declarations = isRoot ? stripRootDecls(inner.text) : inner.text; - if (selectors.length && declarations.trim()) { - out += `${selectors.join(",")}{${declarations}}`; - } - i2 = inner.end; - continue; - } - if (ch === ";" && buf.trim().startsWith("@")) { - buf = ""; - i2++; - continue; - } - buf += ch; - i2++; - } - return out; - } - function readBlock(src, start) { - let depth = 1; - let i2 = start; - while (i2 < src.length && depth > 0) { - const c = src[i2]; - if (c === "{") depth++; - else if (c === "}") depth--; - if (depth === 0) break; - i2++; - } - return { text: src.slice(start, i2), end: Math.min(i2 + 1, src.length) }; - } - function parseCssMap(cssMap) { - if (!cssMap || typeof cssMap !== "string") return {}; - try { - const parsed = JSON.parse(cssMap); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch { - return {}; - } - } - async function fetchCss(uri) { - const cached = cssCache.get(uri); - if (cached !== void 0) return cached; - try { - const res = await fetch(CDN_PREFIX + uri); - const text = res.ok ? await res.text() : ""; - cssCache.set(uri, text); - return text; - } catch (e) { - console.warn("获取书籍样式表失败:", uri, e); - cssCache.set(uri, ""); - return ""; - } - } - async function getScopedBookCss(cssMap, scope) { - const map = parseCssMap(cssMap); - const uris = Object.values(map).filter(Boolean); - if (uris.length === 0) return ""; - const sheets = await Promise.all(uris.map(fetchCss)); - return sheets.filter(Boolean).map((css) => scopeCss(css, scope)).join("\n"); - } - async function applyBookCss(cssMap, scope, styleId = "fqa-book-style") { - const css = await getScopedBookCss(cssMap, scope); - let el = document.getElementById(styleId); - if (!css) { - el == null ? void 0 : el.remove(); - return false; - } - if (!el) { - el = document.createElement("style"); - el.id = styleId; - document.head.appendChild(el); - } - el.textContent = css; - return true; - } - const FOOTNOTE_CLASS = "fqa-footnote"; - const FOOTNOTE_REF_CLASS = "fqa-footnote-ref"; - const FOOTNOTE_LIST_CLASS = "fqa-footnote-list"; - function byAttr(root, tag, attr, value) { - return [...root.querySelectorAll(tag)].filter((el) => { - const v = el.getAttribute(attr); - if (v === null) return false; - return value === void 0 || v === value; - }); - } - function processFootnotes(root) { - const notes = /* @__PURE__ */ new Map(); - const sections = byAttr(root, "section", "epub:type", "footnotes"); - for (const section of sections) { - for (const aside of section.querySelectorAll("aside")) { - const id = aside.getAttribute("id"); - if (id) notes.set(id, aside.innerHTML.trim()); - } - } - if (notes.size === 0) { - for (const aside of byAttr(root, "aside", "epub:type", "footnote")) { - const id = aside.getAttribute("id"); - if (id) notes.set(id, aside.innerHTML.trim()); - } - } - const collectRefs = (scope) => { - const set = new Set(byAttr(scope, "a", "epub:type", "noteref")); - for (const img of scope.querySelectorAll("img.bdFootnote")) { - const a = img.closest('a[href^="#"]'); - if (a && scope.contains(a)) set.add(a); - } - return [...scope.querySelectorAll("a")].filter((a) => set.has(a)); - }; - const inSection = (el) => sections.some((s) => s.contains(el)); - const refs = collectRefs(root).filter((a) => !inSection(a)); - if (refs.length === 0 && notes.size === 0) return 0; - const ordered = []; - const numberOf = /* @__PURE__ */ new Map(); - let counter = 0; - const makeSup = (num2, text) => { - const sup = document.createElement("sup"); - sup.className = FOOTNOTE_REF_CLASS; - sup.textContent = String(num2); - sup.setAttribute("role", "button"); - sup.setAttribute("tabindex", "0"); - sup.title = stripTags(text); - return sup; - }; - refs.forEach((ref2) => { - const href = ref2.getAttribute("href") ?? ""; - const id = href.startsWith("#") ? href.slice(1) : ""; - const text = notes.get(id); - if (text === void 0) return; - counter += 1; - numberOf.set(id, counter); - ordered.push({ num: counter, text }); - ref2.replaceWith(makeSup(counter, text)); - }); - for (const [id, text] of notes) { - if (numberOf.has(id)) continue; - counter += 1; - numberOf.set(id, counter); - ordered.push({ num: counter, text }); - } - for (const section of sections) section.remove(); - if (ordered.length > 0) { - const list = document.createElement("ol"); - list.className = FOOTNOTE_LIST_CLASS; - for (const { num: num2, text } of ordered) { - const li = document.createElement("li"); - li.id = `fqa-fn-${num2}`; - li.innerHTML = text; - for (const inner of collectRefs(li)) { - const innerId = (inner.getAttribute("href") ?? "").replace(/^#/, ""); - const innerNum = numberOf.get(innerId); - const innerText = notes.get(innerId); - if (innerNum && innerText !== void 0) { - inner.replaceWith(makeSup(innerNum, innerText)); - } else { - inner.remove(); - } - } - list.appendChild(li); - } - const wrapper = document.createElement("section"); - wrapper.className = FOOTNOTE_CLASS; - const heading = document.createElement("div"); - heading.className = "fqa-footnote-title"; - heading.textContent = "注释"; - wrapper.appendChild(heading); - wrapper.appendChild(list); - root.appendChild(wrapper); - } - for (const img of root.querySelectorAll("img.bdFootnote")) img.remove(); - return ordered.length; - } - function stripTags(html) { - const el = document.createElement("div"); - el.innerHTML = html; - return (el.textContent ?? "").replace(/\s+/g, " ").trim(); - } - const KNOWN_TIERS = /muye-reader-content-(16|20|24|28|32)\b/; - function syncFootnoteFontSize(container2) { - const box = container2.closest('[class*="muye-reader-content-"]'); - const apply2 = () => { - container2.style.removeProperty("--fqa-body-size"); - if (!box || KNOWN_TIERS.test(box.className)) return; - const p = container2.querySelector("p"); - if (!p) return; - const size = getComputedStyle(p).fontSize; - if (size) container2.style.setProperty("--fqa-body-size", size); - }; - apply2(); - if (!box) return; - const holder = container2; - if (holder.fqaFontObserver) return; - const observer2 = new MutationObserver(apply2); - observer2.observe(box, { attributes: true, attributeFilter: ["class"] }); - holder.fqaFontObserver = observer2; - } - function bindFootnoteInteraction(container2) { - syncFootnoteFontSize(container2); - if (container2.dataset.fqaFootnoteBound === "1") return; - container2.dataset.fqaFootnoteBound = "1"; - const activate = (sup) => { - var _a; - const num2 = (_a = sup.textContent) == null ? void 0 : _a.trim(); - if (!num2) return; - const target = container2.querySelector(`#fqa-fn-${num2}`); - if (!target) return; - target.scrollIntoView({ behavior: "smooth", block: "center" }); - target.classList.add("fqa-footnote-active"); - setTimeout(() => target.classList.remove("fqa-footnote-active"), 1600); - }; - container2.addEventListener("click", (e) => { - var _a, _b; - const sup = (_b = (_a = e.target) == null ? void 0 : _a.closest) == null ? void 0 : _b.call(_a, `.${FOOTNOTE_REF_CLASS}`); - if (sup) { - e.preventDefault(); - activate(sup); - } - }); - container2.addEventListener("keydown", (e) => { - var _a, _b; - const ke = e; - if (ke.key !== "Enter" && ke.key !== " ") return; - const sup = (_b = (_a = ke.target) == null ? void 0 : _a.closest) == null ? void 0 : _b.call(_a, `.${FOOTNOTE_REF_CLASS}`); - if (sup) { - ke.preventDefault(); - activate(sup); - } - }); - } - let currentBook = null; - let latestItemId = null; - const SCRIPT_CONTAINER_ID = "fqa-reader-content"; - let comicObserver = null; - function ensureScriptContainer(readerContainer, comic) { - let scriptContainer = document.getElementById(SCRIPT_CONTAINER_ID); - if (!scriptContainer) { - scriptContainer = cloneElement(readerContainer); - scriptContainer.id = SCRIPT_CONTAINER_ID; - scriptContainer.classList.add("fqa"); - readerContainer.insertAdjacentElement("beforebegin", scriptContainer); - } - scriptContainer.classList.toggle("fqa-comic-reader", comic); - if (settings$1.allowCopy) scriptContainer.classList.remove("noselect"); - comicObserver == null ? void 0 : comicObserver.disconnect(); - comicObserver = null; - scriptContainer.innerHTML = ""; - readerContainer.classList.add("fqa-hide"); - return scriptContainer; - } - async function insertContent() { - var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; - const itemId = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; - if (!itemId) { - console.warn("No item_id found in URL"); - return; - } - latestItemId = itemId; - const chapter = await getChapter(itemId); - if (!chapter) { - console.warn("No chapter found for item_id:", itemId); - return; - } - if (latestItemId !== itemId) { - console.debug("Stale chapter response discarded:", itemId); - return; - } - console.log("Chapter:", chapter); - const pageState = unsafeWindow.__INITIAL_STATE__; - const chapterTitle = ((_b = chapter.novel_data) == null ? void 0 : _b.title) || ((_d = (_c = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _c.chapterData) == null ? void 0 : _d.title); - if (typeof chapter.content === "string") { - void applyBookCss((_e = chapter.novel_data) == null ? void 0 : _e.css_map, "#fqa-reader-content"); - const dp = new DOMParser(); - const doc = dp.parseFromString(chapter.content, "text/html"); - const body = doc.body; - body.querySelectorAll('link[rel="stylesheet"]').forEach((el) => el.remove()); - let article = body.querySelector("article"); - let toProcess = article || body; - processFootnotes(toProcess); - for (let i2 = 0; i2 < toProcess.childNodes.length; i2++) { - if (i2 < 2 && ((_g = (_f = toProcess.childNodes[i2]) == null ? void 0 : _f.innerHTML) == null ? void 0 : _g.includes(chapterTitle))) { - toProcess.removeChild(toProcess.childNodes[i2]); - break; - } - } - if (!article) { - article = document.createElement("article"); - article.innerHTML = toProcess.innerHTML; - toProcess = article; - } - const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); - if (readerContainer) { - const scriptContainer = ensureScriptContainer(readerContainer, false); - scriptContainer.appendChild(toProcess); - bindFootnoteInteraction(scriptContainer); - } - } else if (chapter.content.picInfos) { - if (chapter.content.encrypt) { - const imgs = []; - for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { - const picInfo = chapter.content.picInfos[i2]; - const img = document.createElement("img"); - img.className = "fqa-comic-img fqa-comic-encrypted"; - img.alt = `第${i2 + 1}页`; - img.dataset.encryptedUrl = picInfo.picUrl; - img.dataset.encryptKey = chapter.content.encrypt_key; - img.dataset.pageIndex = i2.toString(); - img.style.minHeight = "500px"; - img.style.backgroundColor = "#f0f0f0"; - imgs.push(img); - } - const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); - if (readerContainer) { - const scriptContainer = ensureScriptContainer(readerContainer, true); - imgs.forEach((img) => scriptContainer.appendChild(img)); - const observer2 = new IntersectionObserver( - async (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - const img = entry.target; - if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) { - observer2.unobserve(img); - try { - const encryptedBuffer = await fetchArrayBuffer( - img.dataset.encryptedUrl - ); - const decryptedBuffer = await decryptComicImage( - encryptedBuffer, - img.dataset.encryptKey - ); - const blob = new Blob([decryptedBuffer], { type: "image/jpeg" }); - const blobUrl = URL.createObjectURL(blob); - img.src = blobUrl; - img.style.minHeight = ""; - img.style.backgroundColor = ""; - img.onload = () => { - URL.revokeObjectURL(blobUrl); - }; - } catch (error) { - console.error(`解密图片失败 (页 ${img.dataset.pageIndex}):`, error); - img.alt = `第${Number(img.dataset.pageIndex) + 1}页 - 解密失败`; - img.style.backgroundColor = "#ffebee"; - } - } - } - } - }, - { - rootMargin: "200px" - } - ); - comicObserver = observer2; - imgs.forEach((img) => observer2.observe(img)); - } - } else { - const imgs = []; - for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { - const picInfo = chapter.content.picInfos[i2]; - const img = document.createElement("img"); - img.className = "fqa-comic-img"; - img.alt = `第${i2 + 1}页`; - img.src = picInfo.picUrl; - imgs.push(img); - } - const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); - if (readerContainer) { - const scriptContainer = ensureScriptContainer(readerContainer, true); - imgs.forEach((img) => scriptContainer.appendChild(img)); - } - } - } - const muyeReaderTitle = document.querySelector("h1.muye-reader-title"); - let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle"); - (_h = document.querySelector("#fqa-subtitle")) == null ? void 0 : _h.remove(); - if (muyeReaderSubtitle) { - let _cloned = cloneElement(muyeReaderSubtitle); - muyeReaderSubtitle.classList.add("fqa-hide"); - _cloned.id = "fqa-subtitle"; - muyeReaderSubtitle.insertAdjacentElement("afterend", _cloned); - muyeReaderSubtitle = _cloned; - _cloned.classList.remove("fqa-hide"); - console.log("clone subtitle: ", _cloned); - } - if (muyeReaderTitle) { - muyeReaderTitle.textContent = chapterTitle; - } - console.log("Current book:", currentBook); - if (!currentBook || currentBook == null || currentBook.book_id !== ((_i = chapter.novel_data) == null ? void 0 : _i.book_id)) { - currentBook = await getBookInfoAndCatalog((_j = chapter.novel_data) == null ? void 0 : _j.book_id); - console.log("Current book:", currentBook); - } - if (currentBook && currentBook.chapter_list) { - const currentChapterItem = currentBook.chapter_list.find((c) => c.item_id === itemId); - if (currentChapterItem) { - console.log("Current chapter:", currentChapterItem); - document.title = currentChapterItem.title + " - " + currentBook.title + " - 番茄小说"; - if (document.getElementById("fqa-current-chapter-volume")) { - const c = document.getElementById("fqa-current-chapter-volume"); - if (c) { - c.textContent = currentChapterItem.volume_title; - } - } else { - const volSpan = document.createElement("span"); - volSpan.className = "desc-item"; - volSpan.id = "fqa-current-chapter-volume"; - volSpan.textContent = currentChapterItem.volume_title; - const c = muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.firstChild; - if (c) { - c.insertAdjacentElement("beforebegin", volSpan); - } - } - let updateTimeSpans = (muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.querySelectorAll("span.desc-item")) || []; - if (updateTimeSpans.length >= 2) { - let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1]; - let uttspan = updateTimeSpan.firstChild; - uttspan == null ? void 0 : uttspan.remove(); - updateTimeSpan.innerHTML = "更新时间:" + currentChapterItem.update_time; - } else { - let updateTimeSpan = document.createElement("span"); - updateTimeSpan.className = "desc-item"; - updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`; - } - } - } - } - async function onUrlChange$1(_previous) { - await insertContent(); - } - async function onHashChange$1(_previous) { - } - async function onLoad$1() { - document.querySelector("div.muye-reader-btns"); - await insertContent(); - } - function readerFilter(path, _query, _hash) { - return path.startsWith("/reader") || path.startsWith("reader"); - } - const _exports$5 = [ - { - id: "readerHook_load", - event: "load", - handler: onLoad$1, - filter: readerFilter - }, - { - id: "readerHook_urlChange", - event: "onUrlChange", - handler: onUrlChange$1, - filter: readerFilter - }, - { - id: "readerHook_hashChange", - event: "onHashChange", - handler: onHashChange$1, - filter: readerFilter - } - ]; - const blackList = [ - "mcs.zijieapi.com", - "vcs.zijieapi.com/vc/setting", - "mon.zijieapi.com", - "mssdk.bytedance.com/web/common", - "hm.baidu.com" - ]; - const BLOCKED_BODY = JSON.stringify({ - e: 0, - sc: 10, - tc: 10 - }); - function checkBlack(url) { - if (!settings$1.blockReport) return false; - return blackList.some((black) => url.includes(black)); - } - const originalFetch = unsafeWindow.fetch.bind(unsafeWindow); - unsafeWindow.fetch = function fetch2(input, init2) { - let url; - if (input instanceof Request) { - url = input.url; - } else if (input instanceof URL) { - url = input.href; - } else { - url = input; - } - if (checkBlack(url)) { - console.log("blocked request: " + url); - return Promise.resolve(new Response(BLOCKED_BODY, { - status: 200, - headers: { "Content-Type": "application/json" } - })); - } - return originalFetch(input, init2); - }; - const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest; - unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { - constructor() { - super(...arguments); - __publicField(this, "_blockedUrl"); - } - open(method, url, async = true, user, password) { - if (checkBlack(url)) { - console.log("blocked request: " + url); - this._blockedUrl = url; - return; - } - this._blockedUrl = void 0; - super.open(method, url, async, user, password); - } - setRequestHeader(name2, value) { - if (this._blockedUrl !== void 0) return; - super.setRequestHeader(name2, value); - } - send(body) { - if (this._blockedUrl === void 0) { - super.send(body); - return; - } - const url = this._blockedUrl; - const shadow = (prop, value) => Object.defineProperty(this, prop, { configurable: true, get: () => value }); - setTimeout(() => { - shadow("readyState", 4); - shadow("status", 200); - shadow("statusText", "OK"); - shadow("responseURL", url); - shadow("responseText", this.responseType === "" || this.responseType === "text" ? BLOCKED_BODY : ""); - shadow("response", this.responseType === "json" ? { - "e": 0, - "sc": 10, - "tc": 10 - } : BLOCKED_BODY); - this.dispatchEvent(new Event("readystatechange")); - this.dispatchEvent(new ProgressEvent("load")); - this.dispatchEvent(new ProgressEvent("loadend")); - }, 0); - } - abort() { - if (this._blockedUrl !== void 0) return; - super.abort(); - } - getAllResponseHeaders() { - if (this._blockedUrl !== void 0) return "content-type: application/json\r\n"; - return super.getAllResponseHeaders(); - } - getResponseHeader(name2) { - if (this._blockedUrl !== void 0) { - return name2.toLowerCase() === "content-type" ? "application/json" : null; - } - return super.getResponseHeader(name2); - } - }; - const _exports$4 = []; - let userState = { - isLogin: false, - userInfo: null - }; - if (read("userState")) { - userState = read("userState"); - } - console.log("userState:", userState); - async function getDetailedUserInfo() { - if (!(userState == null ? void 0 : userState.isLogin) || !(userState == null ? void 0 : userState.userInfo)) { - return null; - } - if (userState.userInfo.gender !== void 0 && userState.userInfo.recommend_gender !== void 0 && userState.userInfo.fans_num !== void 0 && userState.userInfo.following_num !== void 0 && userState.userInfo.is_author !== void 0 && userState.userInfo.author_desc !== void 0 && userState.userInfo.read_book_num !== void 0 && userState.userInfo.read_book_time !== void 0) { - return userState.userInfo; - } - const response = await fetch$1("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967"); - const j = await response.json(); - if (j == null ? void 0 : j.data) { - const data = j.data; - userState.userInfo.gender = data.profile_gender; - userState.userInfo.recommend_gender = data.gender; - userState.userInfo.fans_num = data.fans_num; - userState.userInfo.following_num = data.follow_user_num; - userState.userInfo.is_author = data.is_author; - userState.userInfo.author_desc = data.author_desc; - userState.userInfo.read_book_num = data.read_book_num; - userState.userInfo.read_book_time = BigInt(data.read_book_time); - return userState.userInfo; - } - write("userState", userState); - return userState.userInfo; - } - async function checkLogin() { - var _a, _b, _c, _d, _e, _f; - const response = await fetch$1("https://fanqienovel.com/api/user/info/v2"); - const j = await response.json(); - const _userInfo = { - id: (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.id, - username: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.name, - avatar: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.avatar, - desc: (_d = j == null ? void 0 : j.data) == null ? void 0 : _d.desc, - age: (_e = j == null ? void 0 : j.data) == null ? void 0 : _e.age - }; - if (((_f = j == null ? void 0 : j.data) == null ? void 0 : _f.id) > 1) { - userState.isLogin = true; - userState.userInfo = _userInfo; - write("userState", userState); - return true; - } else { - del("userState"); - return false; - } - } - async function init() { - var _a; - await checkLogin(); - if (userState.isLogin) { - console.log("Hello, ", (_a = userState == null ? void 0 : userState.userInfo) == null ? void 0 : _a.username); - } - } - const bookshelf = '\r\n \r\n \r\n \r\n \r\n \r\n'; - const settings = '\r\n\r\n \r\n \r\n'; const FIXED_STRING = b64decode( "TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA==" ); @@ -2580,16 +1513,22 @@ function load() { const s = read(STORE_KEY); if (!(s == null ? void 0 : s.device_id) || !(s == null ? void 0 : s.install_id)) return null; + if (s.key) { + if (!read("keyinfo")) { + write("keyinfo", { key: s.key, keyver: s.keyver }); + } + delete s.key; + delete s.keyver; + write(STORE_KEY, s); + } return { device_id: s.device_id, install_id: s.install_id, device_type: s.device_type, - device_brand: s.device_brand, - key_info: s.key ? { key: b64decode(s.key), keyver: s.keyver } : void 0 + device_brand: s.device_brand }; } function save(c, vipExpireTime) { - var _a; const s = { device_id: c.device_id, install_id: c.install_id, @@ -2597,10 +1536,6 @@ device_brand: c.device_brand, vip_expire_time: vipExpireTime }; - if ((_a = c.key_info) == null ? void 0 : _a.key) { - s.key = b64encode(c.key_info.key); - s.keyver = c.key_info.keyver; - } write(STORE_KEY, s); } async function provisionDevice() { @@ -2615,6 +1550,10 @@ }; _config.currentConfig = c; save(c, vipExpireTime); + write("keyinfo", { + key: b64encode(keyInfo.key), + keyver: keyInfo.keyver + }); return c; } async function ensureDevice() { @@ -2645,6 +1584,3787 @@ return defaultConfig; } } + let replaceInflight = null; + let unverified = false; + function markDeviceHealthy() { + unverified = false; + } + function replaceDevice() { + if (replaceInflight) return replaceInflight; + replaceInflight = doReplace().finally(() => { + replaceInflight = null; + }); + return replaceInflight; + } + async function doReplace() { + if (unverified) { + console.warn( + "[fqa:device] 刚换过设备仍然收到空响应,问题多半不在设备(检查网络代理是否拦了 reading.snssdk.com)" + ); + return null; + } + if (settings$1.deviceId.trim() && settings$1.installId.trim()) { + console.warn("[fqa:device] 当前是手填设备,不自动替换。若接口持续失败,请到设置里清空或改用自动注册"); + return null; + } + unverified = true; + console.warn("[fqa:device] 当前设备已被服务端作废,正在注册新设备…"); + try { + del("keyinfo"); + _config.currentConfig.key_info = void 0; + const c = await provisionDevice(); + console.log("[fqa:device] 已换到新设备:", c.device_id); + return c; + } catch (e) { + console.error("[fqa:device] 注册新设备失败:", e); + return null; + } + } + const appBaseUrl = "https://reading.snssdk.com/reading"; + const redcandleBaseUrl = "https://api5-sinfonlinec.jxbhmy.com/reading"; + const webBaseUrl = "https://fanqienovel.com/reading"; + const appUserAgent = "com.dragon.read"; + function buildAppQuery(extra) { + const c = _config.currentConfig; + return new URLSearchParams({ + iid: c.install_id, + device_id: c.device_id, + ac: "wifi", + channel: "43536163a", + aid: "1967", + app_name: "novelapp", + version_code: "70132", + version_name: "7.0.1.32", + device_platform: "android", + os: "android", + ssmix: "a", + os_version: "10", + device_type: c.device_type || "P30", + device_brand: c.device_brand || "realme", + update_version_code: "70132", + manifest_version_code: "70132", + pv_player: "70132", + ...extra + }); + } + async function webGet(path, query, credentials2 = "omit") { + const url = `${webBaseUrl}${path}?${buildAppQuery(query).toString()}`; + const signed = await signRequest(url); + const res = await fetch$1(url, { headers: signed, credentials: credentials2 }); + if (!res.ok) { + throw new Error(`请求失败(${res.status})`); + } + return res.json(); + } + function isUsable(res) { + if (!res || res.status !== 200) return false; + try { + const j = res.json(); + return !j || j.code === void 0 || j.code === 0; + } catch { + return false; + } + } + async function requestApp(path, query, headers) { + const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}`; + const signed = await signRequest(url); + return apiFetch(url, { + method: "GET", + headers: { ...signed, "User-Agent": appUserAgent, ...headers } + }); + } + async function requestRedcandle(path, query, headers) { + const url = `${redcandleBaseUrl}${path}?${buildAppQuery(query).toString()}`; + return apiFetch(url, { + method: "GET", + headers: { "User-Agent": appUserAgent, ...headers } + }); + } + async function requestAppWithRecovery(path, query, headers) { + const res = await requestApp(path, query, headers); + if (!isEmptyResponse(res)) { + markDeviceHealthy(); + return res; + } + console.warn(`[fqa:api] ${path} 返回空响应体,判定当前设备已失效`); + if (!await replaceDevice()) return res; + const retry = await requestApp(path, query, headers); + if (!isEmptyResponse(retry)) markDeviceHealthy(); + return retry; + } + async function appGet(path, query, headers) { + if (settings$1.apiPreference === "redcandle") { + try { + const res = await requestRedcandle(path, query, headers); + if (isUsable(res)) return res; + console.warn(`[fqa:api] 红烛接口数据不全,回落到番茄 APP: ${path}`); + } catch (e) { + console.warn(`[fqa:api] 红烛接口请求失败,回落到番茄 APP: ${path}`, e); + } + } + return requestAppWithRecovery(path, query, headers); + } + async function appPost(path, body, query, headers) { + return postSigned(appBaseUrl + path, body, query, headers); + } + async function postSigned(base, body, query, headers) { + const url = `${base}?${buildAppQuery(query).toString()}`; + const signed = await signRequest(url, body); + console.log("---start--- APP POST ", url); + const res = await apiFetch(url, { + method: "POST", + headers: { + ...signed, + "User-Agent": appUserAgent, + "Content-Type": "application/json; charset=utf-8", + ...headers + }, + body + }); + console.log("---complete--- APP POST ", url, res); + return res; + } + async function decryptChapter(encrypted, rawData, config = defaultConfig) { + var _a; + if (!encrypted) { + throw new Error("Invalid encrypted chapter"); + } + const buf = b64decode(encrypted); + const iv = buf.slice(0, 16); + const data = buf.slice(16); + const key = (_a = config.key_info) == null ? void 0 : _a.key; + if (!key) { + throw new Error("Missing decrypt key"); + } + const subtle = getSubtle(); + const cryptoKey = await subtle.importKey( + "raw", + key, + { name: "AES-CBC" }, + false, + ["decrypt"] + ); + return subtle.decrypt( + { name: "AES-CBC", iv }, + cryptoKey, + data + ).then(async (decrypted) => { + if (rawData && (rawData == null ? void 0 : rawData.compress_status) === 1) { + decrypted = await gunzip(decrypted); + } + const decoder = new TextDecoder(); + const plain = decoder.decode(decrypted); + if (plain.trim().startsWith("<")) { + return plain; + } + try { + return JSON.parse(plain); + } catch (e) { + console.warn("Invalid chapter content: ", plain, e); + return void 0; + } + }); + } + async function decryptComicImage(image, key) { + const subtle = getSubtle(); + const cryptoKey = await subtle.importKey( + "raw", + unhex(key), + { name: "AES-GCM" }, + false, + ["decrypt"] + ); + const iv = image.slice(0, 12); + const data = image.slice(12); + return await subtle.decrypt( + { name: "AES-GCM", iv }, + cryptoKey, + data + ); + } + function reverseHex(value) { + const be = BigInt(value).toString(16).padStart(32, "0"); + let result = ""; + for (let i2 = be.length; i2 > 0; i2 -= 2) result += be.slice(i2 - 2, i2); + return result; + } + async function encryptKeyinfoBody(config) { + const deviceId = config.device_id; + const iv = new TextEncoder().encode(randomString(16)); + const data = new Uint8Array(unhex(reverseHex(deviceId))).slice(0, 8); + console.log(data); + const subtle = getSubtle(); + const k = await subtle.importKey( + "raw", + shared_key, + { name: "AES-CBC" }, + false, + ["encrypt"] + ); + const encrypted = await subtle.encrypt( + { name: "AES-CBC", iv }, + k, + data + ); + const final = new Uint8Array(iv.length + encrypted.byteLength); + console.log(final); + final.set(iv, 0); + final.set(new Uint8Array(encrypted), iv.length); + return JSON.stringify({ + content: b64encode(final.buffer) + }); + } + async function decryptKeyinfoResponse(encrypted) { + const buf = b64decode(encrypted); + const iv = buf.slice(0, 16); + const data = buf.slice(16); + const subtle = getSubtle(); + const k = await subtle.importKey( + "raw", + shared_key, + { name: "AES-CBC" }, + false, + ["decrypt"] + ); + return subtle.decrypt( + { name: "AES-CBC", iv }, + k, + data + ); + } + async function refreshKeyinfo() { + var _a, _b, _c; + const b = await encryptKeyinfoBody(_config.currentConfig); + const res = await appPost("/crypt/registerkey", b); + const j = res.json(); + const ek = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.key; + if (!ek) { + throw new Error(`Failed to get key info: ${res.responseText}`); + } + const key = await decryptKeyinfoResponse(ek); + const keyinfo = { + key, + keyver: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.keyver + }; + console.log("Refreshed key info:", keyinfo); + _config.currentConfig.key_info = keyinfo; + write("keyinfo", { + key: b64encode(key), + keyver: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.keyver + }); + } + let refreshInflight = null; + function refreshKey() { + if (!refreshInflight) { + refreshInflight = refreshKeyinfo().finally(() => { + refreshInflight = null; + }); + } + return refreshInflight; + } + async function ensureKeyinfo(expectedKeyVersion) { + const keyinfo = _config.currentConfig.key_info; + const cachedKeyInfo = read("keyinfo"); + console.log("cached key info: ", cachedKeyInfo); + if (cachedKeyInfo) { + const cki = { + key: b64decode(cachedKeyInfo.key), + keyver: cachedKeyInfo.keyver + }; + if (typeof expectedKeyVersion === "undefined" || cki.keyver === expectedKeyVersion) { + _config.currentConfig.key_info = cki; + return; + } + } + if (!keyinfo) { + return await refreshKey(); + } + if ((keyinfo == null ? void 0 : keyinfo.keyver) !== expectedKeyVersion) { + return await refreshKey(); + } + } + async function getChapter(itemId2, _retry) { + var _a, _b; + if (typeof _retry === "undefined") _retry = 0; + if (_retry > 5) { + throw new Error(`Failed to get chapter: ${itemId2}`); + } + if (!_config.currentConfig.key_info) { + await ensureKeyinfo(); + } + const res = await appGet("/reader/full/v", { item_id: itemId2, req_type: "1" }); + const j = (_a = res.json()) == null ? void 0 : _a.data; + if (!j) { + console.warn("Failed to get chapter: ", itemId2, ", response: ", res.responseText); + return await getChapter(itemId2, _retry + 1); + } + if ((j == null ? void 0 : j.content) === "Invalid" || (j == null ? void 0 : j.key_version) !== ((_b = _config.currentConfig.key_info) == null ? void 0 : _b.keyver)) { + console.warn("Key reg expired, regster again and retrying..."); + if ((j == null ? void 0 : j.content) === "Invalid") { + await refreshKey(); + } else { + await ensureKeyinfo(parseInt(j == null ? void 0 : j.key_version)); + } + return await getChapter(itemId2, _retry + 1); + } + j.content = await decryptChapter(j == null ? void 0 : j.content, j, _config.currentConfig); + return j; + } + async function getChapters(itemIds, bookId2 = "0", _retry = 0) { + var _a, _b; + if (itemIds.length === 0) return {}; + if (!_config.currentConfig.key_info) { + await ensureKeyinfo(); + } + const res = await appGet("/reader/batch_full/v", { + item_ids: itemIds.join(","), + book_id: bookId2, + novel_text_type: "1", + req_type: "1" + }); + const raw = (_a = res.json()) == null ? void 0 : _a.data; + const entries = raw && typeof raw === "object" ? Array.isArray(raw) ? raw.map((it) => { + var _a2; + return [String((it == null ? void 0 : it.item_id) ?? ((_a2 = it == null ? void 0 : it.novel_data) == null ? void 0 : _a2.item_id) ?? ""), it]; + }) : Object.entries(raw) : []; + if (entries.length === 0) { + throw new Error(`Failed to batch get chapters: ${res.responseText}`); + } + const localKeyver = (_b = _config.currentConfig.key_info) == null ? void 0 : _b.keyver; + const expired = entries.filter( + ([, item]) => (item == null ? void 0 : item.code) === 0 || (item == null ? void 0 : item.code) === void 0 ? (item == null ? void 0 : item.content) === "Invalid" || (item == null ? void 0 : item.key_version) !== void 0 && Number(item.key_version) !== localKeyver : false + ); + if (expired.length > 0 && _retry < 2) { + const [, sample] = expired[0]; + console.warn( + `[fqa:api] 批量正文密钥失效(${expired.length}/${entries.length} 章),重新注册后重试。本地 keyver=${localKeyver},服务端=${sample == null ? void 0 : sample.key_version}` + ); + await refreshKey(); + await sleep(800); + return await getChapters(itemIds, bookId2, _retry + 1); + } + const results = {}; + for (const [id, item] of entries) { + if (!id) continue; + if ((item == null ? void 0 : item.code) !== void 0 && item.code !== 0) { + results[id] = { ...item, item_id: id, error: `code ${item.code}` }; + continue; + } + if (!(item == null ? void 0 : item.content) || item.content === "Invalid") { + results[id] = { ...item, item_id: id, error: "Invalid content" }; + continue; + } + try { + results[id] = { + ...item, + item_id: id, + novel_data: item.novel_data, + content: await decryptChapter(item.content, item, _config.currentConfig) + }; + } catch (e) { + results[id] = { ...item, item_id: id, error: String(e) }; + } + } + return results; + } + async function getCatalogRaw(bookId2) { + var _a; + const response = await appGet("/bookapi/directory/all_items/v", { book_id: bookId2 }); + const j = response.json(); + const items = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.item_data_list; + if ((j == null ? void 0 : j.code) !== 0 || !Array.isArray(items) || items.length === 0) { + throw new Error("Empty catalog"); + } + return [items, items.map((it) => String(it.item_id))]; + } + async function webCatalog(bookId2) { + const url = `https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId2}`; + const response = await apiFetch(url); + const rj = response.json(); + const d = rj.data; + const allItems = d.allItemIds; + const volmap = {}; + const vname = d.volumeNameList; + for (let i2 = 0; i2 < vname.length; i2++) { + const volumeName = vname[i2]; + if (volumeName !== void 0) { + volmap[volumeName] = d.chapterListWithVolume[i2]; + } + } + return [volmap, allItems]; + } + async function getCatalog(bookId2) { + const r = await getCatalogRaw(bookId2); + let catalogRaw = r[0]; + let allItemIds = r[1]; + if (!catalogRaw || !allItemIds) { + const rw = await webCatalog(bookId2); + catalogRaw = rw[0]; + allItemIds = rw[1]; + } + const vmap = {}; + const chapters = []; + catalogRaw.forEach((item) => { + const volumeName = item.volume_name ?? ""; + const chapterItem = { + item_id: String(item.item_id || item.itemId), + title: item.title, + // YYYY-MM-DD HH:mm:ss + update_time: moment((item.first_pass_time || item.firstPassTime) * 1e3).format("YYYY-MM-DD HH:mm:ss"), + char_count: item.chapter_word_number || 0, + volume_title: volumeName + }; + chapters.push(chapterItem); + if (!vmap[volumeName]) { + vmap[volumeName] = { + title: volumeName, + book_id: bookId2, + chapter_list: [] + }; + } + vmap[volumeName].chapter_list.push(chapterItem); + }); + return { + book_id: bookId2, + volume_list: Object.values(vmap), + chapter_list: chapters, + all_item_ids: allItemIds + }; + } + function mappingCreationStatus(status) { + switch (status) { + case "0": + return "完结"; + case "1": + return "连载"; + case "4": + return "断更"; + default: + return "未知"; + } + } + async function getBookInfoRaw(bookId2) { + const response = await appGet("/bookapi/detail/v", { book_id: bookId2 }); + const j = response.json(); + console.log("Book Info:", j); + return j.data; + } + async function getBookInfo(bookId2) { + const bookInfo = await getBookInfoRaw(bookId2); + if (!bookInfo) { + throw new Error("Book not found"); + } + return { + book_id: bookInfo.book_id, + title: bookInfo.book_name || bookInfo.original_book_name, + author: bookInfo.author, + cover_url: bookInfo.thumb_url, + summary: bookInfo.abstract, + // volume_list: bookInfo.volume_list, + update_time: moment(bookInfo.last_chapter_first_pass_time * 1e3).format("YYYY-MM-DD HH:mm:ss"), + status: mappingCreationStatus(bookInfo.creation_status) + // chapter_count: bookInfo.chapter_count, + }; + } + async function getBookInfoAndCatalog(book) { + if (typeof book !== "string") { + book = book.book_id; + } + const bookInfo = await getBookInfo(book); + if (!bookInfo) { + throw new Error("Book not found"); + } + const catalog = await getCatalog(bookInfo.book_id); + console.log("Catalog:", catalog); + bookInfo.volume_list = catalog.volume_list; + bookInfo.chapter_list = catalog.chapter_list; + return bookInfo; + } + const CDN_PREFIX = "https://p3-novel.byteimg.com/origin/"; + const cssCache = /* @__PURE__ */ new Map(); + function stripComments(css) { + return css.replace(/\/\*[\s\S]*?\*\//g, ""); + } + function scopeSelector(selector, scope) { + const s = selector.trim(); + if (!s) return ""; + const where = `:where(${scope})`; + if (/^(body|html|:root)$/i.test(s)) return where; + const m = s.match(/^(body|html)\b([\s\S]*)$/i); + if (m) return `${where}${m[2]}`; + return `${where} ${s}`; + } + function stripRootDecls(body) { + return body.split(";").filter((decl) => !/^\s*(color|background|background-color|font-size)\s*:/i.test(decl)).join(";"); + } + function scopeCss(css, scope) { + const src = stripComments(css); + let out = ""; + let buf = ""; + let i2 = 0; + while (i2 < src.length) { + const ch = src[i2]; + if (ch === "{") { + const prelude = buf.trim(); + buf = ""; + i2++; + if (prelude.startsWith("@")) { + if (/^@(media|supports|document)\b/i.test(prelude)) { + const inner2 = readBlock(src, i2); + out += `${prelude}{${scopeCss(inner2.text, scope)}}`; + i2 = inner2.end; + } else { + const inner2 = readBlock(src, i2); + out += `${prelude}{${inner2.text}}`; + i2 = inner2.end; + } + continue; + } + const inner = readBlock(src, i2); + const selectors = prelude.split(",").map((s) => scopeSelector(s, scope)).filter(Boolean); + const isRoot = selectors.length === 1 && selectors[0] === `:where(${scope})`; + const declarations = isRoot ? stripRootDecls(inner.text) : inner.text; + if (selectors.length && declarations.trim()) { + out += `${selectors.join(",")}{${declarations}}`; + } + i2 = inner.end; + continue; + } + if (ch === ";" && buf.trim().startsWith("@")) { + buf = ""; + i2++; + continue; + } + buf += ch; + i2++; + } + return out; + } + function readBlock(src, start) { + let depth = 1; + let i2 = start; + while (i2 < src.length && depth > 0) { + const c = src[i2]; + if (c === "{") depth++; + else if (c === "}") depth--; + if (depth === 0) break; + i2++; + } + return { text: src.slice(start, i2), end: Math.min(i2 + 1, src.length) }; + } + function parseCssMap(cssMap) { + if (!cssMap || typeof cssMap !== "string") return {}; + try { + const parsed = JSON.parse(cssMap); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } + } + async function fetchCss(uri) { + const cached = cssCache.get(uri); + if (cached !== void 0) return cached; + try { + const res = await fetch(CDN_PREFIX + uri); + const text = res.ok ? await res.text() : ""; + cssCache.set(uri, text); + return text; + } catch (e) { + console.warn("获取书籍样式表失败:", uri, e); + cssCache.set(uri, ""); + return ""; + } + } + async function getScopedBookCss(cssMap, scope) { + const map = parseCssMap(cssMap); + const uris = Object.values(map).filter(Boolean); + if (uris.length === 0) return ""; + const sheets = await Promise.all(uris.map(fetchCss)); + return sheets.filter(Boolean).map((css) => scopeCss(css, scope)).join("\n"); + } + async function applyBookCss(cssMap, scope, styleId = "fqa-book-style") { + const css = await getScopedBookCss(cssMap, scope); + let el = document.getElementById(styleId); + if (!css) { + el == null ? void 0 : el.remove(); + return false; + } + if (!el) { + el = document.createElement("style"); + el.id = styleId; + document.head.appendChild(el); + } + el.textContent = css; + return true; + } + const FOOTNOTE_CLASS = "fqa-footnote"; + const FOOTNOTE_REF_CLASS = "fqa-footnote-ref"; + const FOOTNOTE_LIST_CLASS = "fqa-footnote-list"; + function byAttr(root, tag, attr, value) { + return [...root.querySelectorAll(tag)].filter((el) => { + const v = el.getAttribute(attr); + if (v === null) return false; + return value === void 0 || v === value; + }); + } + function processFootnotes(root) { + const notes = /* @__PURE__ */ new Map(); + const sections = byAttr(root, "section", "epub:type", "footnotes"); + for (const section of sections) { + for (const aside of section.querySelectorAll("aside")) { + const id = aside.getAttribute("id"); + if (id) notes.set(id, aside.innerHTML.trim()); + } + } + if (notes.size === 0) { + for (const aside of byAttr(root, "aside", "epub:type", "footnote")) { + const id = aside.getAttribute("id"); + if (id) notes.set(id, aside.innerHTML.trim()); + } + } + const collectRefs = (scope) => { + const set = new Set(byAttr(scope, "a", "epub:type", "noteref")); + for (const img of scope.querySelectorAll("img.bdFootnote")) { + const a = img.closest('a[href^="#"]'); + if (a && scope.contains(a)) set.add(a); + } + return [...scope.querySelectorAll("a")].filter((a) => set.has(a)); + }; + const inSection = (el) => sections.some((s) => s.contains(el)); + const refs = collectRefs(root).filter((a) => !inSection(a)); + if (refs.length === 0 && notes.size === 0) return 0; + const ordered = []; + const numberOf = /* @__PURE__ */ new Map(); + let counter = 0; + const makeSup = (num2, text) => { + const sup = document.createElement("sup"); + sup.className = FOOTNOTE_REF_CLASS; + sup.textContent = String(num2); + sup.setAttribute("role", "button"); + sup.setAttribute("tabindex", "0"); + sup.title = stripTags(text); + return sup; + }; + refs.forEach((ref2) => { + const href = ref2.getAttribute("href") ?? ""; + const id = href.startsWith("#") ? href.slice(1) : ""; + const text = notes.get(id); + if (text === void 0) return; + counter += 1; + numberOf.set(id, counter); + ordered.push({ num: counter, text }); + ref2.replaceWith(makeSup(counter, text)); + }); + for (const [id, text] of notes) { + if (numberOf.has(id)) continue; + counter += 1; + numberOf.set(id, counter); + ordered.push({ num: counter, text }); + } + for (const section of sections) section.remove(); + if (ordered.length > 0) { + const list = document.createElement("ol"); + list.className = FOOTNOTE_LIST_CLASS; + for (const { num: num2, text } of ordered) { + const li = document.createElement("li"); + li.id = `fqa-fn-${num2}`; + li.innerHTML = text; + for (const inner of collectRefs(li)) { + const innerId = (inner.getAttribute("href") ?? "").replace(/^#/, ""); + const innerNum = numberOf.get(innerId); + const innerText = notes.get(innerId); + if (innerNum && innerText !== void 0) { + inner.replaceWith(makeSup(innerNum, innerText)); + } else { + inner.remove(); + } + } + list.appendChild(li); + } + const wrapper = document.createElement("section"); + wrapper.className = FOOTNOTE_CLASS; + const heading = document.createElement("div"); + heading.className = "fqa-footnote-title"; + heading.textContent = "注释"; + wrapper.appendChild(heading); + wrapper.appendChild(list); + root.appendChild(wrapper); + } + for (const img of root.querySelectorAll("img.bdFootnote")) img.remove(); + return ordered.length; + } + function stripTags(html) { + const el = document.createElement("div"); + el.innerHTML = html; + return (el.textContent ?? "").replace(/\s+/g, " ").trim(); + } + const KNOWN_TIERS = /muye-reader-content-(16|20|24|28|32)\b/; + function syncFootnoteFontSize(container2) { + const box = container2.closest('[class*="muye-reader-content-"]'); + const apply2 = () => { + container2.style.removeProperty("--fqa-body-size"); + if (!box || KNOWN_TIERS.test(box.className)) return; + const p = container2.querySelector("p"); + if (!p) return; + const size = getComputedStyle(p).fontSize; + if (size) container2.style.setProperty("--fqa-body-size", size); + }; + apply2(); + if (!box) return; + const holder = container2; + if (holder.fqaFontObserver) return; + const observer2 = new MutationObserver(apply2); + observer2.observe(box, { attributes: true, attributeFilter: ["class"] }); + holder.fqaFontObserver = observer2; + } + function bindFootnoteInteraction(container2) { + syncFootnoteFontSize(container2); + if (container2.dataset.fqaFootnoteBound === "1") return; + container2.dataset.fqaFootnoteBound = "1"; + const activate = (sup) => { + var _a; + const num2 = (_a = sup.textContent) == null ? void 0 : _a.trim(); + if (!num2) return; + const target = container2.querySelector(`#fqa-fn-${num2}`); + if (!target) return; + target.scrollIntoView({ behavior: "smooth", block: "center" }); + target.classList.add("fqa-footnote-active"); + setTimeout(() => target.classList.remove("fqa-footnote-active"), 1600); + }; + container2.addEventListener("click", (e) => { + var _a, _b; + const sup = (_b = (_a = e.target) == null ? void 0 : _a.closest) == null ? void 0 : _b.call(_a, `.${FOOTNOTE_REF_CLASS}`); + if (sup) { + e.preventDefault(); + activate(sup); + } + }); + container2.addEventListener("keydown", (e) => { + var _a, _b; + const ke = e; + if (ke.key !== "Enter" && ke.key !== " ") return; + const sup = (_b = (_a = ke.target) == null ? void 0 : _a.closest) == null ? void 0 : _b.call(_a, `.${FOOTNOTE_REF_CLASS}`); + if (sup) { + ke.preventDefault(); + activate(sup); + } + }); + } + const leftIcon = ''; + const playingIcon = '\n\n\n\n \n \n \n \n \n \n \n image/svg+xml\n \n \n \n \n \n \n \n \n \n\n'; + const pausedIcon = '\n \n \n \n \n\n'; + function decodeBase36(c) { + if (c >= 48 && c <= 57) return c - 48; + if (c >= 97 && c <= 122) return c - 97 + 10; + return 255; + } + function bitCount(n) { + n = n - (n >> 1 & 1431655765); + n = (n & 858993459) + (n >> 2 & 858993459); + return (n + (n >> 4) & 252645135) * 16843009 >> 24; + } + function decryptSpadeInner(spadeKey) { + const result = new Uint8Array(spadeKey); + const buff = new Uint8Array(2 + spadeKey.length); + buff.set([250, 85], 0); + buff.set(spadeKey, 2); + for (let i2 = 0; i2 < result.length; i2++) { + let v = (spadeKey[i2] ^ buff[i2]) - bitCount(i2) - 21; + while (v < 0) { + v += 255; + } + result[i2] = v; + } + return result; + } + function decryptSpade(spadeKeyBytes) { + const spadeKeyLen = spadeKeyBytes.length; + if (spadeKeyLen < 3) return ""; + const paddingLen = (spadeKeyBytes[0] ^ spadeKeyBytes[1] ^ spadeKeyBytes[2]) - 48; + if (spadeKeyLen < paddingLen + 2) return ""; + const innerInput = spadeKeyBytes.slice(1, spadeKeyLen - paddingLen); + const tmpBuff = decryptSpadeInner(innerInput); + if (tmpBuff.length === 0) return ""; + const skipBytes = decodeBase36(tmpBuff[0]); + const decodedMessageLen = spadeKeyLen - paddingLen - 2; + const endIndex = 1 + decodedMessageLen - skipBytes; + if (endIndex > tmpBuff.length) return ""; + const finalBytes = tmpBuff.slice(1, endIndex); + return new TextDecoder("utf-8").decode(finalBytes); + } + function decryptSpadeA(spadeAStr) { + try { + const bytes = new Uint8Array(b64decode(spadeAStr)); + return decryptSpade(bytes); + } catch (e) { + console.error("Spade parsing error", e); + return ""; + } + } + async function getPlayInfo(item_id, tone_id) { + const item_ids = Array.isArray(item_id) ? item_id.join(",") : item_id; + const resp = await appGet("/reader/audio/playinfo/", { + item_ids, + tone_id: tone_id.toString() + }); + const j = resp.json(); + const results = []; + if (Array.isArray(j.data)) { + for (let i2 of j.data) { + results.push({ + urls: [i2.main_url, i2.backup_url].filter(Boolean), + vid: i2.vid, + key: i2.is_encrypt ? decryptSpadeA(i2.encryption_key) : "", + item_id: i2.item_id + }); + } + } + return results; + } + async function getBookAvailableTones(book_id) { + var _a; + const resp = await appGet("/bookapi/audio/toneinfo/", { book_id }); + const j = resp.json(); + const results = []; + if (Array.isArray((_a = j == null ? void 0 : j.data) == null ? void 0 : _a.tts_tones)) { + for (let i2 of j.data.tts_tones) { + results.push({ + id: i2.id, + name: i2.title, + gender: i2.tone_gender, + icon: i2.icon_url, + description: i2.description + }); + } + } + return results; + } + async function getChapterParagraphTimeTag(item_id, tone_id) { + var _a; + const resp = await appGet("/reader/audio/timepoint/", { + item_id, + tone_id: tone_id.toString(), + req_type: "1" + }); + const j = resp.json(); + const results = []; + if (Array.isArray((_a = j == null ? void 0 : j.data) == null ? void 0 : _a.time_points)) { + for (let i2 of j.data.time_points) { + results.push({ + startms: i2.start_time, + endms: i2.end_time, + startidx: i2.start_para, + endidx: i2.end_para, + is_title: i2.start_para === 1e4 + }); + } + } + return results; + } + function asBytes(value) { + if (value instanceof Uint8Array) { + return new Uint8Array(value); + } + return new Uint8Array(value); + } + function concatBytes(...arrays) { + const length = arrays.reduce((sum, array) => sum + array.byteLength, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const array of arrays) { + result.set(array, offset); + offset += array.byteLength; + } + return result; + } + function ascii(text) { + const result = new Uint8Array(text.length); + for (let i2 = 0; i2 < text.length; i2++) { + result[i2] = text.charCodeAt(i2) & 255; + } + return result; + } + function hexToBytes(text) { + const hex2 = text.replace(/\s+/g, ""); + if (!/^[0-9a-fA-F]*$/.test(hex2) || hex2.length % 2 !== 0) { + throw new Error("无效的十六进制数据"); + } + const result = new Uint8Array(hex2.length / 2); + for (let i2 = 0; i2 < result.length; i2++) { + result[i2] = parseInt(hex2.slice(i2 * 2, i2 * 2 + 2), 16); + } + return result; + } + function uintBytes(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`无效的无符号整数: ${value}`); + } + if (value === 0) return new Uint8Array([0]); + const result = []; + let current = value; + while (current > 0) { + result.unshift(current & 255); + current = Math.floor(current / 256); + } + return new Uint8Array(result); + } + function be16(value) { + const result = new Uint8Array(2); + new DataView(result.buffer).setUint16(0, value); + return result; + } + function be32(value) { + const result = new Uint8Array(4); + new DataView(result.buffer).setUint32(0, value >>> 0); + return result; + } + function be64(value) { + const result = new Uint8Array(8); + new DataView(result.buffer).setBigUint64(0, BigInt(value)); + return result; + } + function findBytes(buffer, fourcc, from = 0, to = buffer.length - 4) { + if (fourcc.length !== 4) throw new Error(`fourcc 必须是 4 个字符: ${fourcc}`); + const c0 = fourcc.charCodeAt(0); + const c1 = fourcc.charCodeAt(1); + const c2 = fourcc.charCodeAt(2); + const c3 = fourcc.charCodeAt(3); + for (let i2 = from; i2 <= to; i2++) { + if (buffer[i2] === c0 && buffer[i2 + 1] === c1 && buffer[i2 + 2] === c2 && buffer[i2 + 3] === c3) return i2; + } + return -1; + } + function strBytes(text) { + return new TextEncoder().encode(text); + } + function readFourcc(buffer, offset) { + return String.fromCharCode( + buffer[offset] ?? 0, + buffer[offset + 1] ?? 0, + buffer[offset + 2] ?? 0, + buffer[offset + 3] ?? 0 + ); + } + const CONTAINERS = /* @__PURE__ */ new Set([ + "moov", + "trak", + "mdia", + "minf", + "stbl", + "edts", + "dinf", + "udta", + "mvex", + "meta" + ]); + function readBox(buffer, offset, limit) { + if (offset + 8 > limit) return null; + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const size32 = view.getUint32(offset); + const type = readFourcc(buffer, offset + 4); + let headerSize = 8; + let size; + if (size32 === 1) { + if (offset + 16 > limit) return null; + const extended = view.getBigUint64(offset + 8); + if (extended > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`MP4 box 过大: ${type}`); + } + size = Number(extended); + headerSize = 16; + } else if (size32 === 0) { + size = limit - offset; + } else { + size = size32; + } + if (size < headerSize || offset + size > limit) return null; + return { type, offset, size, headerSize, end: offset + size }; + } + function walkBoxes(buffer, start, end, visit, depth = 0) { + if (depth > 16) return; + let offset = start; + while (offset + 8 <= end) { + const box = readBox(buffer, offset, end); + if (!box) return; + visit(box); + if (CONTAINERS.has(box.type)) { + const childStart = box.offset + box.headerSize + (box.type === "meta" ? 4 : 0); + if (childStart < box.end) walkBoxes(buffer, childStart, box.end, visit, depth + 1); + } + offset = box.end; + } + } + function findBox(boxes, type) { + return boxes.find((box) => box.type === type); + } + function findMdatStart(buffer) { + let offset = 0; + while (offset + 8 <= buffer.length) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const size32 = view.getUint32(offset); + const type = readFourcc(buffer, offset + 4); + const headerSize = size32 === 1 ? 16 : 8; + if (type === "mdat") return offset + headerSize; + if (size32 === 0) return -1; + if (size32 === 1) { + if (offset + 16 > buffer.length) return -1; + const size = view.getBigUint64(offset + 8); + if (size > BigInt(Number.MAX_SAFE_INTEGER)) return -1; + offset += Number(size); + } else { + offset += size32; + } + } + return -1; + } + function parseSampleSizes(box, view) { + if (box.offset + 20 > box.end) throw new Error("stsz box 不完整"); + const sampleSize = view.getUint32(box.offset + 12); + const count = view.getUint32(box.offset + 16); + const sizes = new Uint32Array(count); + if (sampleSize !== 0) { + sizes.fill(sampleSize); + return sizes; + } + if (box.offset + 20 + count * 4 > box.end) throw new Error("stsz 采样表不完整"); + for (let i2 = 0; i2 < count; i2++) sizes[i2] = view.getUint32(box.offset + 20 + i2 * 4); + return sizes; + } + function parseDurations(box, sampleCount, view) { + if (box.offset + 16 > box.end) throw new Error("stts box 不完整"); + const entryCount = view.getUint32(box.offset + 12); + const durations = new Uint32Array(sampleCount); + let index = 0; + for (let i2 = 0; i2 < entryCount; i2++) { + const offset = box.offset + 16 + i2 * 8; + if (offset + 8 > box.end) throw new Error("stts 采样表不完整"); + const count = view.getUint32(offset); + const duration = view.getUint32(offset + 4); + for (let j = 0; j < count && index < sampleCount; j++) durations[index++] = duration; + } + if (index !== sampleCount) throw new Error(`stts 采样数不一致: ${index}/${sampleCount}`); + return durations; + } + function parseIvs(box, sampleCount, view) { + if (box.offset + 16 > box.end) throw new Error("senc box 不完整"); + const flags = view.getUint32(box.offset + 8) & 16777215; + if ((flags & 2) !== 0) throw new Error("暂不支持带 subsample encryption 的音频"); + const count = view.getUint32(box.offset + 12); + if (count !== sampleCount) throw new Error(`senc 采样数不一致: ${count}/${sampleCount}`); + const dataStart = box.offset + 16; + const dataLength = box.end - dataStart; + if (count === 0 || dataLength % count !== 0) throw new Error("senc IV 表不完整"); + const ivLength = dataLength / count; + if (ivLength !== 8 && ivLength !== 16) throw new Error(`不支持的 CENC IV 长度: ${ivLength}`); + return asBytes(new Uint8Array(view.buffer, view.byteOffset + dataStart, dataLength)); + } + function parseTimescale(box, view) { + if (box.offset + 24 > box.end) throw new Error("mdhd box 不完整"); + const version2 = view.getUint8(box.offset + 8); + const offset = version2 === 1 ? box.offset + 28 : box.offset + 20; + if (offset + 4 > box.end) throw new Error("mdhd timescale 缺失"); + const timescale = view.getUint32(offset); + if (timescale === 0) throw new Error("MP4 timescale 为 0"); + return timescale; + } + function parseCodec(buffer, mdatStart) { + const end = Math.max(0, Math.min(mdatStart - 1, buffer.length - 4)); + const dOps = findBytes(buffer, "dOps", 0, end); + if (dOps >= 0) { + const channels2 = buffer[dOps + 5] ?? 1; + const preSkip = (buffer[dOps + 6] ?? 0) << 8 | (buffer[dOps + 7] ?? 0); + return { codec: "opus", channels: channels2, preSkip }; + } + const encodedEntry = findBytes(buffer, "enca", 0, end); + const plainEntry = findBytes(buffer, "mp4a", 0, end); + const entryType = encodedEntry >= 0 ? encodedEntry : plainEntry; + const channels = entryType >= 0 && entryType + 22 <= buffer.length ? new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16(entryType + 20) : 1; + if (findBytes(buffer, "dfLa", 0, end) >= 0) { + return { codec: "flac", channels: channels || 1, preSkip: 0 }; + } + if (entryType >= 0) { + return { codec: "aac", channels: channels || 1, preSkip: 0 }; + } + throw new Error("未识别的音频编码(需要 Opus、AAC 或 FLAC)"); + } + function parseCencMetadata(head) { + const mdatStart = findMdatStart(head); + if (mdatStart < 0) throw new Error("未找到 mdat,MP4 头可能尚未拉完整"); + const boxes = []; + walkBoxes(head, 0, Math.min(mdatStart - 8, head.length), (box) => boxes.push(box)); + const stsz = findBox(boxes, "stsz"); + const stts = findBox(boxes, "stts"); + const senc = findBox(boxes, "senc"); + const mdhd = findBox(boxes, "mdhd"); + if (!stsz || !stts || !senc || !mdhd) { + throw new Error("CENC MP4 头缺少 stsz/stts/senc/mdhd"); + } + const view = new DataView(head.buffer, head.byteOffset, head.byteLength); + const sizes = parseSampleSizes(stsz, view); + const durations = parseDurations(stts, sizes.length, view); + const ivs = parseIvs(senc, sizes.length, view); + const timescale = parseTimescale(mdhd, view); + const codec = parseCodec(head, mdatStart); + const offsets = new Float64Array(sizes.length + 1); + for (let i2 = 0; i2 < sizes.length; i2++) { + offsets[i2 + 1] = offsets[i2] + sizes[i2]; + if (!Number.isSafeInteger(offsets[i2 + 1])) throw new Error("音频采样数据超过 JavaScript 安全整数范围"); + } + let totalTicks = 0; + for (const duration of durations) { + totalTicks += duration; + if (!Number.isSafeInteger(totalTicks)) throw new Error("音频总时长超过 JavaScript 安全整数范围"); + } + return { + ...codec, + sampleCount: sizes.length, + sizes, + ivs, + durations, + timescale, + mdatStart, + offsets, + totalTicks + }; + } + function cencIvSize(meta) { + return meta.ivs.length / meta.sampleCount; + } + function normalizeCencKey(value) { + if (typeof value === "string") { + const bytes2 = hexToBytes(value); + if (bytes2.length !== 16) throw new Error("AES-128 key 必须是 32 位 hex"); + return bytes2; + } + const bytes = asBytes(value); + if (bytes.length !== 16) throw new Error("AES-128 key 必须是 16 字节"); + return bytes.slice(); + } + async function importCencKey(value) { + return getSubtle().importKey( + "raw", + normalizeCencKey(value), + { name: "AES-CTR" }, + false, + ["decrypt"] + ); + } + function makeCencCounter(iv, counterStart) { + if (iv.length !== 8 && iv.length !== 16) throw new Error(`无效的 CENC IV 长度: ${iv.length}`); + const counter = new Uint8Array(16); + counter.set(iv); + if (counterStart === 1) counter[15] = 1; + return counter; + } + async function decryptCencSample(key, iv, ciphertext, counterStart) { + const data = asBytes(ciphertext); + const plain = await getSubtle().decrypt( + { name: "AES-CTR", counter: makeCencCounter(iv, counterStart), length: 64 }, + key, + data + ); + return new Uint8Array(plain); + } + function ebmlVint(value, length) { + if (!Number.isSafeInteger(value) || value < 0 || length < 1 || length > 8) { + throw new Error(`无效的 EBML VINT: ${value}`); + } + const result = new Uint8Array(length); + result[0] = 128 >> length - 1; + let current = value; + for (let i2 = length - 1; i2 > 0; i2--) { + result[i2] = current & 255; + current = Math.floor(current / 256); + } + const max = Math.pow(2, 7 * length); + if (current >= max) throw new Error(`EBML VINT 溢出: ${value}`); + result[0] |= current; + return result; + } + function ebmlSize(value) { + for (let length = 1; length <= 8; length++) { + if (value < Math.pow(2, 7 * length) - 1) return ebmlVint(value, length); + } + throw new Error(`EBML size 过大: ${value}`); + } + function ebmlElement(id, payload) { + return concatBytes(hexToBytes(id), ebmlSize(payload.length), payload); + } + function mp4Box(type, payload) { + return concatBytes(be32(8 + payload.length), ascii(type), payload); + } + function matrixIdentity() { + const matrix = new Uint8Array(36); + const view = new DataView(matrix.buffer); + view.setUint32(0, 65536); + view.setUint32(16, 65536); + view.setUint32(32, 1073741824); + return matrix; + } + function signedBe16(value) { + const result = new Uint8Array(2); + new DataView(result.buffer).setInt16(0, value); + return result; + } + function buildWebmInit(channels, preSkip, durationSeconds) { + const ebml = ebmlElement("1A45DFA3", concatBytes( + ebmlElement("4286", hexToBytes("01")), + ebmlElement("42F7", hexToBytes("01")), + ebmlElement("42F2", hexToBytes("04")), + ebmlElement("42F3", hexToBytes("08")), + ebmlElement("4282", strBytes("webm")), + ebmlElement("4287", hexToBytes("04")), + ebmlElement("4285", hexToBytes("02")) + )); + const codecDelayNs = Math.round(preSkip * 1e9 / 48e3); + const duration = new Uint8Array(8); + new DataView(duration.buffer).setFloat64(0, durationSeconds, false); + const info = ebmlElement("1549A966", concatBytes( + ebmlElement("2AD7B1", uintBytes(1e6)), + ebmlElement("4489", duration), + ebmlElement("4D80", strBytes("clearKeyStreamer")), + ebmlElement("5741", strBytes("clearKeyStreamer")) + )); + const sampleRate = new Uint8Array(4); + new DataView(sampleRate.buffer).setFloat32(0, 48e3, false); + const audio2 = ebmlElement("E1", concatBytes( + ebmlElement("B5", sampleRate), + ebmlElement("9F", uintBytes(channels)) + )); + const track = ebmlElement("AE", concatBytes( + ebmlElement("D7", hexToBytes("01")), + ebmlElement("73C5", uintBytes(1)), + ebmlElement("83", hexToBytes("02")), + ebmlElement("86", strBytes("A_OPUS")), + ebmlElement("63A2", buildOpusHead(channels, preSkip)), + ebmlElement("56AA", uintBytes(codecDelayNs)), + ebmlElement("56BB", uintBytes(8e7)), + audio2 + )); + const tracks = ebmlElement("1654AE6B", track); + return concatBytes(ebml, hexToBytes("18538067"), hexToBytes("01FFFFFFFFFFFFFF"), info, tracks); + } + function buildOpusHead(channels, preSkip) { + const head = new Uint8Array(19); + head.set([79, 112, 117, 115, 72, 101, 97, 100], 0); + const view = new DataView(head.buffer); + head[8] = 1; + head[9] = channels; + view.setUint16(10, preSkip, true); + view.setUint32(12, 48e3, true); + view.setInt16(16, 0, true); + head[18] = 0; + return head; + } + function buildWebmCluster(baseMs, relativeMs, packets) { + if (relativeMs.length !== packets.length) throw new Error("WebM 时间戳和采样数不一致"); + const blocks = []; + for (let i2 = 0; i2 < packets.length; i2++) { + const relative = relativeMs[i2]; + if (relative === void 0 || relative < -32768 || relative > 32767) { + throw new Error(`WebM 相对时间戳超出 Int16: ${relative}`); + } + const trackAndFlags = new Uint8Array([129, 128]); + blocks.push(ebmlElement("A3", concatBytes( + trackAndFlags.slice(0, 1), + signedBe16(relative), + trackAndFlags.slice(1), + packets[i2] + ))); + } + return ebmlElement("1F43B675", concatBytes( + ebmlElement("E7", uintBytes(baseMs)), + concatBytes(...blocks) + )); + } + function replaceFourcc(buffer, offset, type) { + buffer.set(ascii(type), offset); + } + function extractSampleEntry(head, mdatStart) { + const encoded = findBytes(head, "enca", 0, mdatStart - 1); + const plain = findBytes(head, "mp4a", 0, mdatStart - 1); + const typeOffset = encoded >= 0 ? encoded : plain; + if (typeOffset < 4) throw new Error("未找到 AAC 音频采样入口"); + const entryOffset = typeOffset - 4; + const view = new DataView(head.buffer, head.byteOffset, head.byteLength); + const size = view.getUint32(entryOffset); + if (size < 8 || entryOffset + size > head.length) throw new Error("AAC 采样入口不完整"); + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)); + const enca = findBytes(entry, "enca", 0); + if (enca >= 0) replaceFourcc(entry, enca, "mp4a"); + const sinf = findBytes(entry, "sinf", 8); + if (sinf >= 0) replaceFourcc(entry, sinf, "free"); + return entry; + } + function extractFlacEntry(head, mdatStart) { + const encoded = findBytes(head, "enca", 0, mdatStart - 1); + if (encoded < 4) throw new Error("未找到 FLAC 加密采样入口"); + const entryOffset = encoded - 4; + const view = new DataView(head.buffer, head.byteOffset, head.byteLength); + const size = view.getUint32(entryOffset); + if (size < 8 || entryOffset + size > head.length) throw new Error("FLAC 采样入口不完整"); + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)); + replaceFourcc(entry, encoded - entryOffset, "fLaC"); + const sinf = findBytes(entry, "sinf", 8); + if (sinf >= 0) replaceFourcc(entry, sinf, "free"); + return entry; + } + function buildMp4Init(sampleEntry, timescale, totalTicks) { + const totalSeconds = totalTicks / timescale; + const matrix = matrixIdentity(); + const mvhd = mp4Box("mvhd", concatBytes( + be32(0), + be32(0), + be32(0), + be32(1e3), + be32(Math.round(totalSeconds * 1e3)), + be32(65536), + be16(256), + be16(0), + new Uint8Array(8), + matrix, + new Uint8Array(24), + be32(2) + )); + const tkhd = mp4Box("tkhd", concatBytes( + be32(7), + be32(0), + be32(0), + be32(1), + be32(0), + be32(0), + new Uint8Array(8), + be16(0), + be16(0), + be16(256), + be16(0), + matrix, + be32(0), + be32(0) + )); + const mdhd = mp4Box("mdhd", concatBytes( + be32(0), + be32(0), + be32(0), + be32(timescale), + be32(totalTicks), + be16(21956), + be16(0) + )); + const hdlr = mp4Box("hdlr", concatBytes( + be32(0), + be32(0), + ascii("soun"), + new Uint8Array(12) + )); + const smhd = mp4Box("smhd", concatBytes(be32(0), be16(0), be16(0))); + const url = mp4Box("url ", be32(1)); + const dref = mp4Box("dref", concatBytes(be32(0), be32(1), url)); + const dinf = mp4Box("dinf", dref); + const stsd = mp4Box("stsd", concatBytes(be32(0), be32(1), sampleEntry)); + const stts = mp4Box("stts", concatBytes(be32(0), be32(0))); + const stsc = mp4Box("stsc", concatBytes(be32(0), be32(0))); + const stsz = mp4Box("stsz", concatBytes(be32(0), be32(0), be32(0))); + const stco = mp4Box("stco", concatBytes(be32(0), be32(0))); + const stbl = mp4Box("stbl", concatBytes(stsd, stts, stsc, stsz, stco)); + const minf = mp4Box("minf", concatBytes(smhd, dinf, stbl)); + const mdia = mp4Box("mdia", concatBytes(mdhd, hdlr, minf)); + const trak = mp4Box("trak", concatBytes(tkhd, mdia)); + const trex = mp4Box("trex", concatBytes(be32(0), be32(1), be32(1), be32(0), be32(0), be32(0))); + const mvex = mp4Box("mvex", trex); + const moov = mp4Box("moov", concatBytes(mvhd, trak, mvex)); + const ftyp = mp4Box("ftyp", concatBytes(ascii("isom"), be32(512), ascii("isomiso2mp41dash"))); + return concatBytes(ftyp, moov); + } + function buildMp4Segment(baseDecodeTick, samples, sequence) { + if (samples.length === 0) throw new Error("MP4 segment 不能没有采样"); + const sampleTable = samples.flatMap((sample) => [be32(sample.duration), be32(sample.data.length)]); + const mfhd = mp4Box("mfhd", concatBytes(be32(0), be32(sequence))); + const tfhd = mp4Box("tfhd", concatBytes(be32(131072), be32(1))); + const tfdt = mp4Box("tfdt", concatBytes(be32(16777216), be64(baseDecodeTick))); + const makeTrun = (offset) => mp4Box("trun", concatBytes( + be32(769), + be32(samples.length), + be32(offset), + concatBytes(...sampleTable) + )); + const makeMoof = (offset) => mp4Box("moof", concatBytes( + mfhd, + mp4Box("traf", concatBytes(tfhd, tfdt, makeTrun(offset))) + )); + const mediaPayload = concatBytes(...samples.map((sample) => sample.data)); + const dataOffset = makeMoof(0).length + 8; + return concatBytes(makeMoof(dataOffset), mp4Box("mdat", mediaPayload)); + } + function codecMime(codec) { + if (codec === "opus") return 'audio/webm;codecs="opus"'; + if (codec === "flac") return 'audio/mp4; codecs="fLaC"'; + return 'audio/mp4; codecs="mp4a.40.2"'; + } + function initSegment(meta, sampleEntry) { + if (meta.codec === "opus") { + const playableTicks = Math.max(0, meta.totalTicks - meta.preSkip); + return buildWebmInit(meta.channels, meta.preSkip, playableTicks / meta.timescale); + } + if (!sampleEntry) throw new Error("MP4 编码缺少 sample entry"); + return buildMp4Init(sampleEntry, meta.timescale, meta.totalTicks); + } + const DEFAULT_HEAD_BYTES = 512 * 1024; + const DEFAULT_MAX_HEAD_BYTES = 8 * 1024 * 1024; + const DEFAULT_SEGMENT_SECONDS = 15; + const MAX_WEBM_SEGMENT_SECONDS = 30; + const DEFAULT_MAX_BUFFER_AHEAD = 40; + const DEFAULT_KEEP_BEHIND = 10; + const DEFAULT_DECRYPT_CONCURRENCY = 24; + function asError(value) { + return value instanceof Error ? value : new Error(String(value)); + } + function abortError$1() { + return new DOMException("播放器会话已取消", "AbortError"); + } + function isAbort(value) { + return value instanceof DOMException && value.name === "AbortError"; + } + function waitForSourceBufferIdle(sourceBuffer, signal) { + if (!sourceBuffer.updating) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + sourceBuffer.removeEventListener("updateend", done); + sourceBuffer.removeEventListener("abort", done); + sourceBuffer.removeEventListener("error", done); + signal.removeEventListener("abort", done); + resolve(); + }; + sourceBuffer.addEventListener("updateend", done); + sourceBuffer.addEventListener("abort", done); + sourceBuffer.addEventListener("error", done); + signal.addEventListener("abort", done, { once: true }); + }); + } + function describeMediaError(audio2) { + const error = audio2.error; + if (!error) return ""; + const names = { + 1: "MEDIA_ERR_ABORTED", + 2: "MEDIA_ERR_NETWORK", + 3: "MEDIA_ERR_DECODE(数据不是合法音频,多半在传输中被破坏)", + 4: "MEDIA_ERR_SRC_NOT_SUPPORTED" + }; + const name2 = names[error.code] ?? `code ${error.code}`; + return `:${name2}${error.message ? ` - ${error.message}` : ""}`; + } + function validatePositive(value, fallback, name2) { + const result = value ?? fallback; + if (!Number.isFinite(result) || result <= 0) throw new Error(`${name2} 必须是正数`); + return result; + } + async function mapLimit(items, limit, worker) { + const results = new Array(items.length); + let next = 0; + async function consume() { + while (next < items.length) { + const index = next++; + results[index] = await worker(items[index]); + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => consume())); + return results; + } + class SequentialByteStream { + constructor(initial = new Uint8Array(), reader = null, openReader = null) { + __publicField(this, "chunks", []); + __publicField(this, "chunkOffset", 0); + __publicField(this, "available", 0); + __publicField(this, "reader"); + __publicField(this, "openReader"); + __publicField(this, "eof", false); + if (initial.length > 0) { + const copy = new Uint8Array(initial); + this.chunks.push(copy); + this.available = copy.length; + } + this.reader = reader; + this.openReader = openReader; + } + /** 至少缓存 length 字节;流先结束时返回 false。 */ + async bufferAtLeast(length) { + while (this.available < length && !this.eof) await this.pull(); + return this.available >= length; + } + /** 复制当前全部未消费数据,不移动读取位置。 */ + snapshot() { + const result = new Uint8Array(this.available); + let output = 0; + for (let i2 = 0; i2 < this.chunks.length; i2++) { + const chunk2 = this.chunks[i2]; + const start = i2 === 0 ? this.chunkOffset : 0; + result.set(chunk2.subarray(start), output); + output += chunk2.length - start; + } + return result; + } + async skip(length) { + await this.readExactly(length); + } + async readExactly(length) { + if (!Number.isSafeInteger(length) || length < 0) { + throw new Error(`无效的流读取长度: ${length}`); + } + if (length === 0) return new Uint8Array(); + if (!await this.bufferAtLeast(length)) { + throw new Error(`音频流提前结束(还需 ${length - this.available} 字节)`); + } + const result = new Uint8Array(length); + let output = 0; + while (output < length) { + const chunk2 = this.chunks[0]; + const take = Math.min(length - output, chunk2.length - this.chunkOffset); + result.set(chunk2.subarray(this.chunkOffset, this.chunkOffset + take), output); + output += take; + this.chunkOffset += take; + this.available -= take; + if (this.chunkOffset === chunk2.length) { + this.chunks.shift(); + this.chunkOffset = 0; + } + } + return result; + } + async cancel() { + var _a; + this.eof = true; + try { + await ((_a = this.reader) == null ? void 0 : _a.cancel()); + } catch { + } + this.reader = null; + } + async pull() { + if (!this.reader && this.openReader) this.reader = await this.openReader(); + if (!this.reader) { + this.eof = true; + return; + } + const { value, done } = await this.reader.read(); + if (done) { + this.eof = true; + return; + } + if (value == null ? void 0 : value.length) { + const copy = new Uint8Array(value); + this.chunks.push(copy); + this.available += copy.length; + } + } + } + class CencAudioPlayer { + constructor(audio2, options = {}) { + __publicField(this, "audio"); + __publicField(this, "options"); + __publicField(this, "state", "idle"); + __publicField(this, "session", 0); + __publicField(this, "streamToken", 0); + __publicField(this, "seekToken", 0); + __publicField(this, "sessionController", null); + __publicField(this, "streamController", null); + __publicField(this, "sourceBuffer", null); + __publicField(this, "mediaSource", null); + __publicField(this, "objectUrl", null); + __publicField(this, "sourceBufferChain", Promise.resolve()); + __publicField(this, "context", null); + __publicField(this, "info", null); + __publicField(this, "autoplay", false); + __publicField(this, "segmentSequence", 0); + __publicField(this, "onSeeking", () => { + const context = this.context; + if (!context || this.state === "destroyed" || this.session === 0) return; + if (this.audio.error) return; + const target = this.audio.currentTime; + if (!Number.isFinite(target) || this.isTimeBuffered(target)) return; + const wasPlaying = !this.audio.paused; + void this.restartAt(target, wasPlaying); + }); + __publicField(this, "onPlay", () => { + if (this.context && this.state !== "destroyed") this.emitState("playing"); + }); + __publicField(this, "onPause", () => { + if (this.context && this.state === "playing" && !this.audio.ended) this.emitState("paused"); + }); + __publicField(this, "onEnded", () => { + if (this.context && this.state !== "destroyed") this.emitState("ended"); + }); + this.audio = audio2; + const fetcher = (options.fetch ?? fetch$1).bind(unsafeWindow); + this.options = { + headBytes: validatePositive(options.headBytes, DEFAULT_HEAD_BYTES, "headBytes"), + maxHeadBytes: validatePositive(options.maxHeadBytes, DEFAULT_MAX_HEAD_BYTES, "maxHeadBytes"), + segmentSeconds: validatePositive(options.segmentSeconds, DEFAULT_SEGMENT_SECONDS, "segmentSeconds"), + maxBufferAheadSeconds: validatePositive(options.maxBufferAheadSeconds, DEFAULT_MAX_BUFFER_AHEAD, "maxBufferAheadSeconds"), + keepBehindSeconds: validatePositive(options.keepBehindSeconds, DEFAULT_KEEP_BEHIND, "keepBehindSeconds"), + decryptConcurrency: Math.max(1, Math.floor(validatePositive(options.decryptConcurrency, DEFAULT_DECRYPT_CONCURRENCY, "decryptConcurrency"))), + onStateChange: options.onStateChange, + onProgress: options.onProgress, + onMessage: options.onMessage, + // config.fetch 保存的是页面 fetch 的原始引用,避免被字节 SDK 改写。 + fetch: fetcher + }; + this.audio.addEventListener("seeking", this.onSeeking); + this.audio.addEventListener("play", this.onPlay); + this.audio.addEventListener("pause", this.onPause); + this.audio.addEventListener("ended", this.onEnded); + } + get currentState() { + return this.state; + } + get mediaInfo() { + return this.info; + } + get element() { + return this.audio; + } + /** 加载并开始后台缓冲;autoplay 只表示首次缓冲后尝试调用 audio.play。 */ + async load(source, autoplay = false) { + this.ensureAlive(); + const session = this.beginSession(); + this.autoplay = autoplay; + this.emitState("loading"); + let initialStream = null; + try { + const url = this.validateUrl(source.url); + this.streamController = new AbortController(); + initialStream = this.createNetworkStream(url, 0, this.streamController.signal); + let { head, meta } = await this.parseHeadFromStream(initialStream, session); + this.assertSession(session); + const key = await importCencKey(source.key); + this.assertSession(session); + let counterStart = 0; + if (meta.codec === "opus") { + const probeEnd = meta.mdatStart + meta.offsets[Math.min(6, meta.sampleCount)]; + if (head.length < probeEnd) { + await initialStream.bufferAtLeast(probeEnd); + head = initialStream.snapshot(); + } + counterStart = await this.detectCounterStart(meta, head, key); + } + this.assertSession(session); + const sampleEntry = meta.codec === "aac" ? extractSampleEntry(head, meta.mdatStart) : meta.codec === "flac" ? extractFlacEntry(head, meta.mdatStart) : void 0; + const mime = codecMime(meta.codec); + const ctor = this.mediaSourceConstructor(); + if (!ctor.isTypeSupported(mime)) throw new Error(`浏览器不支持 MSE 音频格式: ${mime}`); + const mediaSource = new ctor(); + this.mediaSource = mediaSource; + this.objectUrl = unsafeWindow.URL.createObjectURL(mediaSource); + const opened = this.waitForSourceOpen(mediaSource, session); + this.audio.src = this.objectUrl; + await opened; + this.assertSession(session); + this.sourceBuffer = mediaSource.addSourceBuffer(mime); + this.segmentSequence = 0; + const init2 = initSegment(meta, sampleEntry); + this.emitMessage(`追加 init 段 ${init2.byteLength} 字节 (${mime})`, "info"); + await this.enqueueSourceBuffer(session, (sourceBuffer) => { + sourceBuffer.appendBuffer(this.toPageBuffer(init2)); + }); + this.assertSession(session); + const ticks = new Float64Array(meta.sampleCount + 1); + for (let i2 = 0; i2 < meta.sampleCount; i2++) { + ticks[i2 + 1] = ticks[i2] + meta.durations[i2]; + } + const durationSeconds = meta.totalTicks / meta.timescale; + const samplesPerSecond = meta.sampleCount / durationSeconds; + const segmentSeconds = meta.codec === "opus" ? Math.min(this.options.segmentSeconds, MAX_WEBM_SEGMENT_SECONDS) : this.options.segmentSeconds; + this.context = { + url, + head, + meta, + key, + counterStart, + ticks, + samplesPerSecond, + samplesPerSegment: Math.max(1, Math.round(samplesPerSecond * segmentSeconds)) + }; + this.info = { + codec: meta.codec, + sampleCount: meta.sampleCount, + durationSeconds, + timescale: meta.timescale, + channels: meta.channels, + encryptedBytes: meta.offsets[meta.sampleCount] + }; + await initialStream.skip(meta.mdatStart); + this.assertSession(session); + this.emitState("ready"); + this.startStream(0, initialStream); + initialStream = null; + return this.info; + } catch (error) { + void (initialStream == null ? void 0 : initialStream.cancel()); + if (this.isCurrent(session) && !isAbort(error)) { + this.emitMessage(asError(error).message, "error"); + void this.reportAppendFailure(); + this.emitState("error"); + } + throw error; + } + } + /** load(..., true) 的便捷形式。 */ + start(source) { + return this.load(source, true); + } + play() { + this.ensureAlive(); + return this.audio.play().then(() => void 0); + } + pause() { + this.audio.pause(); + } + /** 停止当前流并释放 MSE URL,但保留播放器实例以便再次 load。 */ + stop() { + if (this.state === "destroyed") return; + this.beginSession(); + this.emitState("idle"); + } + destroy() { + if (this.state === "destroyed") return; + this.beginSession(); + this.audio.removeEventListener("seeking", this.onSeeking); + this.audio.removeEventListener("play", this.onPlay); + this.audio.removeEventListener("pause", this.onPause); + this.audio.removeEventListener("ended", this.onEnded); + this.emitState("destroyed"); + } + ensureAlive() { + if (this.state === "destroyed") throw new Error("播放器已销毁"); + } + beginSession() { + var _a, _b; + this.session++; + this.streamToken++; + this.seekToken++; + (_a = this.sessionController) == null ? void 0 : _a.abort(); + (_b = this.streamController) == null ? void 0 : _b.abort(); + this.sessionController = new AbortController(); + this.streamController = null; + this.context = null; + this.info = null; + this.autoplay = false; + this.sourceBuffer = null; + this.mediaSource = null; + this.sourceBufferChain = Promise.resolve(); + this.audio.pause(); + if (this.audio.src) { + this.audio.removeAttribute("src"); + this.audio.load(); + } + if (this.objectUrl) { + unsafeWindow.URL.revokeObjectURL(this.objectUrl); + this.objectUrl = null; + } + return this.session; + } + validateUrl(raw) { + const url = raw.trim(); + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("音频 URL 必须是 HTTP(S) 地址"); + } + return parsed.toString(); + } + mediaSourceConstructor() { + const win2 = unsafeWindow; + const ctor = win2.MediaSource ?? globalThis.MediaSource; + if (!ctor) throw new Error("当前浏览器没有 MediaSource 支持"); + return ctor; + } + /** + * 把字节搬到页面 realm,再交给 appendBuffer。 + * + * MediaSource 取自 unsafeWindow(页面 realm),而我们构造的 Uint8Array + * 属于用户脚本沙箱 realm。跨 realm 的 ArrayBuffer 传进 appendBuffer 会被拒, + * 表现是 SourceBuffer 立刻抛 error 事件、而 .error 仍是 null + * —— 只看报错完全看不出是 realm 问题。 + * + * 页面 realm 没暴露 Uint8Array 时(少见)就原样返回,交给浏览器自己判断。 + */ + toPageBuffer(bytes) { + const win2 = unsafeWindow; + const PageUint8Array = win2.Uint8Array; + if (!PageUint8Array || PageUint8Array === Uint8Array) return bytes; + const copy = new PageUint8Array(bytes.byteLength); + copy.set(bytes); + return copy; + } + async parseHeadFromStream(stream, session) { + let size = Math.min(this.options.headBytes, this.options.maxHeadBytes); + let lastError = null; + while (size <= this.options.maxHeadBytes) { + const complete = await stream.bufferAtLeast(size); + this.assertSession(session); + const head = stream.snapshot(); + try { + return { head, meta: parseCencMetadata(head) }; + } catch (error) { + lastError = error; + const message = asError(error).message; + const incomplete = /未找到|不完整|缺少/.test(message); + if (!incomplete || !complete || size === this.options.maxHeadBytes) throw error; + size = Math.min(this.options.maxHeadBytes, size * 2); + } + } + throw lastError instanceof Error ? lastError : new Error("无法解析音频 MP4 头"); + } + createNetworkStream(url, start, signal, initial = new Uint8Array()) { + return new SequentialByteStream( + initial, + null, + () => this.openAudioReader(url, start, signal) + ); + } + /** + * 打开一条从指定字节一直到文件末尾的流。 + * 所有 CDN 请求都明确禁止 Referer、Cookie;顺序播放期间只会打开一次。 + */ + async openAudioReader(url, start, signal) { + this.emitMessage(`打开音频流:bytes=${start}-`, "info"); + const response = await this.options.fetch(url, { + method: "GET", + headers: { Range: `bytes=${start}-` }, + credentials: "omit", + // 两项都设:policy 禁止浏览器生成 Referer,空 referrer 防止调用方 Request 继承。 + referrer: "", + referrerPolicy: "no-referrer", + signal + }); + if (!response.ok && response.status !== 206) { + throw new Error(`音频 CDN 请求失败(HTTP ${response.status})`); + } + if (start > 0 && response.status !== 206) { + throw new Error("音频 CDN 忽略了 Range,无法从拖动位置续流"); + } + if (response.body) return response.body.getReader(); + const bytes = await response.arrayBuffer(); + const fallback = new Response(bytes).body; + if (!fallback) throw new Error("浏览器不支持流式读取 Response"); + return fallback.getReader(); + } + sessionSignal(session) { + this.assertSession(session); + return this.sessionController.signal; + } + assertSession(session) { + if (!this.isCurrent(session)) throw abortError$1(); + } + isCurrent(session) { + var _a; + return this.session === session && !((_a = this.sessionController) == null ? void 0 : _a.signal.aborted); + } + async waitForSourceOpen(mediaSource, session) { + const signal = this.sessionSignal(session); + await new Promise((resolve, reject) => { + if (mediaSource.readyState === "open") { + resolve(); + return; + } + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("MediaSource 打开失败")); + }; + const onAbort = () => { + cleanup(); + reject(abortError$1()); + }; + const cleanup = () => { + mediaSource.removeEventListener("sourceopen", onOpen); + mediaSource.removeEventListener("error", onError); + signal.removeEventListener("abort", onAbort); + }; + mediaSource.addEventListener("sourceopen", onOpen, { once: true }); + mediaSource.addEventListener("error", onError, { once: true }); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + enqueueSourceBuffer(session, operation) { + const sourceBuffer = this.sourceBuffer; + if (!sourceBuffer) return Promise.reject(new Error("SourceBuffer 尚未创建")); + const signal = this.sessionSignal(session); + const run = async () => { + if (signal.aborted) throw abortError$1(); + await waitForSourceBufferIdle(sourceBuffer, signal); + if (signal.aborted) throw abortError$1(); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error) => { + if (settled) return; + settled = true; + sourceBuffer.removeEventListener("updateend", onUpdateEnd); + sourceBuffer.removeEventListener("error", onError); + signal.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(); + }; + const onUpdateEnd = () => finish(); + const onError = () => finish(new Error("SourceBuffer 更新失败")); + const onAbort = () => finish(abortError$1()); + sourceBuffer.addEventListener("updateend", onUpdateEnd); + sourceBuffer.addEventListener("error", onError); + signal.addEventListener("abort", onAbort, { once: true }); + try { + operation(sourceBuffer); + } catch (error) { + finish(asError(error)); + } + }); + }; + const next = this.sourceBufferChain.catch(() => void 0).then(run); + this.sourceBufferChain = next; + return next; + } + startStream(startSample, existingStream) { + var _a; + const context = this.context; + if (!context || this.state === "destroyed") return; + if (!existingStream) { + (_a = this.streamController) == null ? void 0 : _a.abort(); + this.streamController = new AbortController(); + } + const controller = this.streamController; + if (!controller) return; + const token = ++this.streamToken; + void this.runStream( + context, + this.session, + token, + startSample, + controller.signal, + existingStream + ); + } + async runStream(context, session, token, startSample, signal, existingStream) { + var _a; + let sample = startSample; + let first = true; + let stream = existingStream ?? null; + try { + stream ?? (stream = this.createSampleStream(context, startSample, signal)); + while (this.isStreamCurrent(session, token, signal) && sample < context.meta.sampleCount) { + await this.waitForRoom(session, token, signal); + if (!this.isStreamCurrent(session, token, signal)) return; + const endSample = Math.min(context.meta.sampleCount, sample + context.samplesPerSegment); + const byteLength = context.meta.offsets[endSample] - context.meta.offsets[sample]; + const encrypted = await stream.readExactly(byteLength); + if (!this.isStreamCurrent(session, token, signal)) return; + const plain = await this.decryptRange(context, encrypted, sample, endSample); + if (!this.isStreamCurrent(session, token, signal)) return; + const segment = context.meta.codec === "opus" ? buildWebmCluster( + Math.round(context.ticks[sample] * 1e3 / context.meta.timescale), + plain.map((_, index) => Math.round( + context.ticks[sample + index] * 1e3 / context.meta.timescale + ) - Math.round(context.ticks[sample] * 1e3 / context.meta.timescale)), + plain + ) : buildMp4Segment( + context.ticks[sample], + plain.map((data, index) => ({ + data, + duration: context.meta.durations[sample + index] + })), + ++this.segmentSequence + ); + await this.enqueueSourceBuffer(session, (sourceBuffer) => { + sourceBuffer.appendBuffer(this.toPageBuffer(segment)); + }); + if (first) { + const toc = (_a = plain[0]) == null ? void 0 : _a[0]; + this.emitMessage( + `首段 samples=${sample}..${endSample} 密文${encrypted.byteLength}B 段长${segment.byteLength}B TOC=0x${(toc ?? 0).toString(16)}`, + "info" + ); + } + if (!this.isStreamCurrent(session, token, signal)) return; + await this.evictBehind(session); + sample = endSample; + this.emitProgress({ + processedSamples: sample, + totalSamples: context.meta.sampleCount, + percent: Math.round(sample / context.meta.sampleCount * 100), + bufferedAhead: this.bufferedAhead(), + durationSeconds: context.meta.totalTicks / context.meta.timescale + }); + if (first && this.autoplay) { + first = false; + void this.audio.play().catch(() => { + this.emitMessage("自动播放被浏览器拦截,请点击音频控件播放", "warn"); + }); + } else { + first = false; + } + } + if (this.isStreamCurrent(session, token, signal)) { + await this.finishStream(session, sample); + this.emitMessage("音频已缓冲到结尾", "info"); + } + } catch (error) { + if (this.isStreamCurrent(session, token, signal) && !isAbort(error)) { + this.emitMessage(asError(error).message, "error"); + void this.reportAppendFailure(); + this.emitState("error"); + } + } finally { + void (stream == null ? void 0 : stream.cancel()); + } + } + /** + * 补报媒体元素的错误码。 + * + * SourceBuffer 的 error 事件先于媒体元素错误传播,同步读 audio.error + * 大概率是 null。让出一轮事件循环再读,才能拿到真正的 MediaError。 + */ + async reportAppendFailure() { + await new Promise((resolve) => setTimeout(resolve, 0)); + const detail = describeMediaError(this.audio); + if (detail) this.emitMessage(`媒体元素错误${detail}`, "error"); + } + isStreamCurrent(session, token, signal) { + return this.isCurrent(session) && this.streamToken === token && !signal.aborted; + } + /** + * 全部样本都送进 SourceBuffer 后收口 MediaSource。 + * + * 只有真的跑到最后一个样本才收口。seek 之后的流也会走到循环末尾, + * 但那时前面还有没缓冲的区间,提前 endOfStream 会把 duration 定在错的位置。 + */ + async finishStream(session, lastSample) { + const context = this.context; + const mediaSource = this.mediaSource; + if (!context || !mediaSource) return; + if (lastSample < context.meta.sampleCount) return; + if (mediaSource.readyState !== "open") return; + await this.sourceBufferChain.catch(() => void 0); + if (!this.isCurrent(session)) return; + if (this.sourceBuffer) { + await waitForSourceBufferIdle(this.sourceBuffer, this.sessionSignal(session)); + } + if (!this.isCurrent(session) || mediaSource.readyState !== "open") return; + try { + mediaSource.endOfStream(); + } catch (error) { + this.emitMessage(`标记音频结尾失败: ${asError(error).message}`, "warn"); + } + } + async waitForRoom(session, token, signal) { + while (this.isStreamCurrent(session, token, signal) && this.bufferedAhead() >= this.options.maxBufferAheadSeconds) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + if (!this.isStreamCurrent(session, token, signal)) throw abortError$1(); + } + /** + * seek 后的新流。目标仍在已保存的 MP4 头内时先复用内存字节,读完再从 + * head.length 开一条网络流;否则直接从目标 sample 的绝对偏移续传。 + */ + createSampleStream(context, startSample, signal) { + const start = context.meta.mdatStart + context.meta.offsets[startSample]; + if (start < context.head.length) { + return this.createNetworkStream( + context.url, + context.head.length, + signal, + context.head.slice(start) + ); + } + return this.createNetworkStream(context.url, start, signal); + } + async decryptRange(context, encrypted, startSample, endSample) { + const ivSize = cencIvSize(context.meta); + const jobs = []; + for (let i2 = startSample; i2 < endSample; i2++) { + const relative = context.meta.offsets[i2] - context.meta.offsets[startSample]; + const size = context.meta.sizes[i2]; + jobs.push({ + iv: context.meta.ivs.slice(i2 * ivSize, (i2 + 1) * ivSize), + ciphertext: encrypted.slice(relative, relative + size) + }); + } + return mapLimit(jobs, this.options.decryptConcurrency, (job) => decryptCencSample( + context.key, + job.iv, + job.ciphertext, + context.counterStart + )); + } + async detectCounterStart(meta, head, key) { + const count = Math.min(6, meta.sampleCount); + if (count === 0) return 0; + const start = meta.mdatStart + meta.offsets[0]; + const end = meta.mdatStart + meta.offsets[count] - 1; + if (end >= head.length) throw new Error("Opus counter 探测数据不完整"); + const encrypted = head.slice(start, end + 1); + const ivSize = cencIvSize(meta); + for (const candidate of [0, 1]) { + const firstBytes = /* @__PURE__ */ new Set(); + for (let i2 = 0; i2 < count; i2++) { + const relative = meta.offsets[i2]; + const ciphertext = encrypted.slice(relative, relative + meta.sizes[i2]); + const plain = await decryptCencSample( + key, + meta.ivs.slice(i2 * ivSize, (i2 + 1) * ivSize), + ciphertext, + candidate + ); + if (plain.length > 0) firstBytes.add(plain[0]); + } + if (firstBytes.size === 1) { + this.emitMessage(`CENC counter 低位起始=${candidate}`, "info"); + return candidate; + } + } + this.emitMessage("CENC counter 未收敛,按 0 继续", "warn"); + return 0; + } + async evictBehind(session) { + const sourceBuffer = this.sourceBuffer; + if (!sourceBuffer || !sourceBuffer.buffered.length) return; + const current = this.audio.currentTime; + if (current <= this.options.keepBehindSeconds + 5) return; + const start = sourceBuffer.buffered.start(0); + const end = current - this.options.keepBehindSeconds; + if (start < end) { + try { + await this.enqueueSourceBuffer(session, (buffer) => buffer.remove(start, end)); + } catch { + } + } + } + async restartAt(target, wasPlaying) { + var _a, _b; + const context = this.context; + if (!context) return; + if (this.audio.error) { + this.emitMessage("播放元素已进入错误态,无法跳转,请重新开始听书", "warn"); + return; + } + const session = this.session; + const seek = ++this.seekToken; + this.streamToken++; + (_a = this.streamController) == null ? void 0 : _a.abort(); + let low = 0; + let high = context.meta.sampleCount - 1; + let index = 0; + const targetTicks = target * context.meta.timescale; + while (low <= high) { + const middle = low + high >> 1; + if (context.ticks[middle] <= targetTicks) { + index = middle; + low = middle + 1; + } else { + high = middle - 1; + } + } + const preSamples = Math.max(1, Math.round(context.samplesPerSecond * 2)); + const startSample = Math.max(0, index - preSamples); + this.emitMessage(`跳转到 ${target.toFixed(1)} 秒`, "info"); + await this.sourceBufferChain.catch(() => void 0); + if (seek !== this.seekToken || !this.isCurrent(session)) return; + const bufferedEnd = ((_b = this.sourceBuffer) == null ? void 0 : _b.buffered.length) ? this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) : 0; + if (bufferedEnd > 0) { + try { + await this.enqueueSourceBuffer(session, (sourceBuffer) => sourceBuffer.remove(0, bufferedEnd + 0.5)); + } catch { + } + } + if (seek !== this.seekToken || !this.isCurrent(session)) return; + if (this.mediaSource && this.mediaSource.readyState === "closed") { + this.emitMessage("媒体源已关闭,无法跳转", "warn"); + return; + } + this.startStream(startSample); + if (wasPlaying) void this.audio.play().catch(() => void 0); + } + isTimeBuffered(time) { + var _a; + const ranges = (_a = this.sourceBuffer) == null ? void 0 : _a.buffered; + if (!ranges) return false; + for (let i2 = 0; i2 < ranges.length; i2++) { + if (time >= ranges.start(i2) - 0.05 && time <= ranges.end(i2) + 0.05) return true; + } + return false; + } + bufferedAhead() { + var _a; + const ranges = (_a = this.sourceBuffer) == null ? void 0 : _a.buffered; + if (!ranges || !ranges.length) return 0; + const current = this.audio.currentTime; + for (let i2 = 0; i2 < ranges.length; i2++) { + if (current >= ranges.start(i2) - 0.05 && current <= ranges.end(i2) + 0.05) { + return Math.max(0, ranges.end(i2) - current); + } + } + return Math.max(0, ranges.end(ranges.length - 1) - current); + } + emitState(state2) { + var _a, _b; + this.state = state2; + (_b = (_a = this.options).onStateChange) == null ? void 0 : _b.call(_a, state2); + } + emitProgress(progress) { + var _a, _b; + (_b = (_a = this.options).onProgress) == null ? void 0 : _b.call(_a, progress); + } + emitMessage(message, level) { + var _a, _b; + (_b = (_a = this.options).onMessage) == null ? void 0 : _b.call(_a, message, level); + } + } + function gmAudioFetch(input, init2 = {}) { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const headers = normalizeHeaders(init2.headers); + const signal = init2.signal ?? null; + return new Promise((resolve, reject) => { + if (signal == null ? void 0 : signal.aborted) { + reject(abortError()); + return; + } + let settled = false; + let request; + const onAbort = () => { + try { + request == null ? void 0 : request.abort(); + } catch { + } + if (settled) return; + settled = true; + reject(abortError()); + }; + signal == null ? void 0 : signal.addEventListener("abort", onAbort, { once: true }); + const cleanup = () => { + signal == null ? void 0 : signal.removeEventListener("abort", onAbort); + }; + const succeed = (res, body) => { + if (settled) return; + settled = true; + cleanup(); + resolve(makeResponse(res, body)); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + request = GM_xmlhttpRequest({ + method: "GET", + url, + headers, + responseType: "stream", + // GM 请求不带页面 Referer;anonymous 同时也不带 Cookie + anonymous: true, + onreadystatechange(res) { + if (settled || res.readyState < 3) return; + const stream = res.response; + if (stream instanceof ReadableStream) succeed(res, stream); + }, + onload(res) { + if (settled) return; + const stream = res.response; + if (stream instanceof ReadableStream) { + succeed(res, stream); + return; + } + const bytes = toBytes(res); + if (!bytes) { + fail(new Error( + "GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)" + )); + return; + } + if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) { + fail(new Error("音频响应为空")); + return; + } + succeed(res, bytes); + }, + onerror(res) { + fail(new Error(`音频 CDN 请求失败:${describe(res)}`)); + }, + ontimeout() { + fail(new Error("音频 CDN 请求超时")); + } + }); + }); + } + function abortError() { + return new DOMException("音频请求已取消", "AbortError"); + } + function parseHeaders(raw) { + const headers = new Headers(); + if (!raw) return headers; + for (const line of raw.split(/\r?\n/)) { + const colon = line.indexOf(":"); + if (colon <= 0) continue; + const name2 = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (!name2) continue; + try { + headers.append(name2, value); + } catch { + } + } + return headers; + } + function makeResponse(res, body) { + return new Response(body ?? new Uint8Array(), { + status: res.status || 200, + statusText: res.statusText || "", + headers: parseHeaders(res.responseHeaders) + }); + } + function toBytes(res) { + const raw = res.response; + if (raw instanceof ArrayBuffer) return new Uint8Array(raw); + if (raw instanceof Uint8Array) return raw; + return null; + } + function normalizeHeaders(init2) { + const out = {}; + if (!init2) return out; + if (init2 instanceof Headers) { + init2.forEach((value, key) => { + out[key] = value; + }); + return out; + } + if (Array.isArray(init2)) { + for (const [key, value] of init2) { + if (key !== void 0 && value !== void 0) out[key] = value; + } + return out; + } + return { ...init2 }; + } + function describe(res) { + if ("error" in res && res.error) return String(res.error); + const status = res.status; + return status ? `HTTP ${status}` : "网络错误"; + } + const CONTENT_SELECTOR = "#fqa-reader-content"; + const TITLE_SELECTOR = "h1.muye-reader-title"; + const ACTIVE_CLASS = "fqa-audio-active"; + function collectParagraphs() { + const map = /* @__PURE__ */ new Map(); + const container2 = document.querySelector(CONTENT_SELECTOR); + if (!container2) return map; + for (const node of container2.querySelectorAll("p[idx]")) { + const idx = Number(node.getAttribute("idx")); + if (!Number.isFinite(idx)) continue; + const list = map.get(idx); + if (list) list.push(node); + else map.set(idx, [node]); + } + return map; + } + function titleTarget(tags) { + const titles = tags.filter((t) => t.is_title); + if (titles.length !== 1) return null; + return document.querySelector(TITLE_SELECTOR); + } + function tagIndexAt(tags, timeMs) { + let low = 0; + let high = tags.length - 1; + let found = -1; + while (low <= high) { + const middle = low + high >> 1; + if (tags[middle].startms <= timeMs) { + found = middle; + low = middle + 1; + } else { + high = middle - 1; + } + } + if (found < 0) return -1; + return found; + } + class ParagraphHighlighter { + constructor() { + __publicField(this, "paragraphs", /* @__PURE__ */ new Map()); + __publicField(this, "tags", []); + __publicField(this, "active", []); + __publicField(this, "activeTagIndex", -1); + } + /** 换章或正文重新插入后调用,重建索引 */ + reset(tags) { + this.clear(); + this.tags = tags; + this.paragraphs = collectParagraphs(); + this.activeTagIndex = -1; + } + /** 正文 DOM 被替换过(切音色不会换 DOM,但切章会),重新抓一遍段落 */ + refresh() { + this.paragraphs = collectParagraphs(); + this.activeTagIndex = -1; + } + get timeTags() { + return this.tags; + } + /** 当前高亮对应的时间点下标,用于切音色时保持段落 */ + get currentTagIndex() { + return this.activeTagIndex; + } + clear() { + for (const node of this.active) node.classList.remove(ACTIVE_CLASS); + this.active = []; + } + /** + * 按播放时间更新高亮。 + * + * @param scroll 是否把高亮段落滚进视口 + * @returns 当前时间点下标,没有变化时返回原值 + */ + update(timeMs, scroll) { + if (this.tags.length === 0) return -1; + const index = tagIndexAt(this.tags, timeMs); + if (index < 0 || index === this.activeTagIndex) return this.activeTagIndex; + this.activeTagIndex = index; + this.applyTag(index, scroll); + return index; + } + /** 直接高亮第 index 个时间点,供切音色后恢复位置用 */ + applyTag(index, scroll) { + const tag = this.tags[index]; + if (!tag) return; + this.clear(); + const targets = tag.is_title ? [titleTarget(this.tags)].filter((n) => n !== null) : this.rangeTargets(tag.startidx, tag.endidx); + for (const node of targets) node.classList.add(ACTIVE_CLASS); + this.active = targets; + if (scroll && targets[0]) { + targets[0].scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + rangeTargets(startidx, endidx) { + const targets = []; + for (let idx = startidx; idx <= endidx; idx++) { + const nodes = this.paragraphs.get(idx); + if (nodes) targets.push(...nodes); + } + return targets; + } + /** + * 点段落跳转用:找出这个节点属于哪个时间点。 + * + * 从点击目标往上找最近的 (点到 blk 或行内标签上也能命中), + * 再拿 idx 去时间点表里查覆盖它的那一段。 + */ + tagIndexOfNode(node) { + var _a; + const paragraph = nearestParagraph(node); + if (paragraph) { + const idx = Number(paragraph.getAttribute("idx")); + if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx); + } + if (node instanceof Node && ((_a = titleTarget(this.tags)) == null ? void 0 : _a.contains(node))) { + return this.tags.findIndex((t) => t.is_title); + } + return -1; + } + /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ + tagIndexOfIdx(idx) { + return this.tags.findIndex((t) => !t.is_title && idx >= t.startidx && idx <= t.endidx); + } + /** 第 index 个时间点的起始秒数,供 seek 用 */ + startSecondsOf(index) { + const tag = this.tags[index]; + return tag ? tag.startms / 1e3 : null; + } + } + function nearestParagraph(node) { + let current = node; + while (current) { + if (current instanceof HTMLElement && current.matches("p[idx]")) return current; + if (current instanceof HTMLElement && current.id === "fqa-reader-content") return null; + current = current.parentNode; + } + return null; + } + const TONE_STORE_KEY = "audiobook_tone"; + const state = vue.reactive({ + open: false, + collapsed: false, + tonePickerOpen: false, + loading: false, + playing: false, + spinning: false, + tones: [], + toneId: null, + toneName: "", + cover: "", + title: "", + error: "" + }); + const highlighter = new ParagraphHighlighter(); + let audio = null; + let player = null; + let itemId = ""; + let bookId = ""; + let trackTimer = null; + let pendingTagIndex = -1; + let playToken = 0; + let paragraphClickBound = false; + const onParagraphClick = (event) => { + if (!state.open || state.toneId === null) return; + const target = event.target; + if (target instanceof HTMLElement && target.closest("a, button, sup")) return; + const index = highlighter.tagIndexOfNode(target); + if (index < 0) return; + const seconds = highlighter.startSecondsOf(index); + if (seconds === null || !audio) return; + audio.currentTime = seconds; + highlighter.applyTag(index, false); + if (!state.playing) void togglePlay(); + }; + function bindParagraphClick() { + if (paragraphClickBound) return; + document.addEventListener("click", onParagraphClick); + paragraphClickBound = true; + } + function unbindParagraphClick() { + if (!paragraphClickBound) return; + document.removeEventListener("click", onParagraphClick); + paragraphClickBound = false; + } + function ensurePlayer() { + if (player) return player; + audio = document.createElement("audio"); + audio.preload = "none"; + audio.style.display = "none"; + document.body.appendChild(audio); + player = new CencAudioPlayer(audio, { + // 音频 CDN 不在页面 CSP 的 connect-src 里,页面 fetch 会被 report-only + // 策略上报到 mon.zijieapi.com。走 GM 通道绕开上报,详见 gmFetch + fetch: gmAudioFetch, + onStateChange: onPlayerState, + onMessage: (message, level) => { + if (level === "error") { + console.error("[fqa:audio]", message); + state.error = message; + } else { + console.log("[fqa:audio]", message); + } + } + }); + return player; + } + function onPlayerState(playerState) { + state.playing = playerState === "playing"; + state.spinning = playerState === "playing" || playerState === "loading"; + if (playerState === "playing") state.error = ""; + if (playerState === "playing") startTracking(); + else stopTracking(); + if (playerState === "ended") { + highlighter.clear(); + onChapterEnd(); + } + } + const NEXT_CHAPTER_SELECTOR = "div.chapter-btn.next"; + function onChapterEnd() { + var _a; + if (settings$1.audiobookChapterEnd !== "next") { + state.spinning = false; + return; + } + const next = (_a = document.querySelector(NEXT_CHAPTER_SELECTOR)) == null ? void 0 : _a.firstChild; + if (typeof (next == null ? void 0 : next.click) !== "function") { + state.spinning = false; + state.error = "已经是最后一章"; + return; + } + state.spinning = true; + const before = itemId; + next.click(); + unsafeWindow.setTimeout(() => { + var _a2; + if (!state.open || itemId !== before) return; + const current = ((_a2 = window.location.pathname.split("/").pop()) == null ? void 0 : _a2.substring(0, 19)) || ""; + if (current && current !== before) { + void switchChapter(current, { cover: state.cover, title: state.title }); + } else { + state.spinning = false; + state.error = "自动切章失败"; + } + }, 3e3); + } + function startTracking() { + if (trackTimer !== null) return; + trackTimer = unsafeWindow.setInterval(() => { + if (!audio) return; + highlighter.update(audio.currentTime * 1e3, settings$1.audiobookFollow); + }, 120); + } + function stopTracking() { + if (trackTimer === null) return; + unsafeWindow.clearInterval(trackTimer); + trackTimer = null; + } + function storedToneId() { + const raw = read(TONE_STORE_KEY); + return typeof raw === "number" ? raw : null; + } + async function openAudiobook(chapter, book, meta) { + itemId = chapter; + bookId = book; + state.cover = meta.cover; + state.title = meta.title; + state.error = ""; + state.open = true; + state.collapsed = false; + if (state.tones.length === 0) { + try { + state.tones = await getBookAvailableTones(bookId); + } catch (e) { + state.error = "取音色列表失败"; + console.error("[fqa:audio] 取音色列表失败:", e); + return; + } + } + if (state.tones.length === 0) { + state.error = "这本书没有可用音色"; + return; + } + const remembered = storedToneId(); + if (remembered !== null && state.tones.some((t) => t.id === remembered)) { + await selectTone(remembered); + return; + } + state.tonePickerOpen = true; + } + async function selectTone(toneId) { + state.tonePickerOpen = false; + const tone = state.tones.find((t) => t.id === toneId); + state.toneId = toneId; + state.toneName = (tone == null ? void 0 : tone.name) ?? ""; + write(TONE_STORE_KEY, toneId); + await playCurrent(); + } + function openTonePicker() { + state.tonePickerOpen = true; + } + function closeTonePicker() { + state.tonePickerOpen = false; + if (state.toneId === null) closeAudiobook(); + } + async function changeTone(toneId) { + if (toneId === state.toneId) { + state.tonePickerOpen = false; + return; + } + pendingTagIndex = highlighter.currentTagIndex; + await selectTone(toneId); + } + async function playCurrent() { + const toneId = state.toneId; + if (toneId === null || !itemId) return; + const token = ++playToken; + const chapter = itemId; + state.loading = true; + state.spinning = true; + state.error = ""; + try { + const [contexts, tags] = await Promise.all([ + getPlayInfo(chapter, toneId), + getChapterParagraphTimeTag(chapter, toneId) + ]); + if (token !== playToken) return; + const context = contexts.find((c) => c.item_id === chapter) ?? contexts[0]; + const url = context == null ? void 0 : context.urls[0]; + if (!context || !url) { + state.error = "这一章没有音频"; + return; + } + highlighter.reset(tags); + bindParagraphClick(); + const instance = ensurePlayer(); + await instance.start({ url, key: context.key }); + if (token !== playToken) return; + if (pendingTagIndex >= 0) { + const tag = tags[pendingTagIndex]; + if (tag && audio) { + audio.currentTime = tag.startms / 1e3; + highlighter.applyTag(pendingTagIndex, true); + } + pendingTagIndex = -1; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + state.error = message; + console.error("[fqa:audio] 播放失败:", e); + } finally { + state.loading = false; + if (!state.playing) state.spinning = false; + } + } + async function togglePlay() { + if (!player) return; + if (state.playing) { + player.pause(); + return; + } + if (player.currentState === "ended" && audio) audio.currentTime = 0; + try { + await player.play(); + } catch (e) { + console.warn("[fqa:audio] play() 被拒:", e); + } + } + function toggleCollapsed() { + state.collapsed = !state.collapsed; + } + async function switchChapter(chapter, meta) { + if (!state.open || state.toneId === null) return; + itemId = chapter; + state.cover = meta.cover; + state.title = meta.title; + pendingTagIndex = -1; + await playCurrent(); + } + function refreshParagraphs() { + if (state.open) highlighter.refresh(); + } + function closeAudiobook() { + stopTracking(); + unbindParagraphClick(); + playToken++; + highlighter.clear(); + player == null ? void 0 : player.stop(); + state.open = false; + state.collapsed = false; + state.tonePickerOpen = false; + state.playing = false; + state.spinning = false; + state.loading = false; + state.error = ""; + } + const _hoisted_1$b = { + class: "fqa-tone-box", + role: "dialog", + "aria-modal": "true", + "aria-label": "选择音色" + }; + const _hoisted_2$b = { class: "fqa-tone-title" }; + const _hoisted_3$b = { class: "fqa-tone-list" }; + const _hoisted_4$a = ["onClick"]; + const _hoisted_5$a = ["src", "alt"]; + const _hoisted_6$a = { + key: 1, + class: "fqa-tone-icon fqa-tone-icon-empty" + }; + const _hoisted_7$8 = { class: "fqa-tone-text" }; + const _hoisted_8$8 = { class: "fqa-tone-name" }; + const _hoisted_9$8 = { + key: 0, + class: "fqa-tone-gender" + }; + const _hoisted_10$8 = { + key: 0, + class: "fqa-tone-desc" + }; + const _hoisted_11$6 = { class: "fqa-tone-actions" }; + const _sfc_main$b = /* @__PURE__ */ vue.defineComponent({ + __name: "TonePicker", + props: { + tones: {}, + current: {} + }, + emits: ["select", "close"], + setup(__props, { emit: __emit }) { + const props = __props; + const emit = __emit; + function genderLabel(gender) { + if (gender === 1) return "男声"; + if (gender === 2) return "女声"; + return ""; + } + return (_ctx, _cache) => { + return vue.openBlock(), vue.createElementBlock("div", { + class: "fqa-tone-mask", + onClick: _cache[1] || (_cache[1] = vue.withModifiers(($event) => emit("close"), ["self"])) + }, [ + vue.createElementVNode("div", _hoisted_1$b, [ + vue.createElementVNode("h3", _hoisted_2$b, vue.toDisplayString(props.current === null ? "选择音色" : "切换音色"), 1), + _cache[2] || (_cache[2] = vue.createElementVNode("p", { class: "fqa-tone-sub" }, "切换后会从当前段落开头继续", -1)), + vue.createElementVNode("div", _hoisted_3$b, [ + (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(props.tones, (tone) => { + return vue.openBlock(), vue.createElementBlock("button", { + key: tone.id, + type: "button", + class: vue.normalizeClass(["fqa-tone-item", { "fqa-tone-item-active": tone.id === props.current }]), + onClick: ($event) => emit("select", tone.id) + }, [ + tone.icon ? (vue.openBlock(), vue.createElementBlock("img", { + key: 0, + class: "fqa-tone-icon", + src: tone.icon, + alt: tone.name + }, null, 8, _hoisted_5$a)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_6$a, vue.toDisplayString(tone.name.slice(0, 1)), 1)), + vue.createElementVNode("span", _hoisted_7$8, [ + vue.createElementVNode("span", _hoisted_8$8, [ + vue.createTextVNode(vue.toDisplayString(tone.name) + " ", 1), + genderLabel(tone.gender) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_9$8, vue.toDisplayString(genderLabel(tone.gender)), 1)) : vue.createCommentVNode("", true) + ]), + tone.description ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_10$8, vue.toDisplayString(tone.description), 1)) : vue.createCommentVNode("", true) + ]) + ], 10, _hoisted_4$a); + }), 128)) + ]), + vue.createElementVNode("div", _hoisted_11$6, [ + vue.createElementVNode("button", { + type: "button", + class: "fqa-tone-btn", + onClick: _cache[0] || (_cache[0] = ($event) => emit("close")) + }, "取消") + ]) + ]) + ]); + }; + } + }); + const _hoisted_1$a = ["innerHTML"]; + const _hoisted_2$a = { + key: 1, + class: "fqa-audio-bar" + }; + const _hoisted_3$a = ["title", "aria-label"]; + const _hoisted_4$9 = ["src"]; + const _hoisted_5$9 = { + key: 1, + class: "fqa-audio-cover-empty" + }; + const _hoisted_6$9 = ["innerHTML"]; + const _hoisted_7$7 = { class: "fqa-audio-meta" }; + const _hoisted_8$7 = { class: "fqa-audio-title" }; + const _hoisted_9$7 = { class: "fqa-audio-sub" }; + const _hoisted_10$7 = ["innerHTML"]; + const _sfc_main$a = /* @__PURE__ */ vue.defineComponent({ + __name: "AudioBar", + setup(__props) { + function onSelect(id) { + if (state.toneId === null) void selectTone(id); + else void changeTone(id); + } + return (_ctx, _cache) => { + return vue.unref(state).open ? (vue.openBlock(), vue.createElementBlock("div", { + key: 0, + class: vue.normalizeClass(["fqa-audio-root", { "fqa-audio-collapsed": vue.unref(state).collapsed }]) + }, [ + vue.unref(state).collapsed ? (vue.openBlock(), vue.createElementBlock("button", { + key: 0, + type: "button", + class: "fqa-audio-expand", + title: "展开听书栏", + "aria-label": "展开听书栏", + onClick: _cache[0] || (_cache[0] = //@ts-ignore + (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) + }, [ + vue.createElementVNode("span", { + class: "fqa-audio-icon fqa-audio-icon-flip", + innerHTML: vue.unref(leftIcon) + }, null, 8, _hoisted_1$a) + ])) : (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$a, [ + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-cover", + title: vue.unref(state).playing ? "暂停" : "播放", + "aria-label": vue.unref(state).playing ? "暂停" : "播放", + onClick: _cache[1] || (_cache[1] = //@ts-ignore + (...args) => vue.unref(togglePlay) && vue.unref(togglePlay)(...args)) + }, [ + vue.createElementVNode("span", { + class: vue.normalizeClass(["fqa-audio-disc", { "fqa-audio-spin": vue.unref(state).spinning }]) + }, [ + vue.unref(state).cover ? (vue.openBlock(), vue.createElementBlock("img", { + key: 0, + src: vue.unref(state).cover, + alt: "" + }, null, 8, _hoisted_4$9)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_5$9, "听")) + ], 2), + vue.createElementVNode("span", { + class: "fqa-audio-state", + innerHTML: vue.unref(state).playing ? vue.unref(playingIcon) : vue.unref(pausedIcon) + }, null, 8, _hoisted_6$9) + ], 8, _hoisted_3$a), + vue.createElementVNode("div", _hoisted_7$7, [ + vue.createElementVNode("span", _hoisted_8$7, vue.toDisplayString(vue.unref(state).title), 1), + vue.createElementVNode("span", _hoisted_9$7, vue.toDisplayString(vue.unref(state).error || (vue.unref(state).loading ? "缓冲中…" : vue.unref(state).toneName)), 1) + ]), + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-btn", + title: "切换音色", + "aria-label": "切换音色", + onClick: _cache[2] || (_cache[2] = //@ts-ignore + (...args) => vue.unref(openTonePicker) && vue.unref(openTonePicker)(...args)) + }, [..._cache[5] || (_cache[5] = [ + vue.createElementVNode("span", { class: "fqa-audio-dots" }, null, -1) + ])]), + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-btn", + title: "收起", + "aria-label": "收起听书栏", + onClick: _cache[3] || (_cache[3] = //@ts-ignore + (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) + }, [ + vue.createElementVNode("span", { + class: "fqa-audio-icon", + innerHTML: vue.unref(leftIcon) + }, null, 8, _hoisted_10$7) + ]), + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-btn fqa-audio-close", + title: "关闭听书", + "aria-label": "关闭听书", + onClick: _cache[4] || (_cache[4] = //@ts-ignore + (...args) => vue.unref(closeAudiobook) && vue.unref(closeAudiobook)(...args)) + }, " × ") + ])), + vue.unref(state).tonePickerOpen ? (vue.openBlock(), vue.createBlock(_sfc_main$b, { + key: 2, + tones: vue.unref(state).tones, + current: vue.unref(state).toneId, + onSelect, + onClose: vue.unref(closeTonePicker) + }, null, 8, ["tones", "current", "onClose"])) : vue.createCommentVNode("", true) + ], 2)) : vue.createCommentVNode("", true); + }; + } + }); + const audiobookcss = `/* 听书悬浮栏与音色弹窗。 + * 暗色跟随页面的 div.muye-reader-dark(与 userHook 的判定一致)。 */ + +.fqa-audio-root { + --fqa-audio-bg: #fff; + --fqa-audio-text: #1f2329; + --fqa-audio-sub: #8f959e; + --fqa-audio-hover: rgba(31, 35, 41, 0.06); + --fqa-audio-border: rgba(31, 35, 41, 0.1); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); + + position: fixed; + left: 20px; + bottom: 20px; + z-index: 2147483000; + font-size: 14px; + color: var(--fqa-audio-text); +} + +/* 页面切到夜间模式时整体反色 */ +.muye-reader-dark .fqa-audio-root, +.fqa-audio-root.fqa-audio-dark { + --fqa-audio-bg: #2b2b2b; + --fqa-audio-text: #b3b3b3; + --fqa-audio-sub: #7a7a7a; + --fqa-audio-hover: rgba(255, 255, 255, 0.08); + --fqa-audio-border: rgba(255, 255, 255, 0.12); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); +} + +.fqa-audio-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 8px; + border-radius: 999px; + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); +} + +/* ------------------------------- 旋转封面 ------------------------------- */ + +.fqa-audio-cover { + position: relative; + flex: 0 0 auto; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--fqa-audio-hover); + cursor: pointer; +} + +/* + * 只有这一层转。状态图标是 .fqa-audio-state,放在旋转层外面, + * 否则会跟着封面一起转,反而更看不清。 + * 播放/缓冲时转,暂停时停在当前角度(animation-play-state 比移除动画更平滑) + */ +.fqa-audio-disc { + position: absolute; + inset: 0; + border-radius: 50%; + overflow: hidden; + animation: fqa-audio-rotate 8s linear infinite; + animation-play-state: paused; +} + +.fqa-audio-disc.fqa-audio-spin { + animation-play-state: running; +} + +.fqa-audio-disc img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.fqa-audio-cover-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--fqa-audio-sub); + font-size: 18px; +} + +/* 播放/暂停状态。压一层遮罩,浅色封面上也看得清 */ +.fqa-audio-state { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.38); + color: #fff; + transition: background 0.2s ease; +} + +.fqa-audio-cover:hover .fqa-audio-state { + background: rgba(0, 0, 0, 0.55); +} + +/* + * 只给尺寸,不写 fill:paused.svg 里那块透明点击区是 fill="none", + * CSS 的 fill 优先级高于表现属性,会把它填成一个白方块盖住图标。 + * 两个 svg 自身已经用 currentColor,跟着上面的 color 走。 + */ +.fqa-audio-state svg { + width: 18px; + height: 18px; +} + +@keyframes fqa-audio-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .fqa-audio-disc { animation: none; } +} + +/* -------------------------------- 文字区 -------------------------------- */ + +.fqa-audio-meta { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + max-width: 180px; + line-height: 1.35; +} + +.fqa-audio-title, +.fqa-audio-sub { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-audio-sub { + color: var(--fqa-audio-sub); + font-size: 12px; +} + +/* -------------------------------- 按钮 -------------------------------- */ + +.fqa-audio-btn, +.fqa-audio-expand { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--fqa-audio-text); + cursor: pointer; +} + +.fqa-audio-btn:hover, +.fqa-audio-expand:hover { + background: var(--fqa-audio-hover); +} + +.fqa-audio-close { + font-size: 20px; + line-height: 1; +} + +/* svg 用 currentColor,跟着按钮的 color 走 */ +.fqa-audio-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.fqa-audio-icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +/* 收起后箭头水平翻转朝右 */ +.fqa-audio-icon-flip { + transform: scaleX(-1); +} + +.fqa-audio-expand { + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); + width: 36px; + height: 36px; +} + +/* 三个点 */ +.fqa-audio-dots, +.fqa-audio-dots::before, +.fqa-audio-dots::after { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; +} + +.fqa-audio-dots { + position: relative; +} + +.fqa-audio-dots::before, +.fqa-audio-dots::after { + content: ''; + position: absolute; + top: 0; +} + +.fqa-audio-dots::before { left: -7px; } +.fqa-audio-dots::after { left: 7px; } + +/* ----------------------------- 段落高亮 ----------------------------- */ + +.fqa-audio-active { + background: var(--web-brand_light, rgba(241, 70, 70, 0.12)); + border-radius: 4px; + transition: background 0.2s ease; +} + +/* ----------------------------- 音色弹窗 ----------------------------- */ + +.fqa-tone-mask { + position: fixed; + inset: 0; + z-index: 2147483002; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} + +.fqa-tone-box { + width: min(420px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 64px)); + display: flex; + flex-direction: column; + padding: 20px; + border-radius: 12px; + background: var(--fqa-audio-bg, #fff); + color: var(--fqa-audio-text, #1f2329); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.24); +} + +.fqa-tone-title { + margin: 0 0 4px; + font-size: 17px; + font-weight: 600; +} + +.fqa-tone-sub { + margin: 0 0 14px; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; +} + +.fqa-tone-list { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.fqa-tone-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.fqa-tone-item:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} + +.fqa-tone-item-active { + border-color: var(--web-brand, #f14646); +} + +.fqa-tone-icon { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; +} + +.fqa-tone-icon-empty { + display: flex; + align-items: center; + justify-content: center; + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); + color: var(--fqa-audio-sub, #8f959e); +} + +.fqa-tone-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.fqa-tone-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; +} + +.fqa-tone-gender { + color: var(--fqa-audio-sub, #8f959e); + font-size: 11px; +} + +.fqa-tone-desc { + overflow: hidden; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-tone-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +.fqa-tone-btn { + padding: 6px 16px; + border: 1px solid var(--fqa-audio-border, rgba(31, 35, 41, 0.1)); + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.fqa-tone-btn:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} +`; + const CONTAINER_ID$4 = "fqa-audio-root"; + const STYLE_ID$5 = "fqa-audio-style"; + let app$4 = null; + let container$4 = null; + let themeObserver = null; + function injectStyle$5() { + if (document.getElementById(STYLE_ID$5)) return; + const style = document.createElement("style"); + style.id = STYLE_ID$5; + style.textContent = audiobookcss; + document.head.appendChild(style); + } + function syncTheme() { + if (!container$4) return; + const dark = document.querySelector("div.muye-reader-dark") !== null; + container$4.classList.toggle("fqa-audio-dark", dark); + } + function watchTheme() { + if (themeObserver) return; + themeObserver = new MutationObserver(syncTheme); + themeObserver.observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ["class"] + }); + } + function initAudioPanel() { + if (app$4) return; + injectStyle$5(); + container$4 = document.createElement("div"); + container$4.id = CONTAINER_ID$4; + document.body.appendChild(container$4); + syncTheme(); + watchTheme(); + app$4 = vue.createApp({ render: () => vue.h(_sfc_main$a) }); + app$4.config.errorHandler = (err, _instance, info) => { + console.error(`[fqa:audio] Vue error (${info}):`, err); + }; + app$4.mount(container$4); + } + let currentBook = null; + let latestItemId = null; + const SCRIPT_CONTAINER_ID = "fqa-reader-content"; + let comicObserver = null; + function ensureScriptContainer(readerContainer, comic) { + let scriptContainer = document.getElementById(SCRIPT_CONTAINER_ID); + if (!scriptContainer) { + scriptContainer = cloneElement(readerContainer); + scriptContainer.id = SCRIPT_CONTAINER_ID; + scriptContainer.classList.add("fqa"); + readerContainer.insertAdjacentElement("beforebegin", scriptContainer); + } + scriptContainer.classList.toggle("fqa-comic-reader", comic); + if (settings$1.allowCopy) scriptContainer.classList.remove("noselect"); + comicObserver == null ? void 0 : comicObserver.disconnect(); + comicObserver = null; + scriptContainer.innerHTML = ""; + readerContainer.classList.add("fqa-hide"); + return scriptContainer; + } + async function insertContent() { + var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; + const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; + if (!itemId2) { + console.warn("No item_id found in URL"); + return; + } + latestItemId = itemId2; + const chapter = await getChapter(itemId2); + if (!chapter) { + console.warn("No chapter found for item_id:", itemId2); + return; + } + if (latestItemId !== itemId2) { + console.debug("Stale chapter response discarded:", itemId2); + return; + } + console.log("Chapter:", chapter); + const pageState = unsafeWindow.__INITIAL_STATE__; + const chapterTitle = ((_b = chapter.novel_data) == null ? void 0 : _b.title) || ((_d = (_c = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _c.chapterData) == null ? void 0 : _d.title); + if (typeof chapter.content === "string") { + void applyBookCss((_e = chapter.novel_data) == null ? void 0 : _e.css_map, "#fqa-reader-content"); + const dp = new DOMParser(); + const doc = dp.parseFromString(chapter.content, "text/html"); + const body = doc.body; + body.querySelectorAll('link[rel="stylesheet"]').forEach((el) => el.remove()); + let article = body.querySelector("article"); + let toProcess = article || body; + processFootnotes(toProcess); + for (let i2 = 0; i2 < toProcess.childNodes.length; i2++) { + if (i2 < 2 && ((_g = (_f = toProcess.childNodes[i2]) == null ? void 0 : _f.innerHTML) == null ? void 0 : _g.includes(chapterTitle))) { + toProcess.removeChild(toProcess.childNodes[i2]); + break; + } + } + if (!article) { + article = document.createElement("article"); + article.innerHTML = toProcess.innerHTML; + toProcess = article; + } + const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); + if (readerContainer) { + const scriptContainer = ensureScriptContainer(readerContainer, false); + scriptContainer.appendChild(toProcess); + bindFootnoteInteraction(scriptContainer); + } + } else if (chapter.content.picInfos) { + if (chapter.content.encrypt) { + const imgs = []; + for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { + const picInfo = chapter.content.picInfos[i2]; + const img = document.createElement("img"); + img.className = "fqa-comic-img fqa-comic-encrypted"; + img.alt = `第${i2 + 1}页`; + img.dataset.encryptedUrl = picInfo.picUrl; + img.dataset.encryptKey = chapter.content.encrypt_key; + img.dataset.pageIndex = i2.toString(); + img.style.minHeight = "500px"; + img.style.backgroundColor = "#f0f0f0"; + imgs.push(img); + } + const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); + if (readerContainer) { + const scriptContainer = ensureScriptContainer(readerContainer, true); + imgs.forEach((img) => scriptContainer.appendChild(img)); + const observer2 = new IntersectionObserver( + async (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const img = entry.target; + if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) { + observer2.unobserve(img); + try { + const encryptedBuffer = await fetchArrayBuffer( + img.dataset.encryptedUrl + ); + const decryptedBuffer = await decryptComicImage( + encryptedBuffer, + img.dataset.encryptKey + ); + const blob = new Blob([decryptedBuffer], { type: "image/jpeg" }); + const blobUrl = URL.createObjectURL(blob); + img.src = blobUrl; + img.style.minHeight = ""; + img.style.backgroundColor = ""; + img.onload = () => { + URL.revokeObjectURL(blobUrl); + }; + } catch (error) { + console.error(`解密图片失败 (页 ${img.dataset.pageIndex}):`, error); + img.alt = `第${Number(img.dataset.pageIndex) + 1}页 - 解密失败`; + img.style.backgroundColor = "#ffebee"; + } + } + } + } + }, + { + rootMargin: "200px" + } + ); + comicObserver = observer2; + imgs.forEach((img) => observer2.observe(img)); + } + } else { + const imgs = []; + for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { + const picInfo = chapter.content.picInfos[i2]; + const img = document.createElement("img"); + img.className = "fqa-comic-img"; + img.alt = `第${i2 + 1}页`; + img.src = picInfo.picUrl; + imgs.push(img); + } + const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); + if (readerContainer) { + const scriptContainer = ensureScriptContainer(readerContainer, true); + imgs.forEach((img) => scriptContainer.appendChild(img)); + } + } + } + const muyeReaderTitle = document.querySelector("h1.muye-reader-title"); + let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle"); + (_h = document.querySelector("#fqa-subtitle")) == null ? void 0 : _h.remove(); + if (muyeReaderSubtitle) { + let _cloned = cloneElement(muyeReaderSubtitle); + muyeReaderSubtitle.classList.add("fqa-hide"); + _cloned.id = "fqa-subtitle"; + muyeReaderSubtitle.insertAdjacentElement("afterend", _cloned); + muyeReaderSubtitle = _cloned; + _cloned.classList.remove("fqa-hide"); + console.log("clone subtitle: ", _cloned); + } + if (muyeReaderTitle) { + muyeReaderTitle.textContent = chapterTitle; + } + console.log("Current book:", currentBook); + if (!currentBook || currentBook == null || currentBook.book_id !== ((_i = chapter.novel_data) == null ? void 0 : _i.book_id)) { + currentBook = await getBookInfoAndCatalog((_j = chapter.novel_data) == null ? void 0 : _j.book_id); + console.log("Current book:", currentBook); + } + if (currentBook && currentBook.chapter_list) { + const currentChapterItem = currentBook.chapter_list.find((c) => c.item_id === itemId2); + if (currentChapterItem) { + console.log("Current chapter:", currentChapterItem); + document.title = currentChapterItem.title + " - " + currentBook.title + " - 番茄小说"; + if (document.getElementById("fqa-current-chapter-volume")) { + const c = document.getElementById("fqa-current-chapter-volume"); + if (c) { + c.textContent = currentChapterItem.volume_title; + } + } else { + const volSpan = document.createElement("span"); + volSpan.className = "desc-item"; + volSpan.id = "fqa-current-chapter-volume"; + volSpan.textContent = currentChapterItem.volume_title; + const c = muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.firstChild; + if (c) { + c.insertAdjacentElement("beforebegin", volSpan); + } + } + let updateTimeSpans = (muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.querySelectorAll("span.desc-item")) || []; + if (updateTimeSpans.length >= 2) { + let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1]; + let uttspan = updateTimeSpan.firstChild; + uttspan == null ? void 0 : uttspan.remove(); + updateTimeSpan.innerHTML = "更新时间:" + currentChapterItem.update_time; + } else { + let updateTimeSpan = document.createElement("span"); + updateTimeSpan.className = "desc-item"; + updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`; + } + } + } + if (state.open) { + await switchChapter(itemId2, { + cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", + title: (currentBook == null ? void 0 : currentBook.title) ?? document.title + }); + } else { + refreshParagraphs(); + } + } + async function startAudioPlay() { + var _a; + if (state.open) { + closeAudiobook(); + return; + } + const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; + if (!itemId2) return; + initAudioPanel(); + await openAudiobook(itemId2, (currentBook == null ? void 0 : currentBook.book_id) ?? "", { + cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", + title: (currentBook == null ? void 0 : currentBook.title) ?? document.title + }); + } + async function onUrlChange$1(_previous) { + await insertContent(); + } + async function onHashChange$1(_previous) { + } + async function onLoad$1() { + var _a; + const toolbar = document.querySelector("div.reader-toolbar > div"); + const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)"); + if (toolbarButton && toolbar) { + const c = cloneElement(toolbarButton); + c.id = "fqa-toggle-audiobook"; + const listenIcon = document.createElement("span"); + listenIcon.textContent = "听"; + listenIcon.style.width = "24px"; + listenIcon.style.height = "24px"; + listenIcon.style.fontSize = "24px"; + listenIcon.style.lineHeight = "24px"; + listenIcon.classList.add("muyeicon-icon"); + listenIcon.classList.add("reader-toolbar-item-icon"); + (_a = c.firstChild) == null ? void 0 : _a.replaceWith(listenIcon); + const l = c.lastChild; + if (l) { + l.textContent = "听书"; + } + c.addEventListener("click", () => void startAudioPlay()); + toolbar.appendChild(c); + } + document.querySelector("div.muye-reader-btns"); + await insertContent(); + } + function readerFilter(path, _query, _hash) { + return path.startsWith("/reader") || path.startsWith("reader"); + } + const _exports$5 = [ + { + id: "readerHook_load", + event: "load", + handler: onLoad$1, + filter: readerFilter + }, + { + id: "readerHook_urlChange", + event: "onUrlChange", + handler: onUrlChange$1, + filter: readerFilter + }, + { + id: "readerHook_hashChange", + event: "onHashChange", + handler: onHashChange$1, + filter: readerFilter + } + ]; + const blackList = [ + "mcs.zijieapi.com", + "vcs.zijieapi.com/vc/setting", + "mon.zijieapi.com", + "mssdk.bytedance.com/web/common", + "hm.baidu.com" + ]; + const BLOCKED_BODY = JSON.stringify({ + e: 0, + sc: 10, + tc: 10 + }); + function checkBlack(url) { + if (!settings$1.blockReport) return false; + return blackList.some((black) => url.includes(black)); + } + const originalFetch = unsafeWindow.fetch.bind(unsafeWindow); + unsafeWindow.fetch = function fetch2(input, init2) { + let url; + if (input instanceof Request) { + url = input.url; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input; + } + if (checkBlack(url)) { + console.log("blocked request: " + url); + return Promise.resolve(new Response(BLOCKED_BODY, { + status: 200, + headers: { "Content-Type": "application/json" } + })); + } + return originalFetch(input, init2); + }; + const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest; + unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { + constructor() { + super(...arguments); + __publicField(this, "_blockedUrl"); + } + open(method, url, async = true, user, password) { + if (checkBlack(url)) { + console.log("blocked request: " + url); + this._blockedUrl = url; + return; + } + this._blockedUrl = void 0; + super.open(method, url, async, user, password); + } + setRequestHeader(name2, value) { + if (this._blockedUrl !== void 0) return; + super.setRequestHeader(name2, value); + } + send(body) { + if (this._blockedUrl === void 0) { + super.send(body); + return; + } + const url = this._blockedUrl; + const shadow = (prop, value) => Object.defineProperty(this, prop, { configurable: true, get: () => value }); + setTimeout(() => { + shadow("readyState", 4); + shadow("status", 200); + shadow("statusText", "OK"); + shadow("responseURL", url); + shadow("responseText", this.responseType === "" || this.responseType === "text" ? BLOCKED_BODY : ""); + shadow("response", this.responseType === "json" ? { + "e": 0, + "sc": 10, + "tc": 10 + } : BLOCKED_BODY); + this.dispatchEvent(new Event("readystatechange")); + this.dispatchEvent(new ProgressEvent("load")); + this.dispatchEvent(new ProgressEvent("loadend")); + }, 0); + } + abort() { + if (this._blockedUrl !== void 0) return; + super.abort(); + } + getAllResponseHeaders() { + if (this._blockedUrl !== void 0) return "content-type: application/json\r\n"; + return super.getAllResponseHeaders(); + } + getResponseHeader(name2) { + if (this._blockedUrl !== void 0) { + return name2.toLowerCase() === "content-type" ? "application/json" : null; + } + return super.getResponseHeader(name2); + } + }; + const _exports$4 = []; + let userState = { + isLogin: false, + userInfo: null + }; + if (read("userState")) { + userState = read("userState"); + } + console.log("userState:", userState); + async function getDetailedUserInfo() { + if (!(userState == null ? void 0 : userState.isLogin) || !(userState == null ? void 0 : userState.userInfo)) { + return null; + } + if (userState.userInfo.gender !== void 0 && userState.userInfo.recommend_gender !== void 0 && userState.userInfo.fans_num !== void 0 && userState.userInfo.following_num !== void 0 && userState.userInfo.is_author !== void 0 && userState.userInfo.author_desc !== void 0 && userState.userInfo.read_book_num !== void 0 && userState.userInfo.read_book_time !== void 0) { + return userState.userInfo; + } + const response = await fetch$1("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967"); + const j = await response.json(); + if (j == null ? void 0 : j.data) { + const data = j.data; + userState.userInfo.gender = data.profile_gender; + userState.userInfo.recommend_gender = data.gender; + userState.userInfo.fans_num = data.fans_num; + userState.userInfo.following_num = data.follow_user_num; + userState.userInfo.is_author = data.is_author; + userState.userInfo.author_desc = data.author_desc; + userState.userInfo.read_book_num = data.read_book_num; + userState.userInfo.read_book_time = BigInt(data.read_book_time); + return userState.userInfo; + } + write("userState", userState); + return userState.userInfo; + } + async function checkLogin() { + var _a, _b, _c, _d, _e, _f; + const response = await fetch$1("https://fanqienovel.com/api/user/info/v2"); + const j = await response.json(); + const _userInfo = { + id: (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.id, + username: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.name, + avatar: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.avatar, + desc: (_d = j == null ? void 0 : j.data) == null ? void 0 : _d.desc, + age: (_e = j == null ? void 0 : j.data) == null ? void 0 : _e.age + }; + if (((_f = j == null ? void 0 : j.data) == null ? void 0 : _f.id) > 1) { + userState.isLogin = true; + userState.userInfo = _userInfo; + write("userState", userState); + return true; + } else { + del("userState"); + return false; + } + } + async function init() { + var _a; + await checkLogin(); + if (userState.isLogin) { + console.log("Hello, ", (_a = userState == null ? void 0 : userState.userInfo) == null ? void 0 : _a.username); + } + } + const bookshelf = '\r\n \r\n \r\n \r\n \r\n \r\n'; + const settings = '\r\n\r\n \r\n \r\n'; const name = "fanqie-assistant"; const version = "0.0.6"; const _hoisted_1$9 = { @@ -2663,59 +5383,68 @@ const _hoisted_9$6 = { class: "fqa-set-row" }; const _hoisted_10$6 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_11$5 = ["disabled"]; - const _hoisted_12$5 = { class: "fqa-set-row" }; - const _hoisted_13$4 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_14$4 = { + const _hoisted_12$5 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_13$4 = { class: "fqa-set-radios" }; + const _hoisted_14$4 = { class: "fqa-set-radio" }; + const _hoisted_15$2 = { class: "fqa-set-radio" }; + const _hoisted_16$1 = { class: "fqa-set-row" }; + const _hoisted_17$1 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_18$1 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; - const _hoisted_15$2 = { class: "fqa-set-row" }; - const _hoisted_16$1 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_17$1 = { class: "fqa-set-radios" }; - const _hoisted_18$1 = { class: "fqa-set-radio" }; - const _hoisted_19$1 = { class: "fqa-set-radio" }; + const _hoisted_19$1 = { class: "fqa-set-row" }; const _hoisted_20 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_21 = { class: "fqa-set-radios" }; const _hoisted_22 = { class: "fqa-set-radio" }; const _hoisted_23 = { class: "fqa-set-radio" }; const _hoisted_24 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_25 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; - const _hoisted_26 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; - const _hoisted_27 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; + const _hoisted_25 = { class: "fqa-set-radios" }; + const _hoisted_26 = { class: "fqa-set-radio" }; + const _hoisted_27 = { class: "fqa-set-radio" }; const _hoisted_28 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_29 = { class: "fqa-set-field" }; - const _hoisted_30 = { class: "fqa-set-field" }; - const _hoisted_31 = { class: "fqa-set-field" }; + const _hoisted_29 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; + const _hoisted_30 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; + const _hoisted_31 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; const _hoisted_32 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_33 = { class: "fqa-set-radios" }; - const _hoisted_34 = { class: "fqa-set-radio" }; - const _hoisted_35 = { class: "fqa-set-radio" }; + const _hoisted_33 = { class: "fqa-set-field" }; + const _hoisted_34 = { class: "fqa-set-field" }; + const _hoisted_35 = { class: "fqa-set-field" }; const _hoisted_36 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_37 = { class: "fqa-set-field" }; - const _hoisted_38 = ["placeholder"]; - const _hoisted_39 = { class: "fqa-set-field" }; - const _hoisted_40 = ["placeholder"]; - const _hoisted_41 = { class: "fqa-set-field" }; - const _hoisted_42 = ["placeholder"]; - const _hoisted_43 = { class: "fqa-set-actions" }; - const _hoisted_44 = ["disabled"]; - const _hoisted_45 = { + const _hoisted_37 = { class: "fqa-set-radios" }; + const _hoisted_38 = { class: "fqa-set-radio" }; + const _hoisted_39 = { class: "fqa-set-radio" }; + const _hoisted_40 = { class: "fqa-set-row" }; + const _hoisted_41 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_42 = { class: "fqa-set-radios" }; + const _hoisted_43 = { class: "fqa-set-radio" }; + const _hoisted_44 = { class: "fqa-set-radio" }; + const _hoisted_45 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_46 = { class: "fqa-set-field" }; + const _hoisted_47 = ["placeholder"]; + const _hoisted_48 = { class: "fqa-set-field" }; + const _hoisted_49 = ["placeholder"]; + const _hoisted_50 = { class: "fqa-set-field" }; + const _hoisted_51 = ["placeholder"]; + const _hoisted_52 = { class: "fqa-set-actions" }; + const _hoisted_53 = ["disabled"]; + const _hoisted_54 = { key: 0, class: "fqa-set-note" }; - const _hoisted_46 = { class: "fqa-set-note" }; - const _hoisted_47 = { class: "fqa-set-links" }; - const _hoisted_48 = { class: "fqa-set-link-row" }; - const _hoisted_49 = ["href"]; + const _hoisted_55 = { class: "fqa-set-note" }; + const _hoisted_56 = { class: "fqa-set-links" }; + const _hoisted_57 = { class: "fqa-set-link-row" }; + const _hoisted_58 = ["href"]; const GREASYFORK = "https://greasyfork.org/zh-CN/scripts/589115-%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E5%8A%A9%E6%89%8B"; const GITHUB = "https://github.com/naiyQAQ/fanqie-assistant"; const _sfc_main$9 = /* @__PURE__ */ vue.defineComponent({ @@ -2734,6 +5463,7 @@ { key: "ui", label: "界面" }, { key: "search", label: "搜索" }, { key: "download", label: "下载" }, + { key: "audiobook", label: "听书" }, { key: "protocol", label: "协议" }, { key: "about", label: "关于" } ]; @@ -2785,7 +5515,7 @@ onClick: close2 }, "✕"), vue.createElementVNode("nav", _hoisted_2$9, [ - _cache[24] || (_cache[24] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), + _cache[29] || (_cache[29] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, vue.renderList(SECTIONS, (s) => { return vue.createElementVNode("div", { key: s.key, @@ -2799,9 +5529,9 @@ ]), vue.createElementVNode("section", _hoisted_4$8, [ active.value === "general" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ - _cache[28] || (_cache[28] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), + _cache[33] || (_cache[33] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), vue.createElementVNode("label", _hoisted_5$8, [ - _cache[25] || (_cache[25] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), + _cache[30] || (_cache[30] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => vue.unref(settings$1).decryptFont = $event), type: "checkbox", @@ -2811,7 +5541,7 @@ ]) ]), vue.createElementVNode("label", _hoisted_6$8, [ - _cache[26] || (_cache[26] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), + _cache[31] || (_cache[31] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => vue.unref(settings$1).blockReport = $event), type: "checkbox", @@ -2821,7 +5551,7 @@ ]) ]), vue.createElementVNode("label", _hoisted_7$6, [ - _cache[27] || (_cache[27] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), + _cache[32] || (_cache[32] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => vue.unref(settings$1).allowCopy = $event), type: "checkbox", @@ -2831,9 +5561,9 @@ ]) ]) ], 64)) : active.value === "ui" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ - _cache[33] || (_cache[33] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), + _cache[42] || (_cache[42] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), vue.createElementVNode("div", _hoisted_8$6, [ - _cache[29] || (_cache[29] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), + _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => vue.unref(settings$1).readerFont = $event), class: "fqa-set-input", @@ -2842,10 +5572,10 @@ }, null, 512), [ [vue.vModelText, vue.unref(settings$1).readerFont] ]), - _cache[30] || (_cache[30] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) + _cache[35] || (_cache[35] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) ]), vue.createElementVNode("div", _hoisted_9$6, [ - _cache[31] || (_cache[31] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), + _cache[36] || (_cache[36] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => vue.unref(settings$1).customCssEnabled = $event), type: "checkbox", @@ -2864,138 +5594,164 @@ }, null, 8, _hoisted_11$5), [ [vue.vModelText, vue.unref(settings$1).customCss] ]), - _cache[32] || (_cache[32] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) + _cache[37] || (_cache[37] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) + ]), + vue.createElementVNode("div", _hoisted_12$5, [ + _cache[40] || (_cache[40] = vue.createElementVNode("span", { class: "fqa-set-label" }, "书架单击书本", -1)), + vue.createElementVNode("div", _hoisted_13$4, [ + vue.createElementVNode("label", _hoisted_14$4, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), + type: "radio", + value: "read" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] + ]), + _cache[38] || (_cache[38] = vue.createElementVNode("span", null, "继续阅读", -1)) + ]), + vue.createElementVNode("label", _hoisted_15$2, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), + type: "radio", + value: "detail" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] + ]), + _cache[39] || (_cache[39] = vue.createElementVNode("span", null, "查看详情", -1)) + ]) + ]), + _cache[41] || (_cache[41] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 按住 Ctrl 单击去往另一项;中键在新标签页打开,Ctrl+中键同样反向。 选「继续阅读」但这本书还没读过时,会退回详情页。 ", -1)) ]) ], 64)) : active.value === "search" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 2 }, [ - _cache[37] || (_cache[37] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), - vue.createElementVNode("label", _hoisted_12$5, [ - _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), + _cache[46] || (_cache[46] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), + vue.createElementVNode("label", _hoisted_16$1, [ + _cache[43] || (_cache[43] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).enhanceSearch = $event), + "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).enhanceSearch = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enhanceSearch] ]) ]), - vue.createElementVNode("div", _hoisted_13$4, [ - vue.createElementVNode("label", _hoisted_14$4, [ - _cache[35] || (_cache[35] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), + vue.createElementVNode("div", _hoisted_17$1, [ + vue.createElementVNode("label", _hoisted_18$1, [ + _cache[44] || (_cache[44] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).searchPersonalized = $event), + "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).searchPersonalized = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).searchPersonalized] ]) ]), - _cache[36] || (_cache[36] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) + _cache[45] || (_cache[45] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) ]) ], 64)) : active.value === "download" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ - _cache[58] || (_cache[58] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), - vue.createElementVNode("label", _hoisted_15$2, [ - _cache[38] || (_cache[38] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), + _cache[67] || (_cache[67] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), + vue.createElementVNode("label", _hoisted_19$1, [ + _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).enableDownload = $event), + "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).enableDownload = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enableDownload] ]) ]), - vue.createElementVNode("div", _hoisted_16$1, [ - _cache[41] || (_cache[41] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), - vue.createElementVNode("div", _hoisted_17$1, [ - vue.createElementVNode("label", _hoisted_18$1, [ + vue.createElementVNode("div", _hoisted_20, [ + _cache[50] || (_cache[50] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), + vue.createElementVNode("div", _hoisted_21, [ + vue.createElementVNode("label", _hoisted_22, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).downloadFormat = $event), + "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "epub" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), - _cache[39] || (_cache[39] = vue.createElementVNode("span", null, "EPUB", -1)) + _cache[48] || (_cache[48] = vue.createElementVNode("span", null, "EPUB", -1)) ]), - vue.createElementVNode("label", _hoisted_19$1, [ + vue.createElementVNode("label", _hoisted_23, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).downloadFormat = $event), + "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "txt" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), - _cache[40] || (_cache[40] = vue.createElementVNode("span", null, "TXT", -1)) + _cache[49] || (_cache[49] = vue.createElementVNode("span", null, "TXT", -1)) ]) ]), - _cache[42] || (_cache[42] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) + _cache[51] || (_cache[51] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) ]), - vue.createElementVNode("div", _hoisted_20, [ - _cache[45] || (_cache[45] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), - vue.createElementVNode("div", _hoisted_21, [ - vue.createElementVNode("label", _hoisted_22, [ + vue.createElementVNode("div", _hoisted_24, [ + _cache[54] || (_cache[54] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), + vue.createElementVNode("div", _hoisted_25, [ + vue.createElementVNode("label", _hoisted_26, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).downloadCharset = $event), + "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "utf-8" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), - _cache[43] || (_cache[43] = vue.createElementVNode("span", null, "UTF-8", -1)) + _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "UTF-8", -1)) ]), - vue.createElementVNode("label", _hoisted_23, [ + vue.createElementVNode("label", _hoisted_27, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).downloadCharset = $event), + "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "gbk" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), - _cache[44] || (_cache[44] = vue.createElementVNode("span", null, "GBK", -1)) + _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "GBK", -1)) ]) ]), - _cache[46] || (_cache[46] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) + _cache[55] || (_cache[55] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) ]), - vue.createElementVNode("div", _hoisted_24, [ - _cache[50] || (_cache[50] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), - vue.createElementVNode("label", _hoisted_25, [ - _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), + vue.createElementVNode("div", _hoisted_28, [ + _cache[59] || (_cache[59] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), + vue.createElementVNode("label", _hoisted_29, [ + _cache[56] || (_cache[56] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadImages = $event), + "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadImages = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadImages] ]) ]), - vue.createElementVNode("label", _hoisted_26, [ - _cache[48] || (_cache[48] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), + vue.createElementVNode("label", _hoisted_30, [ + _cache[57] || (_cache[57] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadBookCss = $event), + "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadBookCss = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadBookCss] ]) ]), - vue.createElementVNode("label", _hoisted_27, [ - _cache[49] || (_cache[49] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), + vue.createElementVNode("label", _hoisted_31, [ + _cache[58] || (_cache[58] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), + "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadVolumePage] ]) ]), - _cache[51] || (_cache[51] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) + _cache[60] || (_cache[60] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) ]), - vue.createElementVNode("div", _hoisted_28, [ - _cache[55] || (_cache[55] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), - _cache[56] || (_cache[56] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), - vue.createElementVNode("label", _hoisted_29, [ - _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), + vue.createElementVNode("div", _hoisted_32, [ + _cache[64] || (_cache[64] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), + _cache[65] || (_cache[65] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), + vue.createElementVNode("label", _hoisted_33, [ + _cache[61] || (_cache[61] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), + "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), class: "fqa-set-input", type: "number", min: "1", @@ -3009,10 +5765,10 @@ ] ]) ]), - vue.createElementVNode("label", _hoisted_30, [ - _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), + vue.createElementVNode("label", _hoisted_34, [ + _cache[62] || (_cache[62] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadInterval = $event), + "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).downloadInterval = $event), class: "fqa-set-input", type: "number", min: "0", @@ -3027,10 +5783,10 @@ ] ]) ]), - vue.createElementVNode("label", _hoisted_31, [ - _cache[54] || (_cache[54] = vue.createElementVNode("span", null, "重试轮数", -1)), + vue.createElementVNode("label", _hoisted_35, [ + _cache[63] || (_cache[63] = vue.createElementVNode("span", null, "重试轮数", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadRetries = $event), + "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).downloadRetries = $event), class: "fqa-set-input", type: "number", min: "0", @@ -3044,7 +5800,7 @@ ] ]) ]), - _cache[57] || (_cache[57] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), + _cache[66] || (_cache[66] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), vue.createElementVNode("div", { class: "fqa-set-actions" }, [ vue.createElementVNode("button", { class: "fqa-set-btn", @@ -3052,85 +5808,124 @@ }, "恢复推荐值") ]) ]) - ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ - _cache[68] || (_cache[68] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), - vue.createElementVNode("div", _hoisted_32, [ - _cache[61] || (_cache[61] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), - vue.createElementVNode("div", _hoisted_33, [ - vue.createElementVNode("label", _hoisted_34, [ + ], 64)) : active.value === "audiobook" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ + _cache[73] || (_cache[73] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "听书", -1)), + vue.createElementVNode("div", _hoisted_36, [ + _cache[70] || (_cache[70] = vue.createElementVNode("span", { class: "fqa-set-label" }, "读完一章后", -1)), + vue.createElementVNode("div", _hoisted_37, [ + vue.createElementVNode("label", _hoisted_38, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).apiPreference = $event), + "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), + type: "radio", + value: "next" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] + ]), + _cache[68] || (_cache[68] = vue.createElementVNode("span", null, "自动下一章", -1)) + ]), + vue.createElementVNode("label", _hoisted_39, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), + type: "radio", + value: "stop" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] + ]), + _cache[69] || (_cache[69] = vue.createElementVNode("span", null, "停止播放", -1)) + ]) + ]), + _cache[71] || (_cache[71] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 自动下一章会点击页面上的「下一章」,读到全书最后一章时自动停下。 ", -1)) + ]), + vue.createElementVNode("label", _hoisted_40, [ + _cache[72] || (_cache[72] = vue.createElementVNode("span", { class: "fqa-set-label" }, "高亮并跟随朗读段落", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).audiobookFollow = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings$1).audiobookFollow] + ]) + ]), + _cache[74] || (_cache[74] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 关闭后仍会高亮当前段落,但不再自动滚动页面。点击任意段落可跳到该处朗读。 ", -1)) + ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 5 }, [ + _cache[84] || (_cache[84] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), + vue.createElementVNode("div", _hoisted_41, [ + _cache[77] || (_cache[77] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), + vue.createElementVNode("div", _hoisted_42, [ + vue.createElementVNode("label", _hoisted_43, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[24] || (_cache[24] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "app" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), - _cache[59] || (_cache[59] = vue.createElementVNode("span", null, "番茄 APP", -1)) + _cache[75] || (_cache[75] = vue.createElementVNode("span", null, "番茄 APP", -1)) ]), - vue.createElementVNode("label", _hoisted_35, [ + vue.createElementVNode("label", _hoisted_44, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).apiPreference = $event), + "onUpdate:modelValue": _cache[25] || (_cache[25] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "redcandle" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), - _cache[60] || (_cache[60] = vue.createElementVNode("span", null, "红烛 APP", -1)) + _cache[76] || (_cache[76] = vue.createElementVNode("span", null, "红烛 APP", -1)) ]) ]), - _cache[62] || (_cache[62] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) + _cache[78] || (_cache[78] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) ]), - vue.createElementVNode("div", _hoisted_36, [ - _cache[66] || (_cache[66] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), - _cache[67] || (_cache[67] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), - vue.createElementVNode("label", _hoisted_37, [ - _cache[63] || (_cache[63] = vue.createElementVNode("span", null, "device_id", -1)), + vue.createElementVNode("div", _hoisted_45, [ + _cache[82] || (_cache[82] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), + _cache[83] || (_cache[83] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), + vue.createElementVNode("label", _hoisted_46, [ + _cache[79] || (_cache[79] = vue.createElementVNode("span", null, "device_id", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).deviceId = $event), + "onUpdate:modelValue": _cache[26] || (_cache[26] = ($event) => vue.unref(settings$1).deviceId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_id || "自动注册" - }, null, 8, _hoisted_38), [ + }, null, 8, _hoisted_47), [ [vue.vModelText, vue.unref(settings$1).deviceId] ]) ]), - vue.createElementVNode("label", _hoisted_39, [ - _cache[64] || (_cache[64] = vue.createElementVNode("span", null, "install_id (iid)", -1)), + vue.createElementVNode("label", _hoisted_48, [ + _cache[80] || (_cache[80] = vue.createElementVNode("span", null, "install_id (iid)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).installId = $event), + "onUpdate:modelValue": _cache[27] || (_cache[27] = ($event) => vue.unref(settings$1).installId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.install_id || "自动注册" - }, null, 8, _hoisted_40), [ + }, null, 8, _hoisted_49), [ [vue.vModelText, vue.unref(settings$1).installId] ]) ]), - vue.createElementVNode("label", _hoisted_41, [ - _cache[65] || (_cache[65] = vue.createElementVNode("span", null, "device_type", -1)), + vue.createElementVNode("label", _hoisted_50, [ + _cache[81] || (_cache[81] = vue.createElementVNode("span", null, "device_type", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).deviceType = $event), + "onUpdate:modelValue": _cache[28] || (_cache[28] = ($event) => vue.unref(settings$1).deviceType = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_type || "自动注册" - }, null, 8, _hoisted_42), [ + }, null, 8, _hoisted_51), [ [vue.vModelText, vue.unref(settings$1).deviceType] ]) ]), - vue.createElementVNode("div", _hoisted_43, [ + vue.createElementVNode("div", _hoisted_52, [ vue.createElementVNode("button", { class: "fqa-set-btn", disabled: registering.value, onClick: reRegister - }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_44), - registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_45, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) + }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_53), + registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_54, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) ]) ]) - ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 5 }, [ - _cache[73] || (_cache[73] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), - vue.createElementVNode("p", _hoisted_46, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), - vue.createElementVNode("div", _hoisted_47, [ + ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 6 }, [ + _cache[89] || (_cache[89] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), + vue.createElementVNode("p", _hoisted_55, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), + vue.createElementVNode("div", _hoisted_56, [ vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ - _cache[69] || (_cache[69] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), + _cache[85] || (_cache[85] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), vue.createElementVNode("a", { href: GREASYFORK, target: "_blank", @@ -3138,31 +5933,31 @@ }, "跳转") ]), vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ - _cache[70] || (_cache[70] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), + _cache[86] || (_cache[86] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), vue.createElementVNode("a", { href: GITHUB, target: "_blank", rel: "noreferrer noopener" }, "跳转") ]), - vue.createElementVNode("div", _hoisted_48, [ - _cache[72] || (_cache[72] = vue.createElementVNode("span", null, "问题反馈:", -1)), + vue.createElementVNode("div", _hoisted_57, [ + _cache[88] || (_cache[88] = vue.createElementVNode("span", null, "问题反馈:", -1)), vue.createElementVNode("a", { href: FEEDBACK, target: "_blank", rel: "noreferrer noopener" }, "GreasyFork"), vue.createElementVNode("span", null, [ - _cache[71] || (_cache[71] = vue.createTextVNode(" 或 ", -1)), + _cache[87] || (_cache[87] = vue.createTextVNode(" 或 ", -1)), vue.createElementVNode("a", { href: `${GITHUB}/issues`, target: "_blank", rel: "noreferrer noopener" - }, " GitHub Issues ", 8, _hoisted_49) + }, " GitHub Issues ", 8, _hoisted_58) ]) ]) ]), - _cache[74] || (_cache[74] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ + _cache[90] || (_cache[90] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ vue.createElementVNode("p", null, " 本脚本基于 GNU General Public License 3.0 授权,完全开源且免费,修改/二次开发请注意遵守开源协议。 "), vue.createElementVNode("p", null, "本脚本使用 TypeScript + Vue 开发,请避免直接修改编译产物。") ], -1)) @@ -3338,7 +6133,32 @@ handler: guestHook } ]; - const _hoisted_1$8 = ["aria-label"]; + const MIDDLE_BUTTON = 1; + function modifiersOf(event) { + return { + flip: event.ctrlKey || event.metaKey, + newTab: "button" in event && event.button === MIDDLE_BUTTON + }; + } + function isMiddleButton(event) { + return event.button === MIDDLE_BUTTON; + } + function readerUrl(chapterId) { + return `https://fanqienovel.com/reader/${chapterId}`; + } + function bookPageUrl(bookId2) { + return `https://fanqienovel.com/page/${bookId2}`; + } + function openUrl(url, newTab = false) { + if (!newTab) { + unsafeWindow.location.href = url; + return; + } + if (!unsafeWindow.open(url, "_blank")) { + console.warn("[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:", url); + } + } + const _hoisted_1$8 = ["aria-label", "onKeydown"]; const _hoisted_2$8 = { class: "fqa-cover" }; const _hoisted_3$8 = ["src", "alt"]; const _hoisted_4$7 = { @@ -3391,6 +6211,17 @@ event.preventDefault(); emit("contextmenu", { entry: props.entry, x: event.clientX, y: event.clientY }); } + function open2(event) { + emit("open", { entry: props.entry, mods: modifiersOf(event) }); + } + function onAuxClick(event) { + if (!isMiddleButton(event)) return; + event.preventDefault(); + open2(event); + } + function onMouseDown(event) { + if (isMiddleButton(event)) event.preventDefault(); + } const imgLoaded = vue.ref(false); vue.watch( () => { @@ -3433,19 +6264,21 @@ onMouseenter: onEnter2, onMouseleave: _cache[2] || (_cache[2] = ($event) => emit("leave")), onContextmenu: onContextMenu, - onClick: _cache[3] || (_cache[3] = ($event) => emit("open", __props.entry)), + onClick: open2, + onAuxclick: onAuxClick, + onMousedown: onMouseDown, onKeydown: [ - _cache[4] || (_cache[4] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.entry), ["prevent"]), ["enter"])), - _cache[5] || (_cache[5] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.entry), ["prevent"]), ["space"])) + vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["enter"]), + vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["space"]) ] }, [ !detail.value ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ - _cache[6] || (_cache[6] = vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1)), - _cache[7] || (_cache[7] = vue.createElementVNode("div", { + _cache[3] || (_cache[3] = vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1)), + _cache[4] || (_cache[4] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "90%" } }, null, -1)), - _cache[8] || (_cache[8] = vue.createElementVNode("div", { + _cache[5] || (_cache[5] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "55%" } }, null, -1)) @@ -3990,10 +6823,10 @@ } return results; } - function identify(bookId, modifyTime = 0) { + function identify(bookId2, modifyTime = 0) { return { asterisked: false, - book_id: bookId, + book_id: bookId2, book_type: 0, modify_time: modifyTime }; @@ -4011,23 +6844,23 @@ } return json; } - async function addToBookshelf(bookId) { + async function addToBookshelf(bookId2) { await shelfPost("/add", { add_book_source: 0, - identify_data: [identify(bookId)] + identify_data: [identify(bookId2)] }); } - async function removeFromBookshelf(bookId) { + async function removeFromBookshelf(bookId2) { await shelfPost("/delete", { - identify_data: [identify(bookId, Date.now())] + identify_data: [identify(bookId2, Date.now())] }); } - async function moveToGroup(bookId, groupName) { + async function moveToGroup(bookId2, groupName) { await shelfPost("/update", { book_data: [ { asterisked: false, - book_id: bookId, + book_id: bookId2, book_type: 0, group_name: groupName, has_shown: false, @@ -4254,7 +7087,7 @@ function hasContent(chapter) { return Boolean(chapter == null ? void 0 : chapter.content) && chapter.content !== "Invalid"; } - async function fetchChapters(itemIds, bookId, task, options = {}) { + async function fetchChapters(itemIds, bookId2, task, options = {}) { var _a; const batchSize = Math.min( Math.max(1, options.batchSize ?? settings$1.downloadBatchSize), @@ -4277,13 +7110,13 @@ task.throwIfCancelled(); const batch = batches[i2]; try { - const result = await getChapters(batch, bookId); - for (const itemId of batch) { - const chapter = result[itemId]; + const result = await getChapters(batch, bookId2); + for (const itemId2 of batch) { + const chapter = result[itemId2]; if (hasContent(chapter)) { - chapters[itemId] = chapter; + chapters[itemId2] = chapter; } else { - missed.push(itemId); + missed.push(itemId2); } } } catch (err) { @@ -5519,10 +8352,10 @@ ${body} } return void 0; } - async function resolveMeta(bookId, chapters) { + async function resolveMeta(bookId2, chapters) { var _a; try { - const raw = await getBookInfoRaw(bookId); + const raw = await getBookInfoRaw(bookId2); if (raw == null ? void 0 : raw.book_id) return toBookMeta(raw); } catch (err) { console.warn("[fqa:download] 获取书籍详情失败,改用章节里的信息:", err); @@ -5531,9 +8364,9 @@ ${body} var _a2; return (_a2 = c.novel_data) == null ? void 0 : _a2.book_id; })) == null ? void 0 : _a.novel_data; - return toBookMeta(fallback ?? { book_id: bookId }); + return toBookMeta(fallback ?? { book_id: bookId2 }); } - async function startDownload(bookId, options = {}) { + async function startDownload(bookId2, options = {}) { if (running) { console.warn("[fqa:download] 已有下载任务在进行"); return; @@ -5544,16 +8377,16 @@ ${body} setActiveTask(task); try { task.stage("获取目录…"); - const catalogResult = await getCatalog(bookId); + const catalogResult = await getCatalog(bookId2); const catalog = catalogResult.chapter_list; if (catalog.length === 0) throw new Error("目录为空"); task.throwIfCancelled(); const titleMap = new Map(catalog.map((item) => [item.item_id, item.title])); const { chapters, failed } = await fetchChapters( catalog.map((item) => item.item_id), - bookId, + bookId2, task, - { titleOf: (itemId) => titleMap.get(itemId) } + { titleOf: (itemId2) => titleMap.get(itemId2) } ); task.throwIfCancelled(); if (Object.keys(chapters).length === 0) { @@ -5562,9 +8395,9 @@ ${body} if (failed.length > 0) { console.warn(`[fqa:download] ${failed.length} 章缺失,仍然继续导出`); } - const meta = await resolveMeta(bookId, chapters); + const meta = await resolveMeta(bookId2, chapters); task.throwIfCancelled(); - const base = sanitizeFilename(`${meta.title}_${meta.author}`, bookId); + const base = sanitizeFilename(`${meta.title}_${meta.author}`, bookId2); if (format === "epub") { const blob = await buildEpub({ meta, @@ -5787,12 +8620,16 @@ ${body} function onGroupVisible(entries) { queueDetails(entries); } - function openBook(entry) { + function continueReadingUrl(entry) { var _a; - hideHover(true); const chapterId = ((_a = entry.detail) == null ? void 0 : _a.current_chapter_id) || entry.item.last_read_chapter_id; - const url = chapterId && chapterId !== "0" ? `https://fanqienovel.com/reader/${chapterId}` : `https://fanqienovel.com/page/${entry.item.book_id}`; - unsafeWindow.location.href = url; + return chapterId && chapterId !== "0" ? readerUrl(chapterId) : null; + } + function openBook({ entry, mods }) { + hideHover(true); + const wantRead = settings$1.bookshelfClickAction === "read" !== mods.flip; + const url = wantRead && continueReadingUrl(entry) || bookPageUrl(entry.item.book_id); + openUrl(url, mods.newTab); } function openGroup(group) { hideHover(true); @@ -5819,7 +8656,7 @@ ${body} const targets = groups.value.filter((g2) => g2.name !== current).map((g2) => ({ key: `${MOVE_PREFIX}${g2.name}`, label: g2.name })); if (current) targets.push({ key: NO_GROUP_KEY, label: "无分组" }); const items = [ - { key: "open", label: "打开" }, + { key: "read", label: "继续阅读" }, { key: "detail", label: "查看详情" }, { key: "move", @@ -5851,24 +8688,24 @@ ${body} var _a; const entry = menuEntry.value; if (!entry) return; - const bookId = entry.item.book_id; - if (key === "open") { - openBook(entry); + const bookId2 = entry.item.book_id; + if (key === "read") { + openUrl(continueReadingUrl(entry) ?? bookPageUrl(bookId2)); return; } if (key === "detail") { - unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`; + openUrl(bookPageUrl(bookId2)); return; } if (key.startsWith(DOWNLOAD_PREFIX$1)) { const format = key.slice(DOWNLOAD_PREFIX$1.length); - void startDownload(bookId, { format }); + void startDownload(bookId2, { format }); return; } if (key === "remove") { - if (!unsafeWindow.confirm(`确定要把《${((_a = entry.detail) == null ? void 0 : _a.title) ?? bookId}》从书架删除吗?`)) return; + if (!unsafeWindow.confirm(`确定要把《${((_a = entry.detail) == null ? void 0 : _a.title) ?? bookId2}》从书架删除吗?`)) return; try { - await removeFromBookshelf(bookId); + await removeFromBookshelf(bookId2); showToast("已从书架删除"); await refresh(); } catch (err) { @@ -5880,7 +8717,7 @@ ${body} if (key.startsWith(MOVE_PREFIX)) { const groupName = key.slice(MOVE_PREFIX.length); try { - await moveToGroup(bookId, groupName); + await moveToGroup(bookId2, groupName); showToast(groupName ? `已移动到「${groupName}」` : "已移出分组"); await refresh(); } catch (err) { @@ -6309,11 +9146,11 @@ ${body} } function normalizeBook(raw, cell) { var _a; - const bookId = String((raw == null ? void 0 : raw.book_id) ?? ""); - if (!bookId || bookId === "0") return null; + const bookId2 = String((raw == null ? void 0 : raw.book_id) ?? ""); + if (!bookId2 || bookId2 === "0") return null; const hl = cell == null ? void 0 : cell.search_high_light; return { - book_id: bookId, + book_id: bookId2, title: raw.book_name || raw.original_book_name || "", author: raw.author || "", cover_url: raw.thumb_url || raw.audio_thumb_url_hd || "", @@ -6542,8 +9379,8 @@ ${body} loading.value = false; } } - function openBook(bookId) { - unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`; + function openBook(bookId2) { + unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId2}`; } vue.onMounted(load2); return (_ctx, _cache) => { @@ -6707,14 +9544,14 @@ ${body} if (next !== BOOK_TAB_TYPE) selected.value = {}; void run(true); } - function toggleFilter(rowName, itemId) { + function toggleFilter(rowName, itemId2) { const cur = selected.value[rowName]; - if (cur === itemId) { + if (cur === itemId2) { const next = { ...selected.value }; delete next[rowName]; selected.value = next; } else { - selected.value = { ...selected.value, [rowName]: itemId }; + selected.value = { ...selected.value, [rowName]: itemId2 }; } void run(true); } @@ -7271,7 +10108,7 @@ ${body} } const FUNCTIONAL_CLASSES = ["add-bookshelf-btn", "info-btn"]; const BUTTON_GAP = 10; - function createButton(anchor, bookId) { + function createButton(anchor, bookId2) { const btn = document.createElement("button"); btn.type = "button"; const classes = Array.from(anchor.classList).filter((c) => !FUNCTIONAL_CLASSES.includes(c)); @@ -7282,7 +10119,7 @@ ${body} btn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - void startDownload(bookId, { format: settings$1.downloadFormat }); + void startDownload(bookId2, { format: settings$1.downloadFormat }); }); return btn; } @@ -7298,7 +10135,7 @@ ${body} btn.style.bottom = "0"; btn.style.left = left; } - function inject(bookId) { + function inject(bookId2) { const anchors = Array.from(document.querySelectorAll(ANCHOR_SELECTOR)); if (anchors.length === 0) return; const existing = document.querySelector(`.${ENTRY_CLASS}`); @@ -7308,7 +10145,7 @@ ${body} } const anchor = anchors[anchors.length - 1]; if (!anchor.parentElement) return; - const btn = createButton(anchor, bookId); + const btn = createButton(anchor, bookId2); anchor.insertAdjacentElement("afterend", btn); placeButton(btn, anchors); } @@ -7323,16 +10160,16 @@ ${body} teardown(); return; } - const bookId = parseBookId(path); - if (!bookId) return; + const bookId2 = parseBookId(path); + if (!bookId2) return; injectStyle$1(); teardown(); await waitForElement(ANCHOR_SELECTOR, 8e3); if (window.location.pathname !== path) return; - inject(bookId); + inject(bookId2); observer = new MutationObserver(() => { if (window.location.pathname !== path) return; - inject(bookId); + inject(bookId2); }); observer.observe(document.body, { childList: true, subtree: true }); } diff --git a/src/api/app.ts b/src/api/app.ts index 06ca7fd..4600632 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -1,10 +1,16 @@ -import apiFetch from '../utils/request' +import apiFetch, { isEmptyResponse } from '../utils/request' import { signRequest } from '../crypto/sign' import config, { fetch as pageFetch } from '../config' import { settings } from '../settings' +import { replaceDevice, markDeviceHealthy } from './provision' export const appBaseUrl = 'https://reading.snssdk.com/reading' +/** + * APP 网关的根地址。绝大多数接口挂在 /reading 下(见 appBaseUrl), + * 但书评那套在根路径 /novel/commentapi/*,套上 /reading 会 404。 + */ +export const appRootUrl = 'https://reading.snssdk.com' export const redcandleBaseUrl = 'https://api5-sinfonlinec.jxbhmy.com/reading' /** * 番茄网页站同源挂载的 APP 接口。走页面 fetch 时浏览器会自动带上 Cookie, @@ -33,6 +39,7 @@ export function buildAppQuery(extra?: Record): URLSearchParams { device_brand: c.device_brand || 'realme', update_version_code: '70132', manifest_version_code: '70132', + pv_player: '70132', ...extra, }) } @@ -66,6 +73,7 @@ function isUsable(res: any): boolean { const j = res.json() return !j || j.code === undefined || j.code === 0 } catch { + // 包括空响应体(设备被作废),一律当作不可用,交给下一个通道 return false } } @@ -90,6 +98,35 @@ async function requestRedcandle(path: string, query?: Record, he } +/** + * 打番茄接口,遇到「设备被作废」自动换设备重试一次。 + * + * 服务端作废设备的表现是 HTTP 200 + 完全空的响应体,没有错误码可判断 + * (详见 provision.replaceDevice)。这里是所有 /reading/* GET 的唯一出口, + * 集中处理比每个调用点各写一遍可靠。 + */ +async function requestAppWithRecovery( + path: string, + query?: Record, + headers?: Record, +) { + const res = await requestApp(path, query, headers) + if (!isEmptyResponse(res)) { + // 有响应就说明网关认这台设备,解除替换锁 + markDeviceHealthy() + return res + } + + console.warn(`[fqa:api] ${path} 返回空响应体,判定当前设备已失效`) + // 换设备失败就把空响应交回去,让调用方按原有逻辑报错 + if (!(await replaceDevice())) return res + + const retry = await requestApp(path, query, headers) + if (!isEmptyResponse(retry)) markDeviceHealthy() + return retry +} + + export async function appGet( path: string, query?: Record, @@ -104,7 +141,7 @@ export async function appGet( console.warn(`[fqa:api] 红烛接口请求失败,回落到番茄 APP: ${path}`, e) } } - return requestApp(path, query, headers) + return requestAppWithRecovery(path, query, headers) } @@ -114,7 +151,30 @@ export async function appPost( query?: Record, headers?: Record, ): Promise { - const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}` + return postSigned(appBaseUrl + path, body, query, headers) +} + + +/** + * 打 APP 网关根路径(不带 /reading 前缀)的 POST 接口,目前只有书评在用。 + */ +export async function appRootPost( + path: string, + body: string, + query?: Record, + headers?: Record, +): Promise { + return postSigned(appRootUrl + path, body, query, headers) +} + + +async function postSigned( + base: string, + body: string, + query?: Record, + headers?: Record, +): Promise { + const url = `${base}?${buildAppQuery(query).toString()}` const signed = await signRequest(url, body) console.log('---start--- APP POST ', url) const res = await apiFetch(url, { diff --git a/src/api/audiobook.ts b/src/api/audiobook.ts new file mode 100644 index 0000000..9be0078 --- /dev/null +++ b/src/api/audiobook.ts @@ -0,0 +1,68 @@ +import { appGet } from './app' +import { decryptSpadeA } from '../crypto/spade' +import type { CencAudioContext, Tone, ParagraphTimeTag } from '../types' + +export async function getPlayInfo(item_id: string[] | string, tone_id: number): Promise { + const item_ids = Array.isArray(item_id) ? item_id.join(',') : item_id + const resp = await appGet('/reader/audio/playinfo/', { + item_ids, + tone_id: tone_id.toString() + }) + const j = resp.json() + const results: CencAudioContext[] = [] + if (Array.isArray(j.data)) { + for (let i of j.data) { + results.push({ + urls: [i.main_url, i.backup_url].filter(Boolean), + vid: i.vid, + key: i.is_encrypt ? decryptSpadeA(i.encryption_key) : '', + item_id: i.item_id + }) + } + } + return results +} + +export async function getBookAvailableTones(book_id: string): Promise { + const resp = await appGet('/bookapi/audio/toneinfo/', { book_id }) + const j = resp.json() + const results: Tone[] = [] + + if (Array.isArray(j?.data?.tts_tones)) { + for (let i of j.data.tts_tones) { + results.push({ + id: i.id, + name: i.title, + gender: i.tone_gender, + icon: i.icon_url, + description: i.description + }) + } + } + return results +} + +export async function getChapterParagraphTimeTag(item_id: string, tone_id: number): Promise { + // req_type=1 必须带:不带的话响应变成 para_match_data.text_para_match_data + // (按 book_id 分组、只有 start_time 没有 end_time);带上才是 data.time_points。 + // 另外响应里的 is_title 实测恒为 false,不能用来判断标题,只能看 start_para + const resp = await appGet('/reader/audio/timepoint/', { + item_id, + tone_id: tone_id.toString(), + req_type: '1' + }) + const j = resp.json() + const results: ParagraphTimeTag[] = [] + if (Array.isArray(j?.data?.time_points)) { + for (let i of j.data.time_points) { + results.push({ + startms: i.start_time, + endms: i.end_time, + startidx: i.start_para, + endidx: i.end_para, + is_title: i.start_para === 10000 + }) + } + } + return results +} \ No newline at end of file diff --git a/src/api/comment.ts b/src/api/comment.ts new file mode 100644 index 0000000..d20ac53 --- /dev/null +++ b/src/api/comment.ts @@ -0,0 +1,478 @@ +// 书评(评论 / 回复 / 点赞)。 +// +// 接口挂在 APP 网关的根路径 /novel/commentapi/*,不在 /reading 下面。 +// 网页站只把 /reading 反代到了 APP 网关,所以这里没有同源可用, +// 必须走 GM_xmlhttpRequest 打 reading.snssdk.com,并自己带 Cookie。 +// +// 实测要点(temp/probe 逐项验证): +// - 三个接口都要签名(x-argus/x-ladon/x-khronos + 有 body 时 X-SS-STUB), +// 不签名一律 code=110001「未知异常」。仓库现成的 signRequest 直接可用, +// query 用精简版(7.0.1.32)也照过,不必照搬抓包里那一长串。 +// - 抓包里的 comment-source / server-channel / lc / sdk-version 这些请求头可省, +// 真正起作用的是 body 里的同名字段。 +// - 读列表不需要登录态;但 user_digg(我赞过没)只有带 sessionid 才准。 +// 带登录态时,接口会把「自己打的分 / 写的书评」从 data_list 里抽出来, +// 单独放进 extra.user_comment(所以列表看着少一条,不是分页 bug)。 +// - do_action / comment/add 这类写操作必须带 sessionid,否则 code=103006「用户未登录」。 +// x-tt-token 单独给不管用(那是 APP 的登录票据,与网页 sessionid 不同源)。 +// +// 请求体的必填字段比看上去多,缺一个就 code=103001「参数错误」: +// comment/list:business_param.book_id + fold_type、comment_source、comment_type、 +// group_id、group_type、server_channel、sort —— 一个都不能少 +// reply/list:business_param.book_id、comment_id、comment_source、comment_type、group_type +// do_action:action_type、comment_type、object_id、object_type(去掉 comment_type 会 502) +// comment/add:business_param.book_id + score、group_id、group_type、comment_type、 +// data_type、commit_source、text、rich_text。抓包里的 shark_param / +// text_feature 是埋点,对照 do_action 的先例按可省处理,只保留结构字段。 + +import { appRootPost } from './app' +import { getSessionId, COOKIE_PERMISSION_HINT } from '../utils/cookie' +import type { + BookComment, + CommentBase, + CommentFilterTag, + CommentListResult, + CommentReply, + CommentSort, + CommentTextExt, + CommentUser, + ReplyListResult, +} from '../types' + +/** comment_type=2 是书评。段评/章评是别的值,这里只做书评 */ +const COMMENT_TYPE = 2 + +/** + * 写书评(comment/add)时 comment_type 用的是 0,而不是读列表的 2。 + * 服务端就是这么不对称的,照抓包填 0,填 2 会归错类。 + */ +const ADD_COMMENT_TYPE = 0 + +/** group_type=1 表示 group_id 是书 id */ +const GROUP_TYPE = 1 + +/** 书评区(comment/list 只认这个值,501/1002 会报参数错误) */ +const SOURCE_BOOK = 1 + +/** 评论详情页,reply/list 打一级评论时用 */ +const SOURCE_COMMENT_REPLY = 501 + +/** 回复详情页,reply/list 打二级回复时用,且必须带 ref_reply_id */ +const SOURCE_REPLY_REPLY = 1002 + +/** comment/list 单页上限。101 起服务端只回 1 条,不报错 */ +export const MAX_COMMENT_COUNT = 100 + +/** do_action 的动作码 */ +export const CommentAction = { + Like: 8, + Unlike: 9, + Dislike: 10, + Undislike: 11, +} as const + +export type CommentAction = (typeof CommentAction)[keyof typeof CommentAction] + +/** 点赞等写操作缺少登录态 */ +export class NotLoggedInError extends Error { + constructor(message = COOKIE_PERMISSION_HINT) { + super(message) + this.name = 'NotLoggedInError' + } +} + +/* ------------------------------- 响应解析 ------------------------------- */ + +function str(v: unknown): string { + return typeof v === 'string' ? v : v == null ? '' : String(v) +} + +function int(v: unknown): number { + const n = Number(v) + return Number.isFinite(n) ? Math.trunc(n) : 0 +} + +/** + * 头衔名。接口把它塞成内嵌 JSON 字符串: + * intro: '{"title_name":"vip","title_id":"vip","icon_type":1}' + * 解析失败就退回 zh_title。 + */ +function titleName(raw: any): string { + if (typeof raw?.intro === 'string' && raw.intro) { + try { + const parsed = JSON.parse(raw.intro) + const name = str(parsed?.title_name) + if (name) return name + } catch { + // 服务端改了格式,用下面的兜底 + } + } + return str(raw?.zh_title || raw?.en_title) +} + +function parseUser(raw: any): CommentUser { + const base = raw?.base_info ?? {} + const tag = raw?.user_tag ?? {} + return { + user_id: str(base.user_id || raw?.user_id), + name: str(base.user_name), + avatar: str(base.user_avatar), + description: str(base.description), + is_author: Boolean(tag.is_author), + // 蓝 V 也算会员标记,展示上不区分 + is_vip: Boolean(tag.is_vip || tag.is_blue_vip), + titles: Array.isArray(tag.user_title_info) + ? tag.user_title_info.map(titleName).filter(Boolean) + : [], + } +} + +function parseExts(raw: any): CommentTextExt[] { + if (!Array.isArray(raw)) return [] + const out: CommentTextExt[] = [] + for (const e of raw) { + const text = str(e?.text) + if (!text) continue + out.push({ + start: int(e?.s), + end: int(e?.e), + text, + type: int(e?.tp), + uri: str(e?.uri) || undefined, + }) + } + // 服务端未保证有序,渲染时要按位置切片 + return out.sort((a, b) => a.start - b.start) +} + +/** + * 评论与回复的公共部分。 + * + * 注意大小写:一级评论用 `common`,回复用 `Common`(服务端就是这么不一致的), + * 两边都要试。 + */ +function parseBase(raw: any, id: string): CommentBase { + const common = raw?.common ?? raw?.Common ?? {} + const content = common.content ?? {} + const stat = raw?.stat ?? {} + const action = raw?.user_action ?? {} + return { + id, + text: str(content.text), + exts: parseExts(content.text_exts), + create_time: int(common.create_timestamp), + user: parseUser(common.user_info), + digg_count: int(stat.digg_count), + reply_count: int(stat.reply_count), + user_digg: Boolean(action.user_digg), + user_disagree: Boolean(action.user_disagree), + } +} + +function parseComment(raw: any): BookComment | null { + const id = str(raw?.comment_id) + if (!id) return null + const expand = raw?.expand ?? {} + const action = raw?.user_action ?? {} + + const addition = raw?.addition_comment + const additionId = str(addition?.comment_id) + + return { + ...parseBase(raw, id), + score: int(expand.score), + score_text: str(expand.score_suffix_text), + author_stick: Boolean(action.is_author_stick), + addition: additionId ? parseBase(addition, additionId) : undefined, + preview_reply_count: int(expand.reply_show_count), + } +} + +function parseReply(raw: any): CommentReply | null { + const id = str(raw?.reply_id) + if (!id) return null + return { + ...parseBase(raw, id), + to_comment_id: str(raw?.reply_to_comment_id), + to_reply_id: str(raw?.reply_to_reply_id), + sub: Array.isArray(raw?.sub_reply) + ? raw.sub_reply.map(parseReply).filter((r: CommentReply | null): r is CommentReply => !!r) + : [], + } +} + +function parseTags(raw: any): CommentFilterTag[] { + if (!Array.isArray(raw)) return [] + return raw + .map(t => ({ + tag_id: str(t?.tag_id), + tag_name: str(t?.tag_name), + count: int(t?.count), + })) + .filter(t => t.tag_id && t.tag_name) +} + +/* ------------------------------- 请求封装 ------------------------------- */ + +/** 读接口。带上 sessionid 才能拿到 user_digg,读不到就匿名请求 */ +async function commentPost(path: string, body: unknown, requireLogin = false): Promise { + const headers: Record = {} + const session = await getSessionId() + if (session.value) { + headers['Cookie'] = `sessionid=${session.value}` + } else if (requireLogin) { + throw new NotLoggedInError() + } + + const res = await appRootPost(path, JSON.stringify(body), undefined, headers) + let j: any + try { + j = res.json() + } catch { + throw new Error(`书评接口返回了非 JSON(HTTP ${res.status})`) + } + // 103006 只可能出现在写操作上,读接口不校验登录 + if (j?.code === 103006) throw new NotLoggedInError() + if (j?.code !== 0) { + throw new Error(`书评接口失败(code=${j?.code}): ${j?.message ?? '未知错误'}`) + } + return j.data +} + +export interface CommentListOptions { + /** 1=最热(默认),3=最新 */ + sort?: CommentSort + /** 单页条数,上限 100 */ + count?: number + /** 上一页返回的 cursor,首页不传 */ + cursor?: string + /** 按标签过滤,取 tags 里的 tag_id */ + tagId?: string +} + +/** + * 书评列表。 + * + * @param bookId 书 id + */ +export async function getComments( + bookId: string, + { sort = 1, count = 20, cursor, tagId }: CommentListOptions = {}, +): Promise { + const data = await commentPost(`/novel/commentapi/comment/list/${bookId}/v1`, { + business_param: { + book_id: bookId, + need_count: true, + // fold_type 必填,且只有 1 有效(0 会报参数错误) + fold_type: 1, + ...(tagId ? { tag_id: tagId } : {}), + }, + comment_source: SOURCE_BOOK, + comment_type: COMMENT_TYPE, + count: Math.min(count, MAX_COMMENT_COUNT), + group_id: bookId, + group_type: GROUP_TYPE, + server_channel: 5, + sort, + ...(cursor ? { cursor } : {}), + }) + + const info = data?.common_list_info ?? {} + const list = Array.isArray(data?.data_list) ? data.data_list : [] + const extra = data?.extra ?? {} + return { + comments: list + .map((cell: any) => parseComment(cell?.comment)) + .filter((c: BookComment | null): c is BookComment => !!c), + cursor: str(info.cursor), + has_more: Boolean(info.has_more), + total: int(info.total), + tags: parseTags(extra.filter_tag), + score_text: str(extra.score_text), + score_count: int(extra.score_cnt), + // 登录后自己那条被单独放在 extra.user_comment,形状和列表项里的 comment 一致 + user_comment: parseComment(extra.user_comment), + } +} + +/** + * 打分接口只认这几档:2/4/6/8/10 对应 1~5 星。 + * 实测服务端也收 0(隐藏档,已经有人打过零分),所以 UI 要能显示零星, + * 但主动提交时把星级换算成分请走 starToScore,别自己乘。 + */ +export const REVIEW_SCORE_STEP = 2 +export const MIN_REVIEW_STARS = 1 +export const MAX_REVIEW_STARS = 5 + +/** 星级(1-5)→ 接口分值(2-10)。越界会被夹到合法范围 */ +export function starToScore(stars: number): number { + const s = Math.round(stars) + const clamped = Math.min(MAX_REVIEW_STARS, Math.max(MIN_REVIEW_STARS, s)) + return clamped * REVIEW_SCORE_STEP +} + +/** 接口分值(0-10)→ 星级(0-5)。0 分保留为 0 星,用于回显别人打过的零分 */ +export function scoreToStars(score: number): number { + return Math.round(int(score) / REVIEW_SCORE_STEP) +} + +export interface SubmitReviewOptions { + /** + * 评分。直接传接口分值(0/2/4/6/8/10)。 + * 想用星级请先经 starToScore 换算。默认不改分(沿用当前分)。 + */ + score?: number + /** 评论正文。空串表示只打分不写评 */ + text?: string +} + +/** + * 打分 / 写书评。text 为空只打分,带 text 就是评论。 + * + * 需要登录态(sessionid),否则抛 NotLoggedInError。 + * 成功后 comment/list 会把这条放进 extra.user_comment,可据此回显。 + * + * @param bookId 书 id + * @returns 服务端回存的这条评论 + */ +export async function submitReview( + bookId: string, + { score = 0, text = '' }: SubmitReviewOptions = {}, +): Promise { + const data = await commentPost( + `/novel/commentapi/comment/add/v1/`, + { + business_param: { + book_id: bookId, + score, + has_aigc_content: false, + // 下面这些是 APP 的催更 / 二次确认开关,网页端一律走默认 + from_famous_comment_id: 0, + ignore_urge_rule: false, + is_confirm_request: false, + offset: 0, + read_item_cnt: 0, + video_is_muted: 0, + }, + comment_type: ADD_COMMENT_TYPE, + // 抓包固定值:网页提交场景 + commit_source: 1, + data_type: 2, + group_id: bookId, + group_type: GROUP_TYPE, + // 富文本(@、书名号引用等)网页端暂不支持,只发纯文本 + rich_text: [], + text, + }, + true, + ) + return parseComment(data?.comment_info) +} + +export interface ReplyListOptions { + count?: number + /** 上一页返回的 cursor(回复接口的 cursor 是十进制偏移的字符串) */ + cursor?: string +} + +/** + * 某条评论下的回复。 + * + * @param bookId 书 id,接口要求 + * @param commentId 一级评论 id + */ +export async function getReplies( + bookId: string, + commentId: string, + { count = 20, cursor }: ReplyListOptions = {}, +): Promise { + const data = await commentPost(`/novel/commentapi/reply/list/${commentId}/v1`, { + business_param: { book_id: bookId, need_count: true }, + comment_id: commentId, + comment_source: SOURCE_COMMENT_REPLY, + comment_type: COMMENT_TYPE, + count, + group_id: bookId, + group_type: GROUP_TYPE, + // cursor 必须是字符串,传数字直接 HTTP 400 + ...(cursor ? { cursor: String(cursor) } : {}), + }) + return toReplyResult(data) +} + +/** + * 某条回复下的二级回复。 + * + * 服务端要求 ref_reply_id(任取一条已知子回复的 id), + * 缺了或填 '0' 都会报参数错误。若手上没有子回复 id, + * 可以退回用 getReplies 打这条回复的 id —— source 501 同样能列出子回复, + * 只是父级会以 `comment` 而不是 `reply` 的形状返回。 + * + * @param refReplyId 已知的一条子回复 id + */ +export async function getSubReplies( + bookId: string, + replyId: string, + refReplyId: string, + { count = 20, cursor }: ReplyListOptions = {}, +): Promise { + const data = await commentPost(`/novel/commentapi/reply/list/${replyId}/v1`, { + business_param: { book_id: bookId, need_count: true, ref_reply_id: refReplyId }, + comment_id: replyId, + comment_source: SOURCE_REPLY_REPLY, + comment_type: COMMENT_TYPE, + count, + group_id: bookId, + group_type: GROUP_TYPE, + ...(cursor ? { cursor: String(cursor) } : {}), + }) + return toReplyResult(data) +} + +function toReplyResult(data: any): ReplyListResult { + const info = data?.comment_list_info ?? {} + const list = Array.isArray(data?.reply_list) ? data.reply_list : [] + return { + replies: list + .map(parseReply) + .filter((r: CommentReply | null): r is CommentReply => !!r), + cursor: str(info.cursor), + has_more: Boolean(info.has_more), + total: int(info.total), + } +} + +/** + * 点赞 / 取消 / 点踩 / 取消踩。评论和回复都用这一个接口,object_type 固定 2。 + * + * 需要登录态。读不到 sessionid 时抛 NotLoggedInError, + * 调用方应把 COOKIE_PERMISSION_HINT 提示给用户。 + * + * @param objectId 评论 id 或回复 id + */ +export async function doCommentAction(objectId: string, action: CommentAction): Promise { + await commentPost( + '/novel/commentapi/comment/do_action/v1/', + { + action_type: action, + // 抓包里塞了一堆埋点字段(shark_param 等),实测可以整个留空 + business_param: {}, + comment_type: COMMENT_TYPE, + object_id: objectId, + object_type: 2, + }, + true, + ) +} + +/** 点赞开关。传入当前状态,返回操作后的状态 */ +export async function toggleLike(objectId: string, liked: boolean): Promise { + await doCommentAction(objectId, liked ? CommentAction.Unlike : CommentAction.Like) + return !liked +} + +/** 点踩开关 */ +export async function toggleDislike(objectId: string, disliked: boolean): Promise { + await doCommentAction(objectId, disliked ? CommentAction.Undislike : CommentAction.Dislike) + return !disliked +} diff --git a/src/api/content.ts b/src/api/content.ts index e635a8c..d85da1d 100644 --- a/src/api/content.ts +++ b/src/api/content.ts @@ -80,6 +80,8 @@ export async function getChapter(itemId: string, _retry?: number): Promise await ensureKeyinfo() } const res = await appGet('/reader/full/v', { item_id: itemId, req_type: '1' }) + // appGet 已经处理过「设备被作废」(空响应体会自动换设备重试)。 + // 走到这里还是空的,说明连换设备都没救回来,如实抛错而不是继续重试 const j = res.json()?.data if (!j) { console.warn('Failed to get chapter: ', itemId, ', response: ', res.responseText) @@ -129,6 +131,8 @@ export async function getChapters( req_type: '1', }) + // appGet 已处理过「设备被作废」(空响应体会自动换设备重试); + // 这里还解析失败就是真的坏响应,直接抛给调用方,别退化成「章节全部失败」 const raw = res.json()?.data const entries: Array<[string, any]> = raw && typeof raw === 'object' ? (Array.isArray(raw) diff --git a/src/api/provision.ts b/src/api/provision.ts index 1408162..946a9a7 100644 --- a/src/api/provision.ts +++ b/src/api/provision.ts @@ -2,8 +2,8 @@ // 首次使用时注册新设备并激活会员,之后从本地缓存复用。 import config, { defaultConfig, type DeviceConfig } from '../config' -import { b64decode, b64encode } from '../crypto' -import { read, write } from '../localStorage' +import { b64encode } from '../crypto' +import { del, read, write } from '../localStorage' import { registerDevice, activatePremium, registerKey } from './device' import { settings } from '../settings' @@ -15,19 +15,31 @@ interface StoredDevice { device_type?: string; device_brand?: string; vip_expire_time?: string; - key?: string; // base64 + /** @deprecated 密钥现在只持久化在 `keyinfo` 里,读取时迁移过去后即删除 */ + key?: string; keyver?: number; } function load(): DeviceConfig | null { const s = read(STORE_KEY) as StoredDevice | null if (!s?.device_id || !s?.install_id) return null + // 旧版本把密钥嵌在 device 里,refreshKey 之后两个副本就失同步了。 + // keyinfo 已存在时它是较新的权威副本(refreshKey 只更新它), + // 不能被 device 里过期的旧密钥覆盖回去;不存在时迁移过去。 + // 迁移完删掉 device.key,之后密钥只有一个持久化副本 + if (s.key) { + if (!read('keyinfo')) { + write('keyinfo', { key: s.key, keyver: s.keyver }) + } + delete s.key + delete s.keyver + write(STORE_KEY, s) + } return { device_id: s.device_id, install_id: s.install_id, device_type: s.device_type, device_brand: s.device_brand, - key_info: s.key ? { key: b64decode(s.key), keyver: s.keyver } : undefined, } } @@ -39,10 +51,6 @@ function save(c: DeviceConfig, vipExpireTime?: string): void { device_brand: c.device_brand, vip_expire_time: vipExpireTime, } - if (c.key_info?.key) { - s.key = b64encode(c.key_info.key) - s.keyver = c.key_info.keyver - } write(STORE_KEY, s) } @@ -63,6 +71,11 @@ export async function provisionDevice(): Promise { } config.currentConfig = c save(c, vipExpireTime) + // content.ts 的 ensureKeyinfo 读的是这个键,不同步会拿到上一台设备的密钥 + write('keyinfo', { + key: b64encode(keyInfo.key!), + keyver: keyInfo.keyver, + }) return c } @@ -101,3 +114,78 @@ export async function ensureDevice(): Promise { return defaultConfig } } + +/** + * 正在进行的替换。并发请求共享同一次, + * 否则一屏书架十几个请求同时收到空响应,会一口气注册十几台设备。 + */ +let replaceInflight: Promise | null = null + +/** + * 换完之后还没有任何请求成功过。 + * + * 刚换的设备立刻又收到空响应,说明问题不在设备本身(网络被拦、接口整体故障、 + * 出口 IP 被限),继续换只是白注册。等到有请求真的成功再解锁。 + */ +let unverified = false + +/** + * 记录「设备是工作的」。 + * + * 只要网关给出了非空响应就算 —— 哪怕业务码是错的(书不存在之类), + * 也证明它认这台设备。由 api/app 在每次拿到非空响应时调用。 + */ +export function markDeviceHealthy(): void { + unverified = false +} + +/** + * 换一台设备。 + * + * 设备被服务端作废后,所有 /reading/* 接口都返回 HTTP 200 + 空响应体 + * (`content-length: 0`,一个字节都没有),没有错误码可判断。实测重新注册密钥 + * 救不回来(registerkey 对被拒设备照样回 code:0 和一个 key,但正文仍是空), + * 必须整台重注册,注册后立刻就能正常取正文。 + * + * 作废并不是按闲置时间来的:实测自动注册的设备和内置那台(4-5 月注册) + * 都还活着。所以这里不限次数,只要求两次替换之间至少有一个请求成功过 —— + * 真出问题就换,问题不在设备就不白折腾。 + * + * 手填了设备的用户不动,那是他们自己指定的,替换只会让人困惑。 + * + * @returns 换成功返回新设备,放弃时返回 null + */ +export function replaceDevice(): Promise { + if (replaceInflight) return replaceInflight + replaceInflight = doReplace().finally(() => { + replaceInflight = null + }) + return replaceInflight +} + +async function doReplace(): Promise { + if (unverified) { + console.warn( + '[fqa:device] 刚换过设备仍然收到空响应,问题多半不在设备(检查网络代理是否拦了 reading.snssdk.com)', + ) + return null + } + if (settings.deviceId.trim() && settings.installId.trim()) { + console.warn('[fqa:device] 当前是手填设备,不自动替换。若接口持续失败,请到设置里清空或改用自动注册') + return null + } + // 注册失败时也保持上锁:否则每个失败请求都会再打一次注册接口 + unverified = true + console.warn('[fqa:device] 当前设备已被服务端作废,正在注册新设备…') + try { + // 旧设备的密钥跟着旧 device_id 绑定,留着会让 ensureKeyinfo 命中错的缓存 + del('keyinfo') + config.currentConfig.key_info = undefined + const c = await provisionDevice() + console.log('[fqa:device] 已换到新设备:', c.device_id) + return c + } catch (e) { + console.error('[fqa:device] 注册新设备失败:', e) + return null + } +} diff --git a/src/assets/audiobook.css b/src/assets/audiobook.css new file mode 100644 index 0000000..49298b3 --- /dev/null +++ b/src/assets/audiobook.css @@ -0,0 +1,360 @@ +/* 听书悬浮栏与音色弹窗。 + * 暗色跟随页面的 div.muye-reader-dark(与 userHook 的判定一致)。 */ + +.fqa-audio-root { + --fqa-audio-bg: #fff; + --fqa-audio-text: #1f2329; + --fqa-audio-sub: #8f959e; + --fqa-audio-hover: rgba(31, 35, 41, 0.06); + --fqa-audio-border: rgba(31, 35, 41, 0.1); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); + + position: fixed; + left: 20px; + bottom: 20px; + z-index: 2147483000; + font-size: 14px; + color: var(--fqa-audio-text); +} + +/* 页面切到夜间模式时整体反色 */ +.muye-reader-dark .fqa-audio-root, +.fqa-audio-root.fqa-audio-dark { + --fqa-audio-bg: #2b2b2b; + --fqa-audio-text: #b3b3b3; + --fqa-audio-sub: #7a7a7a; + --fqa-audio-hover: rgba(255, 255, 255, 0.08); + --fqa-audio-border: rgba(255, 255, 255, 0.12); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); +} + +.fqa-audio-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 8px; + border-radius: 999px; + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); +} + +/* ------------------------------- 旋转封面 ------------------------------- */ + +.fqa-audio-cover { + position: relative; + flex: 0 0 auto; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--fqa-audio-hover); + cursor: pointer; +} + +/* + * 只有这一层转。状态图标是 .fqa-audio-state,放在旋转层外面, + * 否则会跟着封面一起转,反而更看不清。 + * 播放/缓冲时转,暂停时停在当前角度(animation-play-state 比移除动画更平滑) + */ +.fqa-audio-disc { + position: absolute; + inset: 0; + border-radius: 50%; + overflow: hidden; + animation: fqa-audio-rotate 8s linear infinite; + animation-play-state: paused; +} + +.fqa-audio-disc.fqa-audio-spin { + animation-play-state: running; +} + +.fqa-audio-disc img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.fqa-audio-cover-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--fqa-audio-sub); + font-size: 18px; +} + +/* 播放/暂停状态。压一层遮罩,浅色封面上也看得清 */ +.fqa-audio-state { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.38); + color: #fff; + transition: background 0.2s ease; +} + +.fqa-audio-cover:hover .fqa-audio-state { + background: rgba(0, 0, 0, 0.55); +} + +/* + * 只给尺寸,不写 fill:paused.svg 里那块透明点击区是 fill="none", + * CSS 的 fill 优先级高于表现属性,会把它填成一个白方块盖住图标。 + * 两个 svg 自身已经用 currentColor,跟着上面的 color 走。 + */ +.fqa-audio-state svg { + width: 18px; + height: 18px; +} + +@keyframes fqa-audio-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .fqa-audio-disc { animation: none; } +} + +/* -------------------------------- 文字区 -------------------------------- */ + +.fqa-audio-meta { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + max-width: 180px; + line-height: 1.35; +} + +.fqa-audio-title, +.fqa-audio-sub { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-audio-sub { + color: var(--fqa-audio-sub); + font-size: 12px; +} + +/* -------------------------------- 按钮 -------------------------------- */ + +.fqa-audio-btn, +.fqa-audio-expand { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--fqa-audio-text); + cursor: pointer; +} + +.fqa-audio-btn:hover, +.fqa-audio-expand:hover { + background: var(--fqa-audio-hover); +} + +.fqa-audio-close { + font-size: 20px; + line-height: 1; +} + +/* svg 用 currentColor,跟着按钮的 color 走 */ +.fqa-audio-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.fqa-audio-icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +/* 收起后箭头水平翻转朝右 */ +.fqa-audio-icon-flip { + transform: scaleX(-1); +} + +.fqa-audio-expand { + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); + width: 36px; + height: 36px; +} + +/* 三个点 */ +.fqa-audio-dots, +.fqa-audio-dots::before, +.fqa-audio-dots::after { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; +} + +.fqa-audio-dots { + position: relative; +} + +.fqa-audio-dots::before, +.fqa-audio-dots::after { + content: ''; + position: absolute; + top: 0; +} + +.fqa-audio-dots::before { left: -7px; } +.fqa-audio-dots::after { left: 7px; } + +/* ----------------------------- 段落高亮 ----------------------------- */ + +.fqa-audio-active { + background: var(--web-brand_light, rgba(241, 70, 70, 0.12)); + border-radius: 4px; + transition: background 0.2s ease; +} + +/* ----------------------------- 音色弹窗 ----------------------------- */ + +.fqa-tone-mask { + position: fixed; + inset: 0; + z-index: 2147483002; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} + +.fqa-tone-box { + width: min(420px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 64px)); + display: flex; + flex-direction: column; + padding: 20px; + border-radius: 12px; + background: var(--fqa-audio-bg, #fff); + color: var(--fqa-audio-text, #1f2329); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.24); +} + +.fqa-tone-title { + margin: 0 0 4px; + font-size: 17px; + font-weight: 600; +} + +.fqa-tone-sub { + margin: 0 0 14px; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; +} + +.fqa-tone-list { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.fqa-tone-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.fqa-tone-item:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} + +.fqa-tone-item-active { + border-color: var(--web-brand, #f14646); +} + +.fqa-tone-icon { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; +} + +.fqa-tone-icon-empty { + display: flex; + align-items: center; + justify-content: center; + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); + color: var(--fqa-audio-sub, #8f959e); +} + +.fqa-tone-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.fqa-tone-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; +} + +.fqa-tone-gender { + color: var(--fqa-audio-sub, #8f959e); + font-size: 11px; +} + +.fqa-tone-desc { + overflow: hidden; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-tone-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +.fqa-tone-btn { + padding: 6px 16px; + border: 1px solid var(--fqa-audio-border, rgba(31, 35, 41, 0.1)); + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.fqa-tone-btn:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} diff --git a/src/assets/left.svg b/src/assets/left.svg new file mode 100644 index 0000000..81895fa --- /dev/null +++ b/src/assets/left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/paused.svg b/src/assets/paused.svg new file mode 100644 index 0000000..3b961be --- /dev/null +++ b/src/assets/paused.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/playing.svg b/src/assets/playing.svg new file mode 100644 index 0000000..f5d4e03 --- /dev/null +++ b/src/assets/playing.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/src/assets/script.css b/src/assets/script.css index 6de5757..e28f288 100644 --- a/src/assets/script.css +++ b/src/assets/script.css @@ -233,4 +233,8 @@ .info { width: 100%!important; +} + +.reader-toolbar { + user-select: none; } \ No newline at end of file diff --git a/src/assets/star.svg b/src/assets/star.svg new file mode 100644 index 0000000..08d09f0 --- /dev/null +++ b/src/assets/star.svg @@ -0,0 +1,15 @@ + + + + Layer 1 + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/star_fill.svg b/src/assets/star_fill.svg new file mode 100644 index 0000000..4dd688a --- /dev/null +++ b/src/assets/star_fill.svg @@ -0,0 +1,18 @@ + + + + Layer 1 + + Layer 1 + + + + + + + + + + + + \ No newline at end of file diff --git a/src/audioPanel.ts b/src/audioPanel.ts new file mode 100644 index 0000000..984643c --- /dev/null +++ b/src/audioPanel.ts @@ -0,0 +1,72 @@ +// 听书悬浮栏的挂载/卸载。与 downloadPanel 同构:全局单例。 +// +// 暗色跟随页面:番茄切日夜间只是给 div 加/去 muye-reader-dark 类, +// 没有事件可监听,所以用 MutationObserver 盯着 class 变化。 + +import { createApp, h, type App } from 'vue' +import AudioBar from './views/AudioBar.vue' +import audiobookcss from './assets/audiobook.css?raw' + +const CONTAINER_ID = 'fqa-audio-root' +const STYLE_ID = 'fqa-audio-style' + +let app: App | null = null +let container: HTMLElement | null = null +let themeObserver: MutationObserver | null = null + +function injectStyle(): void { + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = audiobookcss + document.head.appendChild(style) +} + +/** + * 同步暗色。 + * + * 悬浮栏挂在 body 下,拿不到 .muye-reader-dark 的后代选择器, + * 所以把判定结果写成自己的类名。 + */ +function syncTheme(): void { + if (!container) return + const dark = document.querySelector('div.muye-reader-dark') !== null + container.classList.toggle('fqa-audio-dark', dark) +} + +function watchTheme(): void { + if (themeObserver) return + themeObserver = new MutationObserver(syncTheme) + themeObserver.observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ['class'], + }) +} + +/** 挂载悬浮栏。多次调用只生效一次,状态由 controller 驱动 */ +export function initAudioPanel(): void { + if (app) return + injectStyle() + + container = document.createElement('div') + container.id = CONTAINER_ID + document.body.appendChild(container) + syncTheme() + watchTheme() + + app = createApp({ render: () => h(AudioBar) }) + app.config.errorHandler = (err, _instance, info) => { + console.error(`[fqa:audio] Vue error (${info}):`, err) + } + app.mount(container) +} + +export function destroyAudioPanel(): void { + themeObserver?.disconnect() + themeObserver = null + app?.unmount() + app = null + container?.remove() + container = null +} diff --git a/src/audiobook/controller.ts b/src/audiobook/controller.ts new file mode 100644 index 0000000..ee908a9 --- /dev/null +++ b/src/audiobook/controller.ts @@ -0,0 +1,400 @@ +// 听书的状态机。UI 只读这里的响应式状态,所有播放操作都走这里。 +// +// 换音色的关键约定:不同音色的音频时长不一样,所以恢复位置只能按段落, +// 不能按时间。切完之后回到当前段落的开头(时间点表里那一段的 startms), +// 退回段首是可接受的。 + +import { reactive } from 'vue' +import { getBookAvailableTones, getChapterParagraphTimeTag, getPlayInfo } from '../api/audiobook' +import { CencAudioPlayer, type CencPlayerState } from '../player' +import { gmAudioFetch } from '../player/gmFetch' +import type { Tone } from '../types' +import { ParagraphHighlighter } from './paragraph' +import { read, write } from '../localStorage' +import { settings } from '../settings' + +/** 上次选过的音色,下次进听书直接用 */ +const TONE_STORE_KEY = 'audiobook_tone' + +export interface AudiobookState { + /** 悬浮栏是否挂在页面上 */ + open: boolean + /** 收起成一个小箭头 */ + collapsed: boolean + /** 音色选择弹窗 */ + tonePickerOpen: boolean + /** 正在取播放地址 / 缓冲首段 */ + loading: boolean + playing: boolean + /** 封面该不该转:播放中或缓冲中都转 */ + spinning: boolean + tones: Tone[] + toneId: number | null + toneName: string + cover: string + title: string + error: string +} + +export const state = reactive({ + open: false, + collapsed: false, + tonePickerOpen: false, + loading: false, + playing: false, + spinning: false, + tones: [], + toneId: null, + toneName: '', + cover: '', + title: '', + error: '', +}) + +const highlighter = new ParagraphHighlighter() + +let audio: HTMLAudioElement | null = null +let player: CencAudioPlayer | null = null + +/** 当前章节 */ +let itemId = '' +let bookId = '' + +/** 高亮跟随播放进度的定时器。timeupdate 每 250ms 才一次,段落跟不上 */ +let trackTimer: number | null = null + +/** 换音色时记下的段落,等新音频 ready 后跳回去 */ +let pendingTagIndex = -1 + +/** 递增的播放请求序号,用于丢弃过期的异步结果 */ +let playToken = 0 + +/** 正文上的「点段落跳转」监听,关掉听书时要摘掉 */ +let paragraphClickBound = false + +/** + * 点正文段落跳到对应进度。 + * + * 挂在 document 上做事件委托:正文会被整段替换(切章), + * 绑在容器上每次都得重新绑。 + */ +const onParagraphClick = (event: MouseEvent): void => { + if (!state.open || state.toneId === null) return + // 点注释上标之类的交互元素时不抢行为 + const target = event.target + if (target instanceof HTMLElement && target.closest('a, button, sup')) return + const index = highlighter.tagIndexOfNode(target as Node) + if (index < 0) return + const seconds = highlighter.startSecondsOf(index) + if (seconds === null || !audio) return + audio.currentTime = seconds + highlighter.applyTag(index, false) + // 暂停时点段落也直接开始读 + if (!state.playing) void togglePlay() +} + +function bindParagraphClick(): void { + if (paragraphClickBound) return + document.addEventListener('click', onParagraphClick) + paragraphClickBound = true +} + +function unbindParagraphClick(): void { + if (!paragraphClickBound) return + document.removeEventListener('click', onParagraphClick) + paragraphClickBound = false +} + +function ensurePlayer(): CencAudioPlayer { + if (player) return player + audio = document.createElement('audio') + audio.preload = 'none' + // 悬浮栏自己画控件,原生的不要 + audio.style.display = 'none' + document.body.appendChild(audio) + player = new CencAudioPlayer(audio, { + // 音频 CDN 不在页面 CSP 的 connect-src 里,页面 fetch 会被 report-only + // 策略上报到 mon.zijieapi.com。走 GM 通道绕开上报,详见 gmFetch + fetch: gmAudioFetch, + onStateChange: onPlayerState, + onMessage: (message, level) => { + if (level === 'error') { + console.error('[fqa:audio]', message) + state.error = message + } else { + console.log('[fqa:audio]', message) + } + }, + }) + return player +} + +function onPlayerState(playerState: CencPlayerState): void { + state.playing = playerState === 'playing' + // loading/ready 都算在缓冲,封面继续转,避免一卡就停 + state.spinning = playerState === 'playing' || playerState === 'loading' + if (playerState === 'playing') state.error = '' + if (playerState === 'playing') startTracking() + else stopTracking() + if (playerState === 'ended') { + highlighter.clear() + onChapterEnd() + } +} + +/** 网页端的「下一章」按钮。读到最后一章时这个元素会消失 */ +const NEXT_CHAPTER_SELECTOR = 'div.chapter-btn.next' + +/** + * 一章读完。 + * + * 设置成 next 时点一下页面自带的「下一章」——切页会触发 onUrlChange, + * 走 readerHook 的 switchChapter 把音频也接上,不用自己去算下一章是谁。 + */ +function onChapterEnd(): void { + if (settings.audiobookChapterEnd !== 'next') { + state.spinning = false + return + } + const next = document.querySelector(NEXT_CHAPTER_SELECTOR)?.firstChild as HTMLElement | null + if (typeof next?.click !== 'function') { + // 元素不在了就是读到最后一章了 + state.spinning = false + state.error = '已经是最后一章' + return + } + // 切页要一会儿,先让封面继续转着,别闪一下停 + state.spinning = true + const before = itemId + next.click() + // 正常情况下切页会触发 onUrlChange -> switchChapter。 + // 万一站点那个按钮没走 history(或被别的脚本拦了),这里兜一下, + // 否则会一直转圈但没有声音 + unsafeWindow.setTimeout(() => { + if (!state.open || itemId !== before) return + const current = window.location.pathname.split('/').pop()?.substring(0, 19) || '' + if (current && current !== before) { + void switchChapter(current, { cover: state.cover, title: state.title }) + } else { + state.spinning = false + state.error = '自动切章失败' + } + }, 3000) +} + +function startTracking(): void { + if (trackTimer !== null) return + trackTimer = unsafeWindow.setInterval(() => { + if (!audio) return + highlighter.update(audio.currentTime * 1000, settings.audiobookFollow) + }, 120) +} + +function stopTracking(): void { + if (trackTimer === null) return + unsafeWindow.clearInterval(trackTimer) + trackTimer = null +} + +/** 记住的音色。书里没有这个音色时返回 null,让调用方回退到第一个 */ +function storedToneId(): number | null { + const raw = read(TONE_STORE_KEY) + return typeof raw === 'number' ? raw : null +} + +/** + * 打开听书。先取音色列表,然后弹窗让用户选。 + * + * @param chapter 当前章节 item_id + * @param book 当前书 book_id,取音色列表要用 + * @param meta 悬浮栏上显示的封面与标题 + */ +export async function openAudiobook( + chapter: string, + book: string, + meta: { cover: string; title: string }, +): Promise { + itemId = chapter + bookId = book + state.cover = meta.cover + state.title = meta.title + state.error = '' + state.open = true + state.collapsed = false + + if (state.tones.length === 0) { + try { + state.tones = await getBookAvailableTones(bookId) + } catch (e) { + state.error = '取音色列表失败' + console.error('[fqa:audio] 取音色列表失败:', e) + return + } + } + if (state.tones.length === 0) { + state.error = '这本书没有可用音色' + return + } + // 上次用过的音色还在这本书里就直接开播,不每次都问 + const remembered = storedToneId() + if (remembered !== null && state.tones.some(t => t.id === remembered)) { + await selectTone(remembered) + return + } + state.tonePickerOpen = true +} + +/** 选中音色:关掉弹窗并开始播放 */ +export async function selectTone(toneId: number): Promise { + state.tonePickerOpen = false + const tone = state.tones.find(t => t.id === toneId) + state.toneId = toneId + state.toneName = tone?.name ?? '' + write(TONE_STORE_KEY, toneId) + await playCurrent() +} + +/** 重开音色弹窗(悬浮栏上的三个点) */ +export function openTonePicker(): void { + state.tonePickerOpen = true +} + +export function closeTonePicker(): void { + state.tonePickerOpen = false + // 还没选过音色就关掉弹窗,等于放弃听书 + if (state.toneId === null) closeAudiobook() +} + +/** + * 换音色。 + * + * 换之前记下当前段落,新音频 ready 后跳到那一段的开头。 + * 不能沿用时间:同一段在不同音色里的时间点完全不同。 + */ +export async function changeTone(toneId: number): Promise { + if (toneId === state.toneId) { + state.tonePickerOpen = false + return + } + pendingTagIndex = highlighter.currentTagIndex + await selectTone(toneId) +} + +/** 取播放地址 + 时间点表,然后交给播放器 */ +async function playCurrent(): Promise { + const toneId = state.toneId + if (toneId === null || !itemId) return + // 连续切章/切音色时,晚回来的旧请求不能盖掉新的 + const token = ++playToken + const chapter = itemId + state.loading = true + state.spinning = true + state.error = '' + try { + const [contexts, tags] = await Promise.all([ + getPlayInfo(chapter, toneId), + getChapterParagraphTimeTag(chapter, toneId), + ]) + if (token !== playToken) return + const context = contexts.find(c => c.item_id === chapter) ?? contexts[0] + const url = context?.urls[0] + if (!context || !url) { + state.error = '这一章没有音频' + return + } + highlighter.reset(tags) + bindParagraphClick() + + const instance = ensurePlayer() + await instance.start({ url, key: context.key }) + if (token !== playToken) return + + // 换音色回到原段落。start 之后再 seek,让播放器自己去拉对应位置 + if (pendingTagIndex >= 0) { + const tag = tags[pendingTagIndex] + if (tag && audio) { + audio.currentTime = tag.startms / 1000 + highlighter.applyTag(pendingTagIndex, true) + } + pendingTagIndex = -1 + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + state.error = message + console.error('[fqa:audio] 播放失败:', e) + } finally { + state.loading = false + // 失败时别让封面一直转 + if (!state.playing) state.spinning = false + } +} + +/** 点封面:播放 / 暂停 */ +export async function togglePlay(): Promise { + if (!player) return + if (state.playing) { + player.pause() + return + } + // 播完了再点就从头开始 + if (player.currentState === 'ended' && audio) audio.currentTime = 0 + try { + await player.play() + } catch (e) { + console.warn('[fqa:audio] play() 被拒:', e) + } +} + +export function toggleCollapsed(): void { + state.collapsed = !state.collapsed +} + +/** 切章:正文换了,段落索引和音频都要重来 */ +export async function switchChapter(chapter: string, meta: { cover: string; title: string }): Promise { + if (!state.open || state.toneId === null) return + itemId = chapter + state.cover = meta.cover + state.title = meta.title + pendingTagIndex = -1 + await playCurrent() +} + +/** 正文重新插入后段落节点都换了,重新抓一遍 */ +export function refreshParagraphs(): void { + if (state.open) highlighter.refresh() +} + +export function closeAudiobook(): void { + stopTracking() + unbindParagraphClick() + // 让还在飞的 playCurrent 结果作废,否则关掉之后又冒出来一段 + playToken++ + highlighter.clear() + player?.stop() + state.open = false + state.collapsed = false + state.tonePickerOpen = false + state.playing = false + state.spinning = false + state.loading = false + state.error = '' +} + +/** 工具栏那个「听书」按钮:在听就停,没听就开 */ +export function isListening(): boolean { + return state.open +} + +/** 离开阅读页时彻底清理 */ +export function destroyAudiobook(): void { + stopTracking() + unbindParagraphClick() + highlighter.clear() + player?.destroy() + player = null + audio?.remove() + audio = null + state.open = false + state.tones = [] + state.toneId = null +} diff --git a/src/audiobook/index.ts b/src/audiobook/index.ts new file mode 100644 index 0000000..90378f0 --- /dev/null +++ b/src/audiobook/index.ts @@ -0,0 +1,20 @@ +/** + * 听书。 + * + * 用法:readerHook 里点「听书」-> openAudiobook 取音色 -> selectTone 开播。 + * 悬浮栏由 audioPanel 挂载,只读 controller 的响应式 state。 + */ +export { + changeTone, + closeAudiobook, + destroyAudiobook, + openAudiobook, + refreshParagraphs, + selectTone, + state, + switchChapter, + togglePlay, + type AudiobookState, +} from './controller' + +export { ParagraphHighlighter, TITLE_IDX } from './paragraph' diff --git a/src/audiobook/paragraph.ts b/src/audiobook/paragraph.ts new file mode 100644 index 0000000..f8592a4 --- /dev/null +++ b/src/audiobook/paragraph.ts @@ -0,0 +1,193 @@ +// 段落定位:把时间点表和页面上的正文段落对应起来。 +// +// APP 正文里每个段落带 idx,时间点表用同一套 idx 表示「这一段从第几毫秒读到第几毫秒」。 +// 网页端把 idx 留在了外层 上: +// “凭什么爹可以喝花酒,我就不行!” +// 所以定位只看外层 ,不管里面的 blk 怎么切。 + +import type { ParagraphTimeTag } from '../types' + +/** 标题的 idx。时间点表里用这个值表示「正在读标题」 */ +export const TITLE_IDX = 10000 + +/** 脚本接管的正文容器 id,与 readerHook 保持一致 */ +const CONTENT_SELECTOR = '#fqa-reader-content' + +/** 页面原有的章节标题 */ +const TITLE_SELECTOR = 'h1.muye-reader-title' + +/** 当前高亮的段落类名 */ +export const ACTIVE_CLASS = 'fqa-audio-active' + +/** + * 收集正文里所有带 idx 的段落,按 idx 建索引。 + * + * 同一个 idx 理论上只有一个 ,但真出现重复也不丢弃 —— 一起高亮比只亮一半好。 + */ +export function collectParagraphs(): Map { + const map = new Map() + const container = document.querySelector(CONTENT_SELECTOR) + if (!container) return map + for (const node of container.querySelectorAll('p[idx]')) { + const idx = Number(node.getAttribute('idx')) + if (!Number.isFinite(idx)) continue + const list = map.get(idx) + if (list) list.push(node) + else map.set(idx, [node]) + } + return map +} + +/** + * 标题时间点该高亮谁。 + * + * 时间点表里只有一条 is_title 时是普通章节,标题就是页面上那个 h1。 + * 出现多条则说明是「复杂章节」(卷首语、分节小标题等各自成段), + * 光靠 idx=10000 分不出是哪一个,索性都不高亮 —— 与其亮错位置,不如不亮。 + */ +export function titleTarget(tags: ParagraphTimeTag[]): HTMLElement | null { + const titles = tags.filter(t => t.is_title) + if (titles.length !== 1) return null + return document.querySelector(TITLE_SELECTOR) +} + +/** 找出 timeMs 落在哪个时间点区间。找不到返回 -1 */ +export function tagIndexAt(tags: ParagraphTimeTag[], timeMs: number): number { + let low = 0 + let high = tags.length - 1 + let found = -1 + // 时间点表按时间递增,二分找最后一个 startms <= timeMs 的 + while (low <= high) { + const middle = (low + high) >> 1 + if (tags[middle]!.startms <= timeMs) { + found = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + if (found < 0) return -1 + // 落在两段之间的空隙(上一段已读完、下一段还没开始)就保持上一段亮着 + return found +} + +/** + * 高亮某个时间点覆盖的所有段落,并把上一次的高亮清掉。 + * + * 一个时间点可能跨多段(startidx..endidx),所以按区间取。 + */ +export class ParagraphHighlighter { + private paragraphs = new Map() + private tags: ParagraphTimeTag[] = [] + private active: HTMLElement[] = [] + private activeTagIndex = -1 + + /** 换章或正文重新插入后调用,重建索引 */ + reset(tags: ParagraphTimeTag[]): void { + this.clear() + this.tags = tags + this.paragraphs = collectParagraphs() + this.activeTagIndex = -1 + } + + /** 正文 DOM 被替换过(切音色不会换 DOM,但切章会),重新抓一遍段落 */ + refresh(): void { + this.paragraphs = collectParagraphs() + this.activeTagIndex = -1 + } + + get timeTags(): ParagraphTimeTag[] { + return this.tags + } + + /** 当前高亮对应的时间点下标,用于切音色时保持段落 */ + get currentTagIndex(): number { + return this.activeTagIndex + } + + clear(): void { + for (const node of this.active) node.classList.remove(ACTIVE_CLASS) + this.active = [] + } + + /** + * 按播放时间更新高亮。 + * + * @param scroll 是否把高亮段落滚进视口 + * @returns 当前时间点下标,没有变化时返回原值 + */ + update(timeMs: number, scroll: boolean): number { + if (this.tags.length === 0) return -1 + const index = tagIndexAt(this.tags, timeMs) + if (index < 0 || index === this.activeTagIndex) return this.activeTagIndex + this.activeTagIndex = index + this.applyTag(index, scroll) + return index + } + + /** 直接高亮第 index 个时间点,供切音色后恢复位置用 */ + applyTag(index: number, scroll: boolean): void { + const tag = this.tags[index] + if (!tag) return + this.clear() + const targets = tag.is_title + ? [titleTarget(this.tags)].filter((n): n is HTMLElement => n !== null) + : this.rangeTargets(tag.startidx, tag.endidx) + for (const node of targets) node.classList.add(ACTIVE_CLASS) + this.active = targets + if (scroll && targets[0]) { + targets[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + + private rangeTargets(startidx: number, endidx: number): HTMLElement[] { + const targets: HTMLElement[] = [] + for (let idx = startidx; idx <= endidx; idx++) { + const nodes = this.paragraphs.get(idx) + if (nodes) targets.push(...nodes) + } + return targets + } + + /** + * 点段落跳转用:找出这个节点属于哪个时间点。 + * + * 从点击目标往上找最近的 (点到 blk 或行内标签上也能命中), + * 再拿 idx 去时间点表里查覆盖它的那一段。 + */ + tagIndexOfNode(node: Node | null): number { + const paragraph = nearestParagraph(node) + if (paragraph) { + const idx = Number(paragraph.getAttribute('idx')) + if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx) + } + // 点在标题上 + if (node instanceof Node && titleTarget(this.tags)?.contains(node)) { + return this.tags.findIndex(t => t.is_title) + } + return -1 + } + + /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ + tagIndexOfIdx(idx: number): number { + return this.tags.findIndex(t => !t.is_title && idx >= t.startidx && idx <= t.endidx) + } + + /** 第 index 个时间点的起始秒数,供 seek 用 */ + startSecondsOf(index: number): number | null { + const tag = this.tags[index] + return tag ? tag.startms / 1000 : null + } +} + +/** 从任意节点往上找最近的带 idx 的段落 */ +function nearestParagraph(node: Node | null): HTMLElement | null { + let current: Node | null = node + while (current) { + if (current instanceof HTMLElement && current.matches('p[idx]')) return current + // 到正文容器就停,别一路找到 body + if (current instanceof HTMLElement && current.id === 'fqa-reader-content') return null + current = current.parentNode + } + return null +} diff --git a/src/crypto/spade.ts b/src/crypto/spade.ts new file mode 100644 index 0000000..81def53 --- /dev/null +++ b/src/crypto/spade.ts @@ -0,0 +1,53 @@ +import { b64decode } from '.' + +function decodeBase36(c: number): number { + if (c >= 48 && c <= 57) return c - 48; // '0'-'9' + if (c >= 97 && c <= 122) return c - 97 + 10; // 'a'-'z' + return 0xFF; +} + +function bitCount(n: number): number { + n = n - ((n >> 1) & 0x55555555); + n = (n & 0x33333333) + ((n >> 2) & 0x33333333); + return ((n + (n >> 4) & 0xF0F0F0F) * 0x1010101) >> 24; +} + +function decryptSpadeInner(spadeKey: Uint8Array): Uint8Array { + const result = new Uint8Array(spadeKey); + const buff = new Uint8Array(2 + spadeKey.length); + buff.set([0xFA, 0x55], 0); + buff.set(spadeKey, 2); + + for (let i = 0; i < result.length; i++) { + let v = (spadeKey[i]! ^ buff[i]!) - bitCount(i) - 21; + while (v < 0) { v += 0xFF; } + result[i] = v; + } + return result; +} + +function decryptSpade(spadeKeyBytes: Uint8Array): string { + const spadeKeyLen = spadeKeyBytes.length; + if (spadeKeyLen < 3) return ""; + const paddingLen = (spadeKeyBytes[0]! ^ spadeKeyBytes[1]! ^ spadeKeyBytes[2]!) - 48; + if (spadeKeyLen < paddingLen + 2) return ""; + const innerInput = spadeKeyBytes.slice(1, spadeKeyLen - paddingLen); + const tmpBuff = decryptSpadeInner(innerInput); + if (tmpBuff.length === 0) return ""; + const skipBytes = decodeBase36(tmpBuff[0]!); + const decodedMessageLen = spadeKeyLen - paddingLen - 2; + const endIndex = 1 + decodedMessageLen - skipBytes; + if (endIndex > tmpBuff.length) return ""; + const finalBytes = tmpBuff.slice(1, endIndex); + return new TextDecoder("utf-8").decode(finalBytes); +} + +export function decryptSpadeA(spadeAStr: string): string { + try { + const bytes = new Uint8Array(b64decode(spadeAStr)); + return decryptSpade(bytes); + } catch (e) { + console.error("Spade parsing error", e); + return ""; + } +} \ No newline at end of file diff --git a/src/hooks/readerHook.ts b/src/hooks/readerHook.ts index 446dd8b..19363d1 100644 --- a/src/hooks/readerHook.ts +++ b/src/hooks/readerHook.ts @@ -9,6 +9,14 @@ import { cloneElement } from '../utils'; import { fetchArrayBuffer } from '../utils/request'; import { type Book } from '../types' import { decryptComicImage } from '../crypto/content'; +import { initAudioPanel } from '../audioPanel' +import { + closeAudiobook, + openAudiobook, + refreshParagraphs, + state as audioState, + switchChapter, +} from '../audiobook/controller' // import moment from 'moment' let currentBook: Book | null = null @@ -303,6 +311,36 @@ async function insertContent() { } } } + + // 正文换了:段落节点全是新的,正在听书就把音频也切到这一章 + if (audioState.open) { + await switchChapter(itemId, { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) + } else { + refreshParagraphs() + } +} + +/** + * 工具栏「听书」按钮:停止 / 开始听书。 + * + * 已经在听的时候再点就是停止。切音色走左下角悬浮栏,不从这里进。 + */ +async function startAudioPlay(): Promise { + if (audioState.open) { + closeAudiobook() + return + } + const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || '' + if (!itemId) return + initAudioPanel() + // 封面和书名从已取到的书籍信息来;正文还没加载完时退化成空封面 + await openAudiobook(itemId, currentBook?.book_id ?? '', { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) } // let currentBook: Book | null = null @@ -322,6 +360,28 @@ async function onLoad(): Promise { document.head.appendChild(style) } */ + // 切换日夜间的按钮,不管怎么样它的状态都是完全一样的。适合作为模板。 + const toolbar = document.querySelector("div.reader-toolbar > div") + const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)") + if (toolbarButton && toolbar) { + const c = cloneElement(toolbarButton) + c.id = 'fqa-toggle-audiobook' + const listenIcon = document.createElement('span') + listenIcon.textContent = '听' + listenIcon.style.width = '24px' + listenIcon.style.height = '24px' + listenIcon.style.fontSize = '24px' + listenIcon.style.lineHeight = '24px' + listenIcon.classList.add('muyeicon-icon') + listenIcon.classList.add('reader-toolbar-item-icon') + c.firstChild?.replaceWith(listenIcon) + const l = c.lastChild as HTMLDivElement + if (l) { + l.textContent = '听书' + } + c.addEventListener('click', () => void startAudioPlay()) + toolbar.appendChild(c) + } const btns = document.querySelector("div.muye-reader-btns") // single div if (btns) { // TODO: 直接覆写按钮行为,切章节由脚本完成 diff --git a/src/player/CencAudioPlayer.ts b/src/player/CencAudioPlayer.ts new file mode 100644 index 0000000..ca97b05 --- /dev/null +++ b/src/player/CencAudioPlayer.ts @@ -0,0 +1,1083 @@ +import { fetch as pageFetch } from '../config' +import type { Bytes } from './bytes' +import { + cencIvSize, + decryptCencSample, + importCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' +import { + buildMp4Segment, + buildWebmCluster, + codecMime, + extractFlacEntry, + extractSampleEntry, + initSegment, +} from './remux' + +export type CencPlayerState = 'idle' | 'loading' | 'ready' | 'playing' | 'paused' | 'ended' | 'error' | 'destroyed' + +export interface CencAudioSource { + /** 加密音频 MP4 URL。Range 请求不会发送 Referer。 */ + url: string + /** 16 字节 AES-128 key,或 32 位 hex key。 */ + key: string | ArrayBuffer | Uint8Array +} + +export interface CencMediaInfo { + codec: CencCodec + sampleCount: number + durationSeconds: number + timescale: number + channels: number + encryptedBytes: number +} + +export interface CencProgress { + processedSamples: number + totalSamples: number + percent: number + bufferedAhead: number + durationSeconds: number +} + +export interface CencAudioPlayerCallbacks { + onStateChange?: (state: CencPlayerState) => void + onProgress?: (progress: CencProgress) => void + onMessage?: (message: string, level: 'info' | 'warn' | 'error') => void +} + +export interface CencAudioPlayerOptions extends CencAudioPlayerCallbacks { + /** 初次读取的 MP4 头大小,默认 512 KiB。 */ + headBytes?: number + /** MP4 头解析失败时逐次扩大读取,默认最多 8 MiB。 */ + maxHeadBytes?: number + /** + * 每个 Range/MSE media segment 包含的目标秒数,默认 15 秒。 + * Opus/WebM 因时间戳范围限制最高按 30 秒处理。 + */ + segmentSeconds?: number + /** 播放位置前方达到此秒数后暂停续流,默认 40 秒。 */ + maxBufferAheadSeconds?: number + /** 清理播放位置后方的缓冲,只保留此秒数,默认 10 秒。 */ + keepBehindSeconds?: number + /** 并发 AES-CTR 解密数,默认 24。 */ + decryptConcurrency?: number + /** 仅用于测试或替换网络实现;默认使用页面原始 fetch。 */ + fetch?: typeof fetch +} + +interface StreamContext { + url: string + head: Bytes + meta: CencMetadata + key: CryptoKey + counterStart: 0 | 1 + ticks: Float64Array + samplesPerSecond: number + samplesPerSegment: number +} + +const DEFAULT_HEAD_BYTES = 512 * 1024 +const DEFAULT_MAX_HEAD_BYTES = 8 * 1024 * 1024 +const DEFAULT_SEGMENT_SECONDS = 15 +const MAX_WEBM_SEGMENT_SECONDS = 30 +const DEFAULT_MAX_BUFFER_AHEAD = 40 +const DEFAULT_KEEP_BEHIND = 10 +const DEFAULT_DECRYPT_CONCURRENCY = 24 + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(): DOMException { + return new DOMException('播放器会话已取消', 'AbortError') +} + +function isAbort(value: unknown): boolean { + return value instanceof DOMException && value.name === 'AbortError' +} + +/** + * 等 SourceBuffer 处理完手头的 appendBuffer/remove。 + * + * abort 会让上一次操作的 Promise 提前兑现,但浏览器那边还在跑, + * 此时再调 appendBuffer 就会抛 "still processing"。updating 转 false 时 + * 一定会有一次 updateend/abort/error,所以监听事件即可,不用轮询。 + */ +function waitForSourceBufferIdle(sourceBuffer: SourceBuffer, signal: AbortSignal): Promise { + if (!sourceBuffer.updating) return Promise.resolve() + return new Promise(resolve => { + const done = (): void => { + sourceBuffer.removeEventListener('updateend', done) + sourceBuffer.removeEventListener('abort', done) + sourceBuffer.removeEventListener('error', done) + signal.removeEventListener('abort', done) + resolve() + } + sourceBuffer.addEventListener('updateend', done) + sourceBuffer.addEventListener('abort', done) + sourceBuffer.addEventListener('error', done) + // 会话被取消也别一直挂着;调用方随后会自己检查 signal + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * 把 的错误码翻成可读文本,附在 SourceBuffer 报错后面。 + * + * MEDIA_ERR_DECODE 基本等于「喂进去的字节不是合法的音频」—— + * 数据在传输环节被破坏时就是这个。 + */ +function describeMediaError(audio: HTMLAudioElement): string { + const error = audio.error + if (!error) return '' + const names: Record = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE(数据不是合法音频,多半在传输中被破坏)', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED', + } + const name = names[error.code] ?? `code ${error.code}` + return `:${name}${error.message ? ` - ${error.message}` : ''}` +} + +function validatePositive(value: number | undefined, fallback: number, name: string): number { + const result = value ?? fallback + if (!Number.isFinite(result) || result <= 0) throw new Error(`${name} 必须是正数`) + return result +} + +async function mapLimit( + items: readonly T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + const results = new Array(items.length) + let next = 0 + async function consume(): Promise { + while (next < items.length) { + const index = next++ + results[index] = await worker(items[index]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => consume())) + return results +} + +type ByteReader = ReadableStreamDefaultReader + +/** + * 把 fetch 的任意网络 chunk 重新切成播放器要求的 sample 段。 + * 不主动预读:调用方停止 readExactly 后,ReadableStream 的背压会限制继续下载。 + */ +class SequentialByteStream { + private readonly chunks: Bytes[] = [] + private chunkOffset = 0 + private available = 0 + private reader: ByteReader | null + private readonly openReader: (() => Promise) | null + private eof = false + + constructor( + initial: Uint8Array = new Uint8Array(), + reader: ByteReader | null = null, + openReader: (() => Promise) | null = null, + ) { + if (initial.length > 0) { + const copy = new Uint8Array(initial) as Bytes + this.chunks.push(copy) + this.available = copy.length + } + this.reader = reader + this.openReader = openReader + } + + /** 至少缓存 length 字节;流先结束时返回 false。 */ + async bufferAtLeast(length: number): Promise { + while (this.available < length && !this.eof) await this.pull() + return this.available >= length + } + + /** 复制当前全部未消费数据,不移动读取位置。 */ + snapshot(): Bytes { + const result = new Uint8Array(this.available) + let output = 0 + for (let i = 0; i < this.chunks.length; i++) { + const chunk = this.chunks[i]! + const start = i === 0 ? this.chunkOffset : 0 + result.set(chunk.subarray(start), output) + output += chunk.length - start + } + return result as Bytes + } + + async skip(length: number): Promise { + await this.readExactly(length) + } + + async readExactly(length: number): Promise { + if (!Number.isSafeInteger(length) || length < 0) { + throw new Error(`无效的流读取长度: ${length}`) + } + if (length === 0) return new Uint8Array() as Bytes + if (!await this.bufferAtLeast(length)) { + throw new Error(`音频流提前结束(还需 ${length - this.available} 字节)`) + } + + const result = new Uint8Array(length) + let output = 0 + while (output < length) { + const chunk = this.chunks[0]! + const take = Math.min(length - output, chunk.length - this.chunkOffset) + result.set(chunk.subarray(this.chunkOffset, this.chunkOffset + take), output) + output += take + this.chunkOffset += take + this.available -= take + if (this.chunkOffset === chunk.length) { + this.chunks.shift() + this.chunkOffset = 0 + } + } + return result as Bytes + } + + async cancel(): Promise { + this.eof = true + try { + await this.reader?.cancel() + } catch { + // AbortController 通常已经让 reader 进入 errored,取消失败无需再处理。 + } + this.reader = null + } + + private async pull(): Promise { + if (!this.reader && this.openReader) this.reader = await this.openReader() + if (!this.reader) { + this.eof = true + return + } + const { value, done } = await this.reader.read() + if (done) { + this.eof = true + return + } + if (value?.length) { + const copy = new Uint8Array(value) as Bytes + this.chunks.push(copy) + this.available += copy.length + } + } +} + +/** + * CENC 音频流播放器。 + * + * 负责 MP4 分段读取、AES-CTR 解密、WebM/fMP4 重封装和 MSE 缓冲;不创建任何 + * 页面控件。调用方传入一个已有的 HTMLAudioElement,再把它接到自己的界面即可。 + * + * 音频 CDN 明确拒绝 Referer,因此网络层固定使用: + * - 页面原始 fetch(不走被页面脚本改写的 fetch) + * - credentials: omit + * - referrerPolicy: no-referrer + * 不使用 GM_xmlhttpRequest 回退,也不手动添加 Referer。 + */ +export class CencAudioPlayer { + private readonly audio: HTMLAudioElement + private readonly options: Required< + Pick< + CencAudioPlayerOptions, + | 'headBytes' + | 'maxHeadBytes' + | 'segmentSeconds' + | 'maxBufferAheadSeconds' + | 'keepBehindSeconds' + | 'decryptConcurrency' + > + > & CencAudioPlayerCallbacks & { fetch: typeof fetch } + + private state: CencPlayerState = 'idle' + private session = 0 + private streamToken = 0 + private seekToken = 0 + private sessionController: AbortController | null = null + private streamController: AbortController | null = null + private sourceBuffer: SourceBuffer | null = null + private mediaSource: MediaSource | null = null + private objectUrl: string | null = null + private sourceBufferChain: Promise = Promise.resolve() + private context: StreamContext | null = null + private info: CencMediaInfo | null = null + private autoplay = false + private segmentSequence = 0 + + private readonly onSeeking = (): void => { + const context = this.context + if (!context || this.state === 'destroyed' || this.session === 0) return + // 已经进入错误态时,任何 appendBuffer 都会被拒 + // ("The HTMLMediaElement.error attribute is not null.")。 + // 这时重启流只会把同一个错误刷满控制台,直接放弃 + if (this.audio.error) return + const target = this.audio.currentTime + if (!Number.isFinite(target) || this.isTimeBuffered(target)) return + const wasPlaying = !this.audio.paused + void this.restartAt(target, wasPlaying) + } + + private readonly onPlay = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('playing') + } + + private readonly onPause = (): void => { + if (this.context && this.state === 'playing' && !this.audio.ended) this.emitState('paused') + } + + private readonly onEnded = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('ended') + } + + constructor(audio: HTMLAudioElement, options: CencAudioPlayerOptions = {}) { + this.audio = audio + // Window.fetch 不能作为普通对象的方法调用,否则 Edge 会报 Illegal invocation。 + const fetcher = (options.fetch ?? pageFetch).bind(unsafeWindow) + this.options = { + headBytes: validatePositive(options.headBytes, DEFAULT_HEAD_BYTES, 'headBytes'), + maxHeadBytes: validatePositive(options.maxHeadBytes, DEFAULT_MAX_HEAD_BYTES, 'maxHeadBytes'), + segmentSeconds: validatePositive(options.segmentSeconds, DEFAULT_SEGMENT_SECONDS, 'segmentSeconds'), + maxBufferAheadSeconds: validatePositive(options.maxBufferAheadSeconds, DEFAULT_MAX_BUFFER_AHEAD, 'maxBufferAheadSeconds'), + keepBehindSeconds: validatePositive(options.keepBehindSeconds, DEFAULT_KEEP_BEHIND, 'keepBehindSeconds'), + decryptConcurrency: Math.max(1, Math.floor(validatePositive(options.decryptConcurrency, DEFAULT_DECRYPT_CONCURRENCY, 'decryptConcurrency'))), + onStateChange: options.onStateChange, + onProgress: options.onProgress, + onMessage: options.onMessage, + // config.fetch 保存的是页面 fetch 的原始引用,避免被字节 SDK 改写。 + fetch: fetcher, + } + this.audio.addEventListener('seeking', this.onSeeking) + this.audio.addEventListener('play', this.onPlay) + this.audio.addEventListener('pause', this.onPause) + this.audio.addEventListener('ended', this.onEnded) + } + + get currentState(): CencPlayerState { + return this.state + } + + get mediaInfo(): CencMediaInfo | null { + return this.info + } + + get element(): HTMLAudioElement { + return this.audio + } + + /** 加载并开始后台缓冲;autoplay 只表示首次缓冲后尝试调用 audio.play。 */ + async load(source: CencAudioSource, autoplay = false): Promise { + this.ensureAlive() + const session = this.beginSession() + this.autoplay = autoplay + this.emitState('loading') + let initialStream: SequentialByteStream | null = null + + try { + const url = this.validateUrl(source.url) + this.streamController = new AbortController() + initialStream = this.createNetworkStream(url, 0, this.streamController.signal) + let { head, meta } = await this.parseHeadFromStream(initialStream, session) + this.assertSession(session) + const key = await importCencKey(source.key) + this.assertSession(session) + + let counterStart: 0 | 1 = 0 + if (meta.codec === 'opus') { + // counter 探测仍消费同一条 fetch;若前六帧超出初始头,就继续多读一点。 + const probeEnd = meta.mdatStart + meta.offsets[Math.min(6, meta.sampleCount)]! + if (head.length < probeEnd) { + await initialStream.bufferAtLeast(probeEnd) + head = initialStream.snapshot() + } + counterStart = await this.detectCounterStart(meta, head, key) + } + this.assertSession(session) + + const sampleEntry = meta.codec === 'aac' + ? extractSampleEntry(head, meta.mdatStart) + : meta.codec === 'flac' + ? extractFlacEntry(head, meta.mdatStart) + : undefined + const mime = codecMime(meta.codec) + const ctor = this.mediaSourceConstructor() + if (!ctor.isTypeSupported(mime)) throw new Error(`浏览器不支持 MSE 音频格式: ${mime}`) + + const mediaSource = new ctor() + this.mediaSource = mediaSource + this.objectUrl = unsafeWindow.URL.createObjectURL(mediaSource) + const opened = this.waitForSourceOpen(mediaSource, session) + this.audio.src = this.objectUrl + await opened + this.assertSession(session) + this.sourceBuffer = mediaSource.addSourceBuffer(mime) + this.segmentSequence = 0 + const init = initSegment(meta, sampleEntry) + this.emitMessage(`追加 init 段 ${init.byteLength} 字节 (${mime})`, 'info') + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(init)) + }) + this.assertSession(session) + + const ticks = new Float64Array(meta.sampleCount + 1) + for (let i = 0; i < meta.sampleCount; i++) { + ticks[i + 1] = ticks[i]! + meta.durations[i]! + } + const durationSeconds = meta.totalTicks / meta.timescale + const samplesPerSecond = meta.sampleCount / durationSeconds + // WebM SimpleBlock 的相对时间戳是 Int16,Cluster 最长必须小于约 32.7 秒。 + const segmentSeconds = meta.codec === 'opus' + ? Math.min(this.options.segmentSeconds, MAX_WEBM_SEGMENT_SECONDS) + : this.options.segmentSeconds + this.context = { + url, + head, + meta, + key, + counterStart, + ticks, + samplesPerSecond, + samplesPerSegment: Math.max(1, Math.round(samplesPerSecond * segmentSeconds)), + } + this.info = { + codec: meta.codec, + sampleCount: meta.sampleCount, + durationSeconds, + timescale: meta.timescale, + channels: meta.channels, + encryptedBytes: meta.offsets[meta.sampleCount]!, + } + // MP4 头和音频正文来自同一个 fetch;丢掉头部后继续消费现有 reader。 + await initialStream.skip(meta.mdatStart) + this.assertSession(session) + this.emitState('ready') + this.startStream(0, initialStream) + initialStream = null // reader 的所有权已转交给 runStream + return this.info + } catch (error) { + void initialStream?.cancel() + if (this.isCurrent(session) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + throw error + } + } + + /** load(..., true) 的便捷形式。 */ + start(source: CencAudioSource): Promise { + return this.load(source, true) + } + + play(): Promise { + this.ensureAlive() + return this.audio.play().then(() => undefined) + } + + pause(): void { + this.audio.pause() + } + + /** 停止当前流并释放 MSE URL,但保留播放器实例以便再次 load。 */ + stop(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.emitState('idle') + } + + destroy(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.audio.removeEventListener('seeking', this.onSeeking) + this.audio.removeEventListener('play', this.onPlay) + this.audio.removeEventListener('pause', this.onPause) + this.audio.removeEventListener('ended', this.onEnded) + this.emitState('destroyed') + } + + private ensureAlive(): void { + if (this.state === 'destroyed') throw new Error('播放器已销毁') + } + + private beginSession(): number { + this.session++ + this.streamToken++ + this.seekToken++ + this.sessionController?.abort() + this.streamController?.abort() + this.sessionController = new AbortController() + this.streamController = null + this.context = null + this.info = null + this.autoplay = false + this.sourceBuffer = null + this.mediaSource = null + this.sourceBufferChain = Promise.resolve() + this.audio.pause() + if (this.audio.src) { + this.audio.removeAttribute('src') + this.audio.load() + } + if (this.objectUrl) { + unsafeWindow.URL.revokeObjectURL(this.objectUrl) + this.objectUrl = null + } + return this.session + } + + private validateUrl(raw: string): string { + const url = raw.trim() + const parsed = new URL(url) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('音频 URL 必须是 HTTP(S) 地址') + } + return parsed.toString() + } + + private mediaSourceConstructor(): typeof MediaSource { + const win = unsafeWindow as Window & { MediaSource?: typeof MediaSource } + const ctor = win.MediaSource ?? globalThis.MediaSource + if (!ctor) throw new Error('当前浏览器没有 MediaSource 支持') + return ctor + } + + /** + * 把字节搬到页面 realm,再交给 appendBuffer。 + * + * MediaSource 取自 unsafeWindow(页面 realm),而我们构造的 Uint8Array + * 属于用户脚本沙箱 realm。跨 realm 的 ArrayBuffer 传进 appendBuffer 会被拒, + * 表现是 SourceBuffer 立刻抛 error 事件、而 .error 仍是 null + * —— 只看报错完全看不出是 realm 问题。 + * + * 页面 realm 没暴露 Uint8Array 时(少见)就原样返回,交给浏览器自己判断。 + */ + private toPageBuffer(bytes: Uint8Array): BufferSource { + const win = unsafeWindow as Window & { Uint8Array?: Uint8ArrayConstructor } + const PageUint8Array = win.Uint8Array + if (!PageUint8Array || PageUint8Array === Uint8Array) return bytes as Bytes + const copy = new PageUint8Array(bytes.byteLength) as Bytes + copy.set(bytes) + return copy + } + + private async parseHeadFromStream( + stream: SequentialByteStream, + session: number, + ): Promise<{ head: Bytes; meta: CencMetadata }> { + let size = Math.min(this.options.headBytes, this.options.maxHeadBytes) + let lastError: unknown = null + while (size <= this.options.maxHeadBytes) { + const complete = await stream.bufferAtLeast(size) + this.assertSession(session) + const head = stream.snapshot() + try { + return { head, meta: parseCencMetadata(head) } + } catch (error) { + lastError = error + const message = asError(error).message + const incomplete = /未找到|不完整|缺少/.test(message) + if (!incomplete || !complete || size === this.options.maxHeadBytes) throw error + size = Math.min(this.options.maxHeadBytes, size * 2) + } + } + throw lastError instanceof Error ? lastError : new Error('无法解析音频 MP4 头') + } + + private createNetworkStream( + url: string, + start: number, + signal: AbortSignal, + initial: Uint8Array = new Uint8Array(), + ): SequentialByteStream { + return new SequentialByteStream( + initial, + null, + () => this.openAudioReader(url, start, signal), + ) + } + + /** + * 打开一条从指定字节一直到文件末尾的流。 + * 所有 CDN 请求都明确禁止 Referer、Cookie;顺序播放期间只会打开一次。 + */ + private async openAudioReader(url: string, start: number, signal: AbortSignal): Promise { + this.emitMessage(`打开音频流:bytes=${start}-`, 'info') + const response = await this.options.fetch(url, { + method: 'GET', + headers: { Range: `bytes=${start}-` }, + credentials: 'omit', + // 两项都设:policy 禁止浏览器生成 Referer,空 referrer 防止调用方 Request 继承。 + referrer: '', + referrerPolicy: 'no-referrer', + signal, + }) + if (!response.ok && response.status !== 206) { + throw new Error(`音频 CDN 请求失败(HTTP ${response.status})`) + } + if (start > 0 && response.status !== 206) { + throw new Error('音频 CDN 忽略了 Range,无法从拖动位置续流') + } + if (response.body) return response.body.getReader() + + // 极少数 WebView 不暴露 response.body,退回一次性读取,接口语义仍保持一致。 + const bytes = await response.arrayBuffer() + const fallback = new Response(bytes).body + if (!fallback) throw new Error('浏览器不支持流式读取 Response') + return fallback.getReader() + } + + private sessionSignal(session: number): AbortSignal { + this.assertSession(session) + return this.sessionController!.signal + } + + private assertSession(session: number): void { + if (!this.isCurrent(session)) throw abortError() + } + + private isCurrent(session: number): boolean { + return this.session === session && !this.sessionController?.signal.aborted + } + + private async waitForSourceOpen(mediaSource: MediaSource, session: number): Promise { + const signal = this.sessionSignal(session) + await new Promise((resolve, reject) => { + if (mediaSource.readyState === 'open') { + resolve() + return + } + const onOpen = (): void => { + cleanup() + resolve() + } + const onError = (): void => { + cleanup() + reject(new Error('MediaSource 打开失败')) + } + const onAbort = (): void => { + cleanup() + reject(abortError()) + } + const cleanup = (): void => { + mediaSource.removeEventListener('sourceopen', onOpen) + mediaSource.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + } + mediaSource.addEventListener('sourceopen', onOpen, { once: true }) + mediaSource.addEventListener('error', onError, { once: true }) + signal.addEventListener('abort', onAbort, { once: true }) + }) + } + + private enqueueSourceBuffer( + session: number, + operation: (sourceBuffer: SourceBuffer) => void, + ): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer) return Promise.reject(new Error('SourceBuffer 尚未创建')) + const signal = this.sessionSignal(session) + const run = async (): Promise => { + if (signal.aborted) throw abortError() + // 关键:上一次操作可能因为 abort/error 提前兑现了 Promise, + // 但浏览器仍在处理那次 appendBuffer/remove。这时直接下一个操作会抛 + // "This SourceBuffer is still processing an 'appendBuffer' or 'remove' + // operation.",所以必须等它真的空闲,不能只靠 Promise 链的顺序。 + await waitForSourceBufferIdle(sourceBuffer, signal) + if (signal.aborted) throw abortError() + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) return + settled = true + sourceBuffer.removeEventListener('updateend', onUpdateEnd) + sourceBuffer.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + if (error) reject(error) + else resolve() + } + const onUpdateEnd = (): void => finish() + // SourceBuffer 的 error 事件不带原因。注意 MSE 规范里这个事件 + // 先于媒体元素的错误传播,所以此刻 audio.error 常常还是 null, + // 不能据此断定「不是解码问题」。真实错误码要等一轮事件循环, + // 由下面 catch 里的 reportAppendFailure 补报 + const onError = (): void => finish(new Error('SourceBuffer 更新失败')) + const onAbort = (): void => finish(abortError()) + sourceBuffer.addEventListener('updateend', onUpdateEnd) + sourceBuffer.addEventListener('error', onError) + signal.addEventListener('abort', onAbort, { once: true }) + try { + operation(sourceBuffer) + } catch (error) { + finish(asError(error)) + } + }) + } + const next = this.sourceBufferChain.catch(() => undefined).then(run) + this.sourceBufferChain = next + return next + } + + private startStream(startSample: number, existingStream?: SequentialByteStream): void { + const context = this.context + if (!context || this.state === 'destroyed') return + if (!existingStream) { + this.streamController?.abort() + this.streamController = new AbortController() + } + const controller = this.streamController + if (!controller) return + const token = ++this.streamToken + void this.runStream( + context, + this.session, + token, + startSample, + controller.signal, + existingStream, + ) + } + + private async runStream( + context: StreamContext, + session: number, + token: number, + startSample: number, + signal: AbortSignal, + existingStream?: SequentialByteStream, + ): Promise { + let sample = startSample + let first = true + let stream: SequentialByteStream | null = existingStream ?? null + try { + stream ??= this.createSampleStream(context, startSample, signal) + while (this.isStreamCurrent(session, token, signal) && sample < context.meta.sampleCount) { + await this.waitForRoom(session, token, signal) + if (!this.isStreamCurrent(session, token, signal)) return + const endSample = Math.min(context.meta.sampleCount, sample + context.samplesPerSegment) + const byteLength = context.meta.offsets[endSample]! - context.meta.offsets[sample]! + const encrypted = await stream.readExactly(byteLength) + if (!this.isStreamCurrent(session, token, signal)) return + const plain = await this.decryptRange(context, encrypted, sample, endSample) + if (!this.isStreamCurrent(session, token, signal)) return + + const segment = context.meta.codec === 'opus' + ? buildWebmCluster( + Math.round(context.ticks[sample]! * 1000 / context.meta.timescale), + plain.map((_, index) => Math.round( + context.ticks[sample + index]! * 1000 / context.meta.timescale, + ) - Math.round(context.ticks[sample]! * 1000 / context.meta.timescale)), + plain, + ) + : buildMp4Segment( + context.ticks[sample]!, + plain.map((data, index) => ({ + data, + duration: context.meta.durations[sample + index]!, + })), + ++this.segmentSequence, + ) + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(segment)) + }) + if (first) { + // 首段最容易暴露数据问题:把解密后的特征打出来, + // 便于判断是取流坏了还是封装坏了 + const toc = plain[0]?.[0] + this.emitMessage( + `首段 samples=${sample}..${endSample} 密文${encrypted.byteLength}B ` + + `段长${segment.byteLength}B TOC=0x${(toc ?? 0).toString(16)}`, + 'info', + ) + } + if (!this.isStreamCurrent(session, token, signal)) return + await this.evictBehind(session) + + sample = endSample + this.emitProgress({ + processedSamples: sample, + totalSamples: context.meta.sampleCount, + percent: Math.round(sample / context.meta.sampleCount * 100), + bufferedAhead: this.bufferedAhead(), + durationSeconds: context.meta.totalTicks / context.meta.timescale, + }) + if (first && this.autoplay) { + first = false + void this.audio.play().catch(() => { + this.emitMessage('自动播放被浏览器拦截,请点击音频控件播放', 'warn') + }) + } else { + first = false + } + } + if (this.isStreamCurrent(session, token, signal)) { + // 缓冲到结尾必须通知 MediaSource 收口:不调 endOfStream 的话 + // duration 一直是 Infinity, 永远到不了结尾, + // ended 事件不触发(表现为播到最后还显示播放中但没声音, + // 自动切章也就不会发生)。真正的「播完」仍由 ended 事件上报。 + await this.finishStream(session, sample) + this.emitMessage('音频已缓冲到结尾', 'info') + } + } catch (error) { + if (this.isStreamCurrent(session, token, signal) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + } finally { + void stream?.cancel() + } + } + + /** + * 补报媒体元素的错误码。 + * + * SourceBuffer 的 error 事件先于媒体元素错误传播,同步读 audio.error + * 大概率是 null。让出一轮事件循环再读,才能拿到真正的 MediaError。 + */ + private async reportAppendFailure(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) + const detail = describeMediaError(this.audio) + if (detail) this.emitMessage(`媒体元素错误${detail}`, 'error') + } + + private isStreamCurrent(session: number, token: number, signal: AbortSignal): boolean { + return this.isCurrent(session) && this.streamToken === token && !signal.aborted + } + + /** + * 全部样本都送进 SourceBuffer 后收口 MediaSource。 + * + * 只有真的跑到最后一个样本才收口。seek 之后的流也会走到循环末尾, + * 但那时前面还有没缓冲的区间,提前 endOfStream 会把 duration 定在错的位置。 + */ + private async finishStream(session: number, lastSample: number): Promise { + const context = this.context + const mediaSource = this.mediaSource + if (!context || !mediaSource) return + if (lastSample < context.meta.sampleCount) return + if (mediaSource.readyState !== 'open') return + // 等 append 队列排空:还在 updating 时调 endOfStream 会抛 InvalidStateError。 + // 不能用 enqueueSourceBuffer 塞个空操作 —— 它靠 updateend 兑现, + // 空操作不产生事件,会永远挂住 + await this.sourceBufferChain.catch(() => undefined) + if (!this.isCurrent(session)) return + if (this.sourceBuffer) { + await waitForSourceBufferIdle(this.sourceBuffer, this.sessionSignal(session)) + } + if (!this.isCurrent(session) || mediaSource.readyState !== 'open') return + try { + mediaSource.endOfStream() + } catch (error) { + // 收口失败不影响已经缓冲好的部分,照常播,只是没有 ended + this.emitMessage(`标记音频结尾失败: ${asError(error).message}`, 'warn') + } + } + + private async waitForRoom( + session: number, + token: number, + signal: AbortSignal, + ): Promise { + while ( + this.isStreamCurrent(session, token, signal) && + this.bufferedAhead() >= this.options.maxBufferAheadSeconds + ) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + if (!this.isStreamCurrent(session, token, signal)) throw abortError() + } + + /** + * seek 后的新流。目标仍在已保存的 MP4 头内时先复用内存字节,读完再从 + * head.length 开一条网络流;否则直接从目标 sample 的绝对偏移续传。 + */ + private createSampleStream( + context: StreamContext, + startSample: number, + signal: AbortSignal, + ): SequentialByteStream { + const start = context.meta.mdatStart + context.meta.offsets[startSample]! + if (start < context.head.length) { + return this.createNetworkStream( + context.url, + context.head.length, + signal, + context.head.slice(start), + ) + } + return this.createNetworkStream(context.url, start, signal) + } + + private async decryptRange( + context: StreamContext, + encrypted: Uint8Array, + startSample: number, + endSample: number, + ): Promise { + const ivSize = cencIvSize(context.meta) + const jobs = [] as Array<{ iv: Uint8Array; ciphertext: Uint8Array }> + for (let i = startSample; i < endSample; i++) { + const relative = context.meta.offsets[i]! - context.meta.offsets[startSample]! + const size = context.meta.sizes[i]! + jobs.push({ + iv: context.meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext: encrypted.slice(relative, relative + size), + }) + } + return mapLimit(jobs, this.options.decryptConcurrency, job => decryptCencSample( + context.key, + job.iv, + job.ciphertext, + context.counterStart, + )) + } + + private async detectCounterStart( + meta: CencMetadata, + head: Bytes, + key: CryptoKey, + ): Promise<0 | 1> { + const count = Math.min(6, meta.sampleCount) + if (count === 0) return 0 + const start = meta.mdatStart + meta.offsets[0]! + const end = meta.mdatStart + meta.offsets[count]! - 1 + if (end >= head.length) throw new Error('Opus counter 探测数据不完整') + const encrypted = head.slice(start, end + 1) + const ivSize = cencIvSize(meta) + for (const candidate of [0, 1] as const) { + const firstBytes = new Set() + for (let i = 0; i < count; i++) { + const relative = meta.offsets[i]! + const ciphertext = encrypted.slice(relative, relative + meta.sizes[i]!) + const plain = await decryptCencSample( + key, + meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext, + candidate, + ) + if (plain.length > 0) firstBytes.add(plain[0]!) + } + if (firstBytes.size === 1) { + this.emitMessage(`CENC counter 低位起始=${candidate}`, 'info') + return candidate + } + } + this.emitMessage('CENC counter 未收敛,按 0 继续', 'warn') + return 0 + } + + private async evictBehind(session: number): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer || !sourceBuffer.buffered.length) return + const current = this.audio.currentTime + if (current <= this.options.keepBehindSeconds + 5) return + const start = sourceBuffer.buffered.start(0) + const end = current - this.options.keepBehindSeconds + if (start < end) { + try { + await this.enqueueSourceBuffer(session, buffer => buffer.remove(start, end)) + } catch { + // 清理失败不影响继续播放,下一段仍可能触发浏览器自己的回收。 + } + } + } + + private async restartAt(target: number, wasPlaying: boolean): Promise { + const context = this.context + if (!context) return + if (this.audio.error) { + this.emitMessage('播放元素已进入错误态,无法跳转,请重新开始听书', 'warn') + return + } + const session = this.session + const seek = ++this.seekToken + this.streamToken++ + this.streamController?.abort() + + let low = 0 + let high = context.meta.sampleCount - 1 + let index = 0 + const targetTicks = target * context.meta.timescale + while (low <= high) { + const middle = (low + high) >> 1 + if (context.ticks[middle]! <= targetTicks) { + index = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + const preSamples = Math.max(1, Math.round(context.samplesPerSecond * 2)) + const startSample = Math.max(0, index - preSamples) + this.emitMessage(`跳转到 ${target.toFixed(1)} 秒`, 'info') + + // 先让上一条流排队中的 append 走完,再读 buffered。 + // 否则 remove 的范围是旧的,而且紧接着的 append 会撞上还在处理的操作 + await this.sourceBufferChain.catch(() => undefined) + if (seek !== this.seekToken || !this.isCurrent(session)) return + + const bufferedEnd = this.sourceBuffer?.buffered.length + ? this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) + : 0 + if (bufferedEnd > 0) { + try { + await this.enqueueSourceBuffer(session, sourceBuffer => sourceBuffer.remove(0, bufferedEnd + 0.5)) + } catch { + // 后续 append 会给出更具体的错误;这里不让一次清理失败阻断 seek。 + } + } + if (seek !== this.seekToken || !this.isCurrent(session)) return + // 已经 endOfStream 过时 readyState 是 'ended',append 会让它自动回到 'open'; + // 只有 'closed'(SourceBuffer 已被 detach)才真的没救 + if (this.mediaSource && this.mediaSource.readyState === 'closed') { + this.emitMessage('媒体源已关闭,无法跳转', 'warn') + return + } + this.startStream(startSample) + if (wasPlaying) void this.audio.play().catch(() => undefined) + } + + private isTimeBuffered(time: number): boolean { + const ranges = this.sourceBuffer?.buffered + if (!ranges) return false + for (let i = 0; i < ranges.length; i++) { + if (time >= ranges.start(i) - 0.05 && time <= ranges.end(i) + 0.05) return true + } + return false + } + + private bufferedAhead(): number { + const ranges = this.sourceBuffer?.buffered + if (!ranges || !ranges.length) return 0 + const current = this.audio.currentTime + for (let i = 0; i < ranges.length; i++) { + if (current >= ranges.start(i) - 0.05 && current <= ranges.end(i) + 0.05) { + return Math.max(0, ranges.end(i) - current) + } + } + return Math.max(0, ranges.end(ranges.length - 1) - current) + } + + private emitState(state: CencPlayerState): void { + this.state = state + this.options.onStateChange?.(state) + } + + private emitProgress(progress: CencProgress): void { + this.options.onProgress?.(progress) + } + + private emitMessage(message: string, level: 'info' | 'warn' | 'error'): void { + this.options.onMessage?.(message, level) + } +} diff --git a/src/player/bytes.ts b/src/player/bytes.ts new file mode 100644 index 0000000..219f5ae --- /dev/null +++ b/src/player/bytes.ts @@ -0,0 +1,109 @@ +/** 二进制播放器使用的基础字节工具。 */ + +export type Bytes = Uint8Array + +export function asBytes(value: ArrayBuffer | Uint8Array): Bytes { + if (value instanceof Uint8Array) { + return new Uint8Array(value) as Bytes + } + return new Uint8Array(value) as Bytes +} + +export function concatBytes(...arrays: readonly Uint8Array[]): Bytes { + const length = arrays.reduce((sum, array) => sum + array.byteLength, 0) + const result = new Uint8Array(length) + let offset = 0 + for (const array of arrays) { + result.set(array, offset) + offset += array.byteLength + } + return result as Bytes +} + +export function ascii(text: string): Bytes { + const result = new Uint8Array(text.length) + for (let i = 0; i < text.length; i++) { + result[i] = text.charCodeAt(i) & 0xff + } + return result as Bytes +} + +export function hexToBytes(text: string): Bytes { + const hex = text.replace(/\s+/g, '') + if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) { + throw new Error('无效的十六进制数据') + } + const result = new Uint8Array(hex.length / 2) + for (let i = 0; i < result.length; i++) { + result[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return result as Bytes +} + +export function uintBytes(value: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`无效的无符号整数: ${value}`) + } + if (value === 0) return new Uint8Array([0]) as Bytes + + const result: number[] = [] + let current = value + while (current > 0) { + result.unshift(current & 0xff) + current = Math.floor(current / 256) + } + return new Uint8Array(result) as Bytes +} + +export function be16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setUint16(0, value) + return result as Bytes +} + +export function be32(value: number): Bytes { + const result = new Uint8Array(4) + new DataView(result.buffer).setUint32(0, value >>> 0) + return result as Bytes +} + +export function be64(value: number | bigint): Bytes { + const result = new Uint8Array(8) + new DataView(result.buffer).setBigUint64(0, BigInt(value)) + return result as Bytes +} + +export function findBytes( + buffer: Uint8Array, + fourcc: string, + from = 0, + to = buffer.length - 4, +): number { + if (fourcc.length !== 4) throw new Error(`fourcc 必须是 4 个字符: ${fourcc}`) + const c0 = fourcc.charCodeAt(0) + const c1 = fourcc.charCodeAt(1) + const c2 = fourcc.charCodeAt(2) + const c3 = fourcc.charCodeAt(3) + for (let i = from; i <= to; i++) { + if ( + buffer[i] === c0 && + buffer[i + 1] === c1 && + buffer[i + 2] === c2 && + buffer[i + 3] === c3 + ) return i + } + return -1 +} + +export function strBytes(text: string): Bytes { + return new TextEncoder().encode(text) as Bytes +} + +export function readFourcc(buffer: Uint8Array, offset: number): string { + return String.fromCharCode( + buffer[offset] ?? 0, + buffer[offset + 1] ?? 0, + buffer[offset + 2] ?? 0, + buffer[offset + 3] ?? 0, + ) +} diff --git a/src/player/cenc.ts b/src/player/cenc.ts new file mode 100644 index 0000000..0945b24 --- /dev/null +++ b/src/player/cenc.ts @@ -0,0 +1,287 @@ +import { asBytes, type Bytes, hexToBytes, findBytes, readFourcc } from './bytes' +import { getSubtle } from '../crypto' + +export type CencCodec = 'opus' | 'aac' | 'flac' + +export interface CencMetadata { + codec: CencCodec + sampleCount: number + sizes: Uint32Array + /** 每个采样的 8/16 字节 CENC IV,按 sample 顺序拼接 */ + ivs: Bytes + durations: Uint32Array + timescale: number + channels: number + preSkip: number + /** mdat 的负载起点,相对于 head buffer */ + mdatStart: number + /** 每个 sample 在 mdat 负载中的累计偏移,长度为 sampleCount + 1 */ + offsets: Float64Array + totalTicks: number +} + +export interface CencKey { + key: CryptoKey + /** AES-CTR counter 的低位初值,CENC 常见为 0;某些流使用 1 */ + counterStart: 0 | 1 +} + +interface IsoBox { + type: string + offset: number + size: number + headerSize: number + end: number +} + +const CONTAINERS = new Set([ + 'moov', 'trak', 'mdia', 'minf', 'stbl', 'edts', 'dinf', 'udta', 'mvex', 'meta', +]) + +function readBox(buffer: Uint8Array, offset: number, limit: number): IsoBox | null { + if (offset + 8 > limit) return null + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + let headerSize = 8 + let size: number + + if (size32 === 1) { + if (offset + 16 > limit) return null + const extended = view.getBigUint64(offset + 8) + if (extended > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`MP4 box 过大: ${type}`) + } + size = Number(extended) + headerSize = 16 + } else if (size32 === 0) { + size = limit - offset + } else { + size = size32 + } + + if (size < headerSize || offset + size > limit) return null + return { type, offset, size, headerSize, end: offset + size } +} + +function walkBoxes( + buffer: Uint8Array, + start: number, + end: number, + visit: (box: IsoBox) => void, + depth = 0, +): void { + if (depth > 16) return + let offset = start + while (offset + 8 <= end) { + const box = readBox(buffer, offset, end) + if (!box) return + visit(box) + if (CONTAINERS.has(box.type)) { + // meta 是 FullBox,版本/flags 后才开始嵌套 box。 + const childStart = box.offset + box.headerSize + (box.type === 'meta' ? 4 : 0) + if (childStart < box.end) walkBoxes(buffer, childStart, box.end, visit, depth + 1) + } + offset = box.end + } +} + +function findBox(boxes: readonly IsoBox[], type: string): IsoBox | undefined { + return boxes.find(box => box.type === type) +} + +/** 读取顶层 mdat 的负载起点,即使 mdat 本体不在当前 head buffer 内也能定位。 */ +function findMdatStart(buffer: Uint8Array): number { + let offset = 0 + while (offset + 8 <= buffer.length) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + const headerSize = size32 === 1 ? 16 : 8 + if (type === 'mdat') return offset + headerSize + if (size32 === 0) return -1 + if (size32 === 1) { + if (offset + 16 > buffer.length) return -1 + const size = view.getBigUint64(offset + 8) + if (size > BigInt(Number.MAX_SAFE_INTEGER)) return -1 + offset += Number(size) + } else { + offset += size32 + } + } + return -1 +} + +function parseSampleSizes(box: IsoBox, view: DataView): Uint32Array { + if (box.offset + 20 > box.end) throw new Error('stsz box 不完整') + const sampleSize = view.getUint32(box.offset + 12) + const count = view.getUint32(box.offset + 16) + const sizes = new Uint32Array(count) + if (sampleSize !== 0) { + sizes.fill(sampleSize) + return sizes + } + if (box.offset + 20 + count * 4 > box.end) throw new Error('stsz 采样表不完整') + for (let i = 0; i < count; i++) sizes[i] = view.getUint32(box.offset + 20 + i * 4) + return sizes +} + +function parseDurations(box: IsoBox, sampleCount: number, view: DataView): Uint32Array { + if (box.offset + 16 > box.end) throw new Error('stts box 不完整') + const entryCount = view.getUint32(box.offset + 12) + const durations = new Uint32Array(sampleCount) + let index = 0 + for (let i = 0; i < entryCount; i++) { + const offset = box.offset + 16 + i * 8 + if (offset + 8 > box.end) throw new Error('stts 采样表不完整') + const count = view.getUint32(offset) + const duration = view.getUint32(offset + 4) + for (let j = 0; j < count && index < sampleCount; j++) durations[index++] = duration + } + if (index !== sampleCount) throw new Error(`stts 采样数不一致: ${index}/${sampleCount}`) + return durations +} + +function parseIvs(box: IsoBox, sampleCount: number, view: DataView): Bytes { + if (box.offset + 16 > box.end) throw new Error('senc box 不完整') + const flags = view.getUint32(box.offset + 8) & 0xffffff + if ((flags & 0x02) !== 0) throw new Error('暂不支持带 subsample encryption 的音频') + const count = view.getUint32(box.offset + 12) + if (count !== sampleCount) throw new Error(`senc 采样数不一致: ${count}/${sampleCount}`) + const dataStart = box.offset + 16 + const dataLength = box.end - dataStart + if (count === 0 || dataLength % count !== 0) throw new Error('senc IV 表不完整') + const ivLength = dataLength / count + if (ivLength !== 8 && ivLength !== 16) throw new Error(`不支持的 CENC IV 长度: ${ivLength}`) + return asBytes(new Uint8Array(view.buffer, view.byteOffset + dataStart, dataLength)) +} + +function parseTimescale(box: IsoBox, view: DataView): number { + if (box.offset + 24 > box.end) throw new Error('mdhd box 不完整') + const version = view.getUint8(box.offset + 8) + const offset = version === 1 ? box.offset + 28 : box.offset + 20 + if (offset + 4 > box.end) throw new Error('mdhd timescale 缺失') + const timescale = view.getUint32(offset) + if (timescale === 0) throw new Error('MP4 timescale 为 0') + return timescale +} + +function parseCodec(buffer: Uint8Array, mdatStart: number): Pick { + const end = Math.max(0, Math.min(mdatStart - 1, buffer.length - 4)) + const dOps = findBytes(buffer, 'dOps', 0, end) + if (dOps >= 0) { + const channels = buffer[dOps + 5] ?? 1 + const preSkip = ((buffer[dOps + 6] ?? 0) << 8) | (buffer[dOps + 7] ?? 0) + return { codec: 'opus', channels, preSkip } + } + + // AudioSampleEntry 的 channelcount 位于 type 后 20 字节;加密入口也保留这段布局。 + const encodedEntry = findBytes(buffer, 'enca', 0, end) + const plainEntry = findBytes(buffer, 'mp4a', 0, end) + const entryType = encodedEntry >= 0 ? encodedEntry : plainEntry + const channels = entryType >= 0 && entryType + 22 <= buffer.length + ? new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16(entryType + 20) + : 1 + if (findBytes(buffer, 'dfLa', 0, end) >= 0) { + return { codec: 'flac', channels: channels || 1, preSkip: 0 } + } + if (entryType >= 0) { + return { codec: 'aac', channels: channels || 1, preSkip: 0 } + } + throw new Error('未识别的音频编码(需要 Opus、AAC 或 FLAC)') +} + +/** 解析一份已包含 moov 元数据的 MP4 CENC 头。 */ +export function parseCencMetadata(head: Uint8Array): CencMetadata { + const mdatStart = findMdatStart(head) + if (mdatStart < 0) throw new Error('未找到 mdat,MP4 头可能尚未拉完整') + + const boxes: IsoBox[] = [] + walkBoxes(head, 0, Math.min(mdatStart - 8, head.length), box => boxes.push(box)) + const stsz = findBox(boxes, 'stsz') + const stts = findBox(boxes, 'stts') + const senc = findBox(boxes, 'senc') + const mdhd = findBox(boxes, 'mdhd') + if (!stsz || !stts || !senc || !mdhd) { + throw new Error('CENC MP4 头缺少 stsz/stts/senc/mdhd') + } + + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const sizes = parseSampleSizes(stsz, view) + const durations = parseDurations(stts, sizes.length, view) + const ivs = parseIvs(senc, sizes.length, view) + const timescale = parseTimescale(mdhd, view) + const codec = parseCodec(head, mdatStart) + const offsets = new Float64Array(sizes.length + 1) + for (let i = 0; i < sizes.length; i++) { + offsets[i + 1] = offsets[i]! + sizes[i]! + if (!Number.isSafeInteger(offsets[i + 1])) throw new Error('音频采样数据超过 JavaScript 安全整数范围') + } + + let totalTicks = 0 + for (const duration of durations) { + totalTicks += duration + if (!Number.isSafeInteger(totalTicks)) throw new Error('音频总时长超过 JavaScript 安全整数范围') + } + return { + ...codec, + sampleCount: sizes.length, + sizes, + ivs, + durations, + timescale, + mdatStart, + offsets, + totalTicks, + } +} + +export function cencIvSize(meta: CencMetadata): number { + return meta.ivs.length / meta.sampleCount +} + +export function normalizeCencKey(value: string | ArrayBuffer | Uint8Array): Bytes { + if (typeof value === 'string') { + const bytes = hexToBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 32 位 hex') + return bytes + } + const bytes = asBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 16 字节') + return bytes.slice() as Bytes +} + +export async function importCencKey(value: string | ArrayBuffer | Uint8Array): Promise { + return getSubtle().importKey( + 'raw', + normalizeCencKey(value), + { name: 'AES-CTR' }, + false, + ['decrypt'], + ) +} + +/** 按 CENC 的 8/16 字节 IV 构造 Web Crypto 的 16 字节 counter。 */ +export function makeCencCounter(iv: Uint8Array, counterStart: 0 | 1): Bytes { + if (iv.length !== 8 && iv.length !== 16) throw new Error(`无效的 CENC IV 长度: ${iv.length}`) + const counter = new Uint8Array(16) + counter.set(iv) + if (counterStart === 1) counter[15] = 1 + return counter as Bytes +} + +export async function decryptCencSample( + key: CryptoKey, + iv: Uint8Array, + ciphertext: Uint8Array, + counterStart: 0 | 1, +): Promise { + // TS 6 会把普通 Uint8Array 视为可能由 SharedArrayBuffer 支撑;Web Crypto 不接受它。 + const data = asBytes(ciphertext) + const plain = await getSubtle().decrypt( + { name: 'AES-CTR', counter: makeCencCounter(iv, counterStart), length: 64 }, + key, + data, + ) + return new Uint8Array(plain) as Bytes +} diff --git a/src/player/gmFetch.ts b/src/player/gmFetch.ts new file mode 100644 index 0000000..1c5c4a6 --- /dev/null +++ b/src/player/gmFetch.ts @@ -0,0 +1,191 @@ +// 音频 CDN 的取流通道。 +// +// 为什么不用页面 fetch:番茄网页站的 CSP(report-only)里 connect-src 没有任何 +// *vod.com 域名,音频 CDN 一个都不在白名单上。report-only 不会真拦请求,但每次 +// 都会往 mon.zijieapi.com/monitor_browser/collect/batch/security/ 上报一条违规, +// 等于把听书行为连着 URL 一起报给服务端。所以走 GM_xmlhttpRequest: +// 它在页面 CSP 之外,既不触发上报,也天然不带页面 Referer 和 Cookie。 +// +// 试过换 aid 让服务端返回白名单内的域名,行不通 —— CDN 域名跟着 aid 所属产品走: +// 1967 番茄小说 -> *.fqnovelvod.com 3040 番茄畅听 -> *.novelfmvod.com +// 13/35 头条 -> *.toutiaovod.com 6589 -> *.wkbrowser.com +// 1217/1128/8478/32/596084 -> PARAM_INVALID(不支持听书接口) +// 没有一个落在 connect-src 里,而且签名里的 aid 必须与 query 一致, +// 否则网关直接回 200 空响应体。 + +type AnyResponse = Tampermonkey.Response & { response?: unknown } + +/** + * 用 GM_xmlhttpRequest 实现的、够播放器用的 fetch 子集。 + * + * 只支持播放器实际用到的部分:GET + Range + AbortSignal + 流式 body。 + * + * 关于 responseType 的坑(踩过两次,记在这): + * - `'stream'`:TM 会把 ReadableStream 放进 res.response,但**不是**在 + * readyState 2 那一刻就绪。在 readyState 2 判断 instanceof 永远失败。 + * - `'arraybuffer'`:res.response 只在 readyState 4 才有值,onprogress + * 期间一直是 null,拿不到增量。 + * - `responseText`:绝对不能用来还原二进制。它按字符解码过,>0xFF 的码位 + * 已经丢了原始字节,喂进 MSE 会直接报 SourceBuffer 更新失败。 + * 所以这里两种都试:优先等 'stream',拿不到就退回整段 arraybuffer。 + */ +export function gmAudioFetch( + input: string | URL | Request, + init: RequestInit = {}, +): Promise { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const headers = normalizeHeaders(init.headers) + const signal = init.signal ?? null + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError()) + return + } + + let settled = false + let request: { abort: () => void } | undefined + + const onAbort = (): void => { + try { request?.abort() } catch { /* 尚未开始或已结束 */ } + if (settled) return + settled = true + reject(abortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + + const cleanup = (): void => { + signal?.removeEventListener('abort', onAbort) + } + + const succeed = (res: Tampermonkey.ResponseBase, body: BodyInit | null): void => { + if (settled) return + settled = true + cleanup() + resolve(makeResponse(res, body)) + } + + const fail = (error: Error): void => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + request = GM_xmlhttpRequest({ + method: 'GET', + url, + headers, + responseType: 'stream', + // GM 请求不带页面 Referer;anonymous 同时也不带 Cookie + anonymous: true, + onreadystatechange(res) { + // readyState 3/4 时 TM 才把 stream 挂上来。拿到就立刻交出去, + // 播放器那边就能边下边解密,不必等整段下完 + if (settled || res.readyState < 3) return + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) succeed(res, stream) + }, + onload(res) { + if (settled) return + // 没走成 stream:这时 readyState 已是 4,response 里是完整数据 + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) { + succeed(res, stream) + return + } + const bytes = toBytes(res) + if (!bytes) { + fail(new Error( + 'GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)', + )) + return + } + if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) { + fail(new Error('音频响应为空')) + return + } + succeed(res, bytes as unknown as BodyInit) + }, + onerror(res) { + fail(new Error(`音频 CDN 请求失败:${describe(res)}`)) + }, + ontimeout() { + fail(new Error('音频 CDN 请求超时')) + }, + }) as unknown as { abort: () => void } + }) +} + +function abortError(): DOMException { + return new DOMException('音频请求已取消', 'AbortError') +} + +/** 把 GM 的响应头文本解析成 Headers */ +function parseHeaders(raw: string | undefined): Headers { + const headers = new Headers() + if (!raw) return headers + for (const line of raw.split(/\r?\n/)) { + const colon = line.indexOf(':') + if (colon <= 0) continue + const name = line.slice(0, colon).trim() + const value = line.slice(colon + 1).trim() + if (!name) continue + try { + headers.append(name, value) + } catch { + // 个别响应头名字不合规范(比如带空格),跳过即可 + } + } + return headers +} + +/** + * 用 GM 的状态和响应头拼一个 Response。 + * + * 播放器只读 status / ok / body,不需要还原全部语义。 + * body 为空时给一条空流:206 是合法状态,但 `new Response(null, {status:206})` + * 会抛错(只有 204/304 允许空 body)。 + */ +function makeResponse(res: Tampermonkey.ResponseBase, body: BodyInit | null): Response { + return new Response(body ?? new Uint8Array(), { + status: res.status || 200, + statusText: res.statusText || '', + headers: parseHeaders(res.responseHeaders), + }) +} + +/** + * 从 GM 响应里取出完整字节。 + * + * 只认真正的二进制载荷 —— 见文件头关于 responseText 的说明, + * 拿不到就返回 null 让上层报错,比悄悄喂坏数据好。 + */ +function toBytes(res: Tampermonkey.Response): Uint8Array | null { + const raw = (res as AnyResponse).response + if (raw instanceof ArrayBuffer) return new Uint8Array(raw) + if (raw instanceof Uint8Array) return raw + return null +} + +function normalizeHeaders(init: HeadersInit | undefined): Record { + const out: Record = {} + if (!init) return out + if (init instanceof Headers) { + init.forEach((value, key) => { out[key] = value }) + return out + } + if (Array.isArray(init)) { + for (const [key, value] of init) { + if (key !== undefined && value !== undefined) out[key] = value + } + return out + } + return { ...init } +} + +function describe(res: Tampermonkey.ErrorResponse | Tampermonkey.ResponseBase): string { + if ('error' in res && res.error) return String(res.error) + const status = (res as Tampermonkey.ResponseBase).status + return status ? `HTTP ${status}` : '网络错误' +} diff --git a/src/player/index.ts b/src/player/index.ts new file mode 100644 index 0000000..01ff874 --- /dev/null +++ b/src/player/index.ts @@ -0,0 +1,24 @@ +/** + * CENC 听书播放器。 + * + * 最小用法: + * const player = new CencAudioPlayer(audioElement) + * await player.start({ url, key }) + * // 切章:再次 start;离开听书页:player.destroy() + */ +export { + CencAudioPlayer, + type CencAudioPlayerCallbacks, + type CencAudioPlayerOptions, + type CencAudioSource, + type CencMediaInfo, + type CencPlayerState, + type CencProgress, +} from './CencAudioPlayer' + +export { + normalizeCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' diff --git a/src/player/remux.ts b/src/player/remux.ts new file mode 100644 index 0000000..b419d33 --- /dev/null +++ b/src/player/remux.ts @@ -0,0 +1,260 @@ +import { ascii, be16, be32, be64, concatBytes, findBytes, hexToBytes, strBytes, uintBytes, type Bytes } from './bytes' +import type { CencCodec, CencMetadata } from './cenc' + +function ebmlVint(value: number, length: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0 || length < 1 || length > 8) { + throw new Error(`无效的 EBML VINT: ${value}`) + } + const result = new Uint8Array(length) + result[0] = 0x80 >> (length - 1) + let current = value + for (let i = length - 1; i > 0; i--) { + result[i] = current & 0xff + current = Math.floor(current / 256) + } + const max = Math.pow(2, 7 * length) + if (current >= max) throw new Error(`EBML VINT 溢出: ${value}`) + result[0] |= current + return result as Bytes +} + +/** + * 编码 EBML 元素长度。 + * + * 关键约束:全 1 的 VINT(1 字节的 0xFF、2 字节的 0x7FFF……)在 EBML 里是保留值, + * 表示「长度未知」。所以 2^(7n)-1 这些边界值必须多用一个字节来编, + * 否则解析器会把这个元素当成 unknown-size,从它开始一路吞掉后面的数据。 + * + * 踩过的坑:Opus 帧正好 127 字节时 SimpleBlock 的 payload 长度就是 127, + * 编成 0xFF 之后 Chromium 报 CHUNK_DEMUXER_ERROR_APPEND_FAILED, + * ffmpeg 报 "Unknown-sized element inside parent with finite size", + * 750 帧里只解出 8 帧。 + */ +function ebmlSize(value: number): Bytes { + for (let length = 1; length <= 8; length++) { + // < 2^(7n)-1:留出全 1 这个保留值,等于时进位到下一个长度 + if (value < Math.pow(2, 7 * length) - 1) return ebmlVint(value, length) + } + throw new Error(`EBML size 过大: ${value}`) +} + +function ebmlElement(id: string, payload: Uint8Array): Bytes { + return concatBytes(hexToBytes(id), ebmlSize(payload.length), payload) +} + +function mp4Box(type: string, payload: Uint8Array): Bytes { + return concatBytes(be32(8 + payload.length), ascii(type), payload) +} + +function matrixIdentity(): Bytes { + const matrix = new Uint8Array(36) + const view = new DataView(matrix.buffer) + view.setUint32(0, 0x10000) + view.setUint32(16, 0x10000) + view.setUint32(32, 0x40000000) + return matrix as Bytes +} + +function signedBe16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setInt16(0, value) + return result as Bytes +} + +/** 构造只包含 Opus 音轨的 WebM 初始化段。 */ +export function buildWebmInit(channels: number, preSkip: number, durationSeconds: number): Bytes { + const ebml = ebmlElement('1A45DFA3', concatBytes( + ebmlElement('4286', hexToBytes('01')), + ebmlElement('42F7', hexToBytes('01')), + ebmlElement('42F2', hexToBytes('04')), + ebmlElement('42F3', hexToBytes('08')), + ebmlElement('4282', strBytes('webm')), + ebmlElement('4287', hexToBytes('04')), + ebmlElement('4285', hexToBytes('02')), + )) + const codecDelayNs = Math.round(preSkip * 1e9 / 48000) + const duration = new Uint8Array(8) + new DataView(duration.buffer).setFloat64(0, durationSeconds, false) + const info = ebmlElement('1549A966', concatBytes( + ebmlElement('2AD7B1', uintBytes(1000000)), + ebmlElement('4489', duration as Bytes), + ebmlElement('4D80', strBytes('clearKeyStreamer')), + ebmlElement('5741', strBytes('clearKeyStreamer')), + )) + const sampleRate = new Uint8Array(4) + new DataView(sampleRate.buffer).setFloat32(0, 48000, false) + const audio = ebmlElement('E1', concatBytes( + ebmlElement('B5', sampleRate as Bytes), + ebmlElement('9F', uintBytes(channels)), + )) + const track = ebmlElement('AE', concatBytes( + ebmlElement('D7', hexToBytes('01')), + ebmlElement('73C5', uintBytes(1)), + ebmlElement('83', hexToBytes('02')), + ebmlElement('86', strBytes('A_OPUS')), + ebmlElement('63A2', buildOpusHead(channels, preSkip)), + ebmlElement('56AA', uintBytes(codecDelayNs)), + ebmlElement('56BB', uintBytes(80000000)), + audio, + )) + const tracks = ebmlElement('1654AE6B', track) + // Unknown-size Segment permits later clusters and seek-triggered re-buffering. + return concatBytes(ebml, hexToBytes('18538067'), hexToBytes('01FFFFFFFFFFFFFF'), info, tracks) +} + +function buildOpusHead(channels: number, preSkip: number): Bytes { + const head = new Uint8Array(19) + head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0) + const view = new DataView(head.buffer) + head[8] = 1 + head[9] = channels + view.setUint16(10, preSkip, true) + view.setUint32(12, 48000, true) + view.setInt16(16, 0, true) + head[18] = 0 + return head as Bytes +} + +/** 构造一个包含若干 Opus Block 的 WebM Cluster。 */ +export function buildWebmCluster(baseMs: number, relativeMs: readonly number[], packets: readonly Uint8Array[]): Bytes { + if (relativeMs.length !== packets.length) throw new Error('WebM 时间戳和采样数不一致') + const blocks: Bytes[] = [] + for (let i = 0; i < packets.length; i++) { + const relative = relativeMs[i] + if (relative === undefined || relative < -32768 || relative > 32767) { + throw new Error(`WebM 相对时间戳超出 Int16: ${relative}`) + } + const trackAndFlags = new Uint8Array([0x81, 0x80]) + blocks.push(ebmlElement('A3', concatBytes( + trackAndFlags.slice(0, 1), + signedBe16(relative), + trackAndFlags.slice(1), + packets[i]!, + ))) + } + return ebmlElement('1F43B675', concatBytes( + ebmlElement('E7', uintBytes(baseMs)), + concatBytes(...blocks), + )) +} + +function replaceFourcc(buffer: Bytes, offset: number, type: string): void { + buffer.set(ascii(type), offset) +} + +/** 从原始 MP4 头复制采样入口,并把加密入口改回浏览器可识别的明文入口。 */ +export function extractSampleEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + const plain = findBytes(head, 'mp4a', 0, mdatStart - 1) + const typeOffset = encoded >= 0 ? encoded : plain + if (typeOffset < 4) throw new Error('未找到 AAC 音频采样入口') + const entryOffset = typeOffset - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('AAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + const enca = findBytes(entry, 'enca', 0) + if (enca >= 0) replaceFourcc(entry, enca, 'mp4a') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 从原始 MP4 头复制 FLAC 采样入口,并去掉 CENC 标记。 */ +export function extractFlacEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + if (encoded < 4) throw new Error('未找到 FLAC 加密采样入口') + const entryOffset = encoded - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('FLAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + replaceFourcc(entry, encoded - entryOffset, 'fLaC') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 构造 AAC/FLAC 的 fragmented MP4 初始化段。 */ +export function buildMp4Init(sampleEntry: Uint8Array, timescale: number, totalTicks: number): Bytes { + const totalSeconds = totalTicks / timescale + const matrix = matrixIdentity() + const mvhd = mp4Box('mvhd', concatBytes( + be32(0), be32(0), be32(0), be32(1000), be32(Math.round(totalSeconds * 1000)), + be32(0x10000), be16(0x100), be16(0), new Uint8Array(8), + matrix, new Uint8Array(24), be32(2), + )) + const tkhd = mp4Box('tkhd', concatBytes( + be32(7), be32(0), be32(0), be32(1), be32(0), be32(0), new Uint8Array(8), + be16(0), be16(0), be16(0x100), be16(0), matrix, be32(0), be32(0), + )) + const mdhd = mp4Box('mdhd', concatBytes( + be32(0), be32(0), be32(0), be32(timescale), be32(totalTicks), be16(0x55c4), be16(0), + )) + const hdlr = mp4Box('hdlr', concatBytes( + be32(0), be32(0), ascii('soun'), new Uint8Array(12), + )) + const smhd = mp4Box('smhd', concatBytes(be32(0), be16(0), be16(0))) + const url = mp4Box('url ', be32(1)) + const dref = mp4Box('dref', concatBytes(be32(0), be32(1), url)) + const dinf = mp4Box('dinf', dref) + const stsd = mp4Box('stsd', concatBytes(be32(0), be32(1), sampleEntry)) + const stts = mp4Box('stts', concatBytes(be32(0), be32(0))) + const stsc = mp4Box('stsc', concatBytes(be32(0), be32(0))) + const stsz = mp4Box('stsz', concatBytes(be32(0), be32(0), be32(0))) + const stco = mp4Box('stco', concatBytes(be32(0), be32(0))) + const stbl = mp4Box('stbl', concatBytes(stsd, stts, stsc, stsz, stco)) + const minf = mp4Box('minf', concatBytes(smhd, dinf, stbl)) + const mdia = mp4Box('mdia', concatBytes(mdhd, hdlr, minf)) + const trak = mp4Box('trak', concatBytes(tkhd, mdia)) + const trex = mp4Box('trex', concatBytes(be32(0), be32(1), be32(1), be32(0), be32(0), be32(0))) + const mvex = mp4Box('mvex', trex) + const moov = mp4Box('moov', concatBytes(mvhd, trak, mvex)) + const ftyp = mp4Box('ftyp', concatBytes(ascii('isom'), be32(512), ascii('isomiso2mp41dash'))) + return concatBytes(ftyp, moov) +} + +export interface Mp4Sample { + data: Uint8Array + duration: number +} + +/** 构造一个 fragmented MP4 media segment。 */ +export function buildMp4Segment( + baseDecodeTick: number, + samples: readonly Mp4Sample[], + sequence: number, +): Bytes { + if (samples.length === 0) throw new Error('MP4 segment 不能没有采样') + const sampleTable = samples.flatMap(sample => [be32(sample.duration), be32(sample.data.length)]) + const mfhd = mp4Box('mfhd', concatBytes(be32(0), be32(sequence))) + const tfhd = mp4Box('tfhd', concatBytes(be32(0x020000), be32(1))) + const tfdt = mp4Box('tfdt', concatBytes(be32(0x01000000), be64(baseDecodeTick))) + const makeTrun = (offset: number) => mp4Box('trun', concatBytes( + be32(0x000301), be32(samples.length), be32(offset), concatBytes(...sampleTable), + )) + const makeMoof = (offset: number) => mp4Box('moof', concatBytes( + mfhd, mp4Box('traf', concatBytes(tfhd, tfdt, makeTrun(offset))), + )) + const mediaPayload = concatBytes(...samples.map(sample => sample.data)) + const dataOffset = makeMoof(0).length + 8 + return concatBytes(makeMoof(dataOffset), mp4Box('mdat', mediaPayload)) +} + +export function codecMime(codec: CencCodec): string { + if (codec === 'opus') return 'audio/webm;codecs="opus"' + if (codec === 'flac') return 'audio/mp4; codecs="fLaC"' + return 'audio/mp4; codecs="mp4a.40.2"' +} + +export function initSegment( + meta: CencMetadata, + sampleEntry?: Uint8Array, +): Bytes { + if (meta.codec === 'opus') { + const playableTicks = Math.max(0, meta.totalTicks - meta.preSkip) + return buildWebmInit(meta.channels, meta.preSkip, playableTicks / meta.timescale) + } + if (!sampleEntry) throw new Error('MP4 编码缺少 sample entry') + return buildMp4Init(sampleEntry, meta.timescale, meta.totalTicks) +} diff --git a/src/settings.ts b/src/settings.ts index bf3c5c4..05de70b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,9 +9,15 @@ export type ApiPreference = 'app' | 'redcandle' /** 下载格式 */ export type DownloadFormat = 'epub' | 'txt' +/** 书架里单击书本的去向 */ +export type BookshelfClickAction = 'read' | 'detail' + /** TXT 编码 */ export type DownloadCharset = 'utf-8' | 'gbk' +/** 听书读完一章之后做什么 */ +export type AudiobookChapterEnd = 'next' | 'stop' + export interface Settings { /* --- 常规 --- */ /** 解密网页端混淆字体 */ @@ -28,6 +34,11 @@ export interface Settings { customCssEnabled: boolean /** 自定义 CSS 内容。关闭开关时仍然保留,只是不应用 */ customCss: string + /** + * 书架里单击书本的默认去向。 + * 按住 Ctrl 单击取相反项;中键(或 Ctrl+中键)在新标签页打开对应目标。 + */ + bookshelfClickAction: BookshelfClickAction /* --- 搜索 --- */ /** 接管网页搜索界面 */ @@ -55,6 +66,12 @@ export interface Settings { /** EPUB:保留书籍自带的排版样式(css_map) */ downloadBookCss: boolean + /* --- 听书 --- */ + /** 读完一章之后:next=自动下一章,stop=停下 */ + audiobookChapterEnd: AudiobookChapterEnd + /** 高亮朗读中的段落,并跟随滚动 */ + audiobookFollow: boolean + /* --- 协议 --- */ apiPreference: ApiPreference /** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */ @@ -71,6 +88,8 @@ export const DEFAULT_SETTINGS: Settings = { readerFont: '', customCssEnabled: false, customCss: '', + // 原站书架点封面是继续阅读,保持一致 + bookshelfClickAction: 'read', enhanceSearch: true, // 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启 @@ -88,6 +107,9 @@ export const DEFAULT_SETTINGS: Settings = { downloadImages: true, downloadBookCss: true, + audiobookChapterEnd: 'next', + audiobookFollow: true, + apiPreference: 'app', deviceId: '', installId: '', @@ -116,6 +138,12 @@ function normalize(raw: unknown): Settings { if (s.downloadCharset !== 'utf-8' && s.downloadCharset !== 'gbk') { s.downloadCharset = DEFAULT_SETTINGS.downloadCharset } + if (s.bookshelfClickAction !== 'read' && s.bookshelfClickAction !== 'detail') { + s.bookshelfClickAction = DEFAULT_SETTINGS.bookshelfClickAction + } + if (s.audiobookChapterEnd !== 'next' && s.audiobookChapterEnd !== 'stop') { + s.audiobookChapterEnd = DEFAULT_SETTINGS.audiobookChapterEnd + } // 数字项来自输入框,可能是 NaN 或越界值 s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize) s.downloadInterval = clampInt(s.downloadInterval, 0, 10_000, DEFAULT_SETTINGS.downloadInterval) diff --git a/src/types.ts b/src/types.ts index b2c01d0..35b3934 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,3 +210,131 @@ export interface SearchLandingSection { words: SearchHotWord[] books: SearchSuggestBook[] } + +/* --------------------------------- 书评 --------------------------------- */ + +/** 评论作者。头衔(vip/作者等)接口下发的是内嵌 JSON 字符串,这里只留解析后的名字 */ +export interface CommentUser { + user_id: string + name: string + avatar: string + description: string + is_author: boolean + is_vip: boolean + /** 头衔名,如 vip、榜一大哥 */ + titles: string[] +} + +/** + * 正文里的富文本片段(书名号引用、话题等)。 + * + * s/e 是 UTF-16 码元下标(实测与 String.prototype.slice 一致, + * 不是 UTF-8 字节,也不是码点序号)。tp 目前只见过 4 = 搜索跳转。 + */ +export interface CommentTextExt { + start: number + end: number + text: string + type: number + /** dragon1967:// 开头的 APP 内部 schema,网页端点不了,仅保留原文 */ + uri?: string +} + +/** 评论与回复共有的部分 */ +export interface CommentBase { + id: string + text: string + /** 富文本片段,可能为空 */ + exts: CommentTextExt[] + /** 秒级时间戳 */ + create_time: number + user: CommentUser + digg_count: number + reply_count: number + /** 当前用户是否赞过。未登录(或读不到 sessionid)时恒为 false */ + user_digg: boolean + user_disagree: boolean +} + +/** 一楼书评 */ +export interface BookComment extends CommentBase { + /** 评分,1-10,缺失为 0 */ + score: number + /** 接口下发的展示文案,如「阅读19小时后点评」 */ + score_text: string + /** 作者置顶 */ + author_stick: boolean + /** 追评。同一用户在原评论之后补写的内容 */ + addition?: CommentBase + /** 接口下发的前几条回复,展开前先用它 */ + preview_reply_count: number +} + +/** 楼中楼回复。二级回复挂在 sub 里 */ +export interface CommentReply extends CommentBase { + /** 所属评论 id */ + to_comment_id: string + /** 二级回复时,父回复的 id */ + to_reply_id: string + /** 接口预览下发的子回复 */ + sub: CommentReply[] +} + +/** 书评区筛选标签,如「长评」「文笔很好」 */ +export interface CommentFilterTag { + tag_id: string + tag_name: string + count: number +} + +/** 评论排序。1=最热,3=最新(其余值接口报参数错误) */ +export type CommentSort = 1 | 3 + +export interface CommentListResult { + comments: BookComment[] + /** 服务端游标,原样回传即可翻页 */ + cursor: string + has_more: boolean + /** 该书评论总数(受 tag 过滤影响) */ + total: number + tags: CommentFilterTag[] + /** 点评人数展示文案,如「1.1万人点评」 */ + score_text: string + score_count: number + /** + * 当前登录用户自己写的那条书评 / 打分。 + * 服务端把它单独放在 extra.user_comment,不混在列表里; + * 没登录或还没点评过时为 null。打过分后即可用它回显星级。 + */ + user_comment: BookComment | null +} + +export interface ReplyListResult { + replies: CommentReply[] + cursor: string + has_more: boolean + total: number +} + +export interface CencAudioContext { + urls: string[] + vid: string + key: string // empty for a not encrypted audio + item_id: string +} + +export interface Tone { + id: number + name: string + icon: string // 空字符串是没有图标 + gender: number // 1=男声 2=女声 + description: string +} + +export interface ParagraphTimeTag { + is_title: boolean // idx=10000 就是标题 + startidx: number + endidx: number + startms: number + endms: number +} \ No newline at end of file diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts new file mode 100644 index 0000000..1d9e20c --- /dev/null +++ b/src/utils/cookie.ts @@ -0,0 +1,127 @@ +// Cookie 读取。 +// +// 网页站的 sessionid 是 HttpOnly,document.cookie 读不到,而书评接口挂在 +// reading.snssdk.com 根路径(网页站只把 /reading 反代到了 APP 网关), +// 拿不到同源那份 Cookie,只能自己把 sessionid 塞进请求头。 +// +// GM_cookie 能读 HttpOnly,但 Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, +// 需要用户手动放开: +// 设置 → 通用 → 配置模式 → 高级 +// 设置 → 高级 → 安全 → 允许脚本访问 Cookie → All +// 见 https://github.com/Tampermonkey/tampermonkey/issues/465#issuecomment-2479061865 +// +// 没有任何接口能告诉我们「是不是被这个开关挡住了」,所以只能反推: +// 用户明明是登录状态(checkLogin 通过),却读不到 sessionid —— 那就是被挡了。 + +/** 放开 Cookie 访问的操作路径,取不到 sessionid 时提示用户 */ +export const COOKIE_PERMISSION_HINT = + '读不到登录态(sessionid)。若确认已登录,请在 Tampermonkey 里放开 Cookie 访问:' + + '设置 → 通用 → 配置模式 改为「高级」,再到 设置 → 高级 → 安全 →' + + '「允许脚本访问 Cookie」改为 All。' + +/** GM_cookie.list 迟迟不回调时的等待上限 */ +const LIST_TIMEOUT = 5_000 + +export type CookieFailure = + /** 脚本管理器不支持,或没授权 GM_cookie */ + | 'no-api' + /** GM_cookie.list 报错或超时 */ + | 'api-error' + /** 接口正常但没有这条 Cookie(未登录,或被 HttpOnly 开关挡住) */ + | 'not-found' + +export interface CookieLookup { + value: string | null + reason?: CookieFailure + /** api-error 时的原始错误信息 */ + error?: string +} + +function hasGMCookie(): boolean { + // @grant GM_cookie 缺失时这个标识符压根不存在,typeof 才不会抛 + return typeof GM_cookie !== 'undefined' && typeof GM_cookie?.list === 'function' +} + +/** 把回调式的 GM_cookie.list 包成 Promise,并防它不回调 */ +function listCookies(details: Tampermonkey.ListCookiesDetails): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (fn: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + fn() + } + const timer = setTimeout( + () => finish(() => reject(new Error('GM_cookie.list 无响应'))), + LIST_TIMEOUT, + ) + try { + GM_cookie.list(details, (cookies, error) => { + finish(() => (error ? reject(new Error(error)) : resolve(cookies ?? []))) + }) + } catch (err) { + finish(() => reject(err instanceof Error ? err : new Error(String(err)))) + } + }) +} + +/** document.cookie 兜底,只能读到非 HttpOnly 的 */ +function readFromDocument(name: string): string | null { + for (const part of document.cookie.split(';')) { + const raw = part.trim() + if (raw.startsWith(name + '=')) return raw.slice(name.length + 1) + } + return null +} + +/** + * 读一条 Cookie。先试 GM_cookie(能读 HttpOnly),再退到 document.cookie。 + * + * @param name Cookie 名 + * @param url 归属页面,默认当前文档 + */ +export async function readCookie(name: string, url?: string): Promise { + if (hasGMCookie()) { + try { + const cookies = await listCookies(url ? { url, name } : { name }) + // 指定了 name 仍可能匹配到多条(不同 domain/path),取第一条有值的 + const hit = cookies.find(c => c.name === name && c.value) + if (hit) return { value: hit.value } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn('[fqa:cookie] GM_cookie.list 失败,退到 document.cookie:', message) + const fallback = readFromDocument(name) + if (fallback) return { value: fallback } + return { value: null, reason: 'api-error', error: message } + } + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'not-found' } + } + + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'no-api' } +} + +const SESSION_COOKIE = 'sessionid' + +/** 成功读到的 sessionid。同一页面内复用,避免每次点赞都问一遍脚本管理器 */ +let cached: string | null = null + +/** + * 读网页站的 sessionid。 + * + * 只在需要写操作(点赞等)或需要「我是否赞过」状态时调用; + * 拿到的值仅随请求发往字节官方的 reading.snssdk.com,不经任何第三方。 + */ +export async function getSessionId(): Promise { + if (cached) return { value: cached } + const found = await readCookie(SESSION_COOKIE) + if (found.value) cached = found.value + return found +} + +/** 登录状态变化后调用,丢掉旧的 sessionid */ +export function clearSessionIdCache(): void { + cached = null +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 67ffdeb..4055234 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -78,4 +78,9 @@ export function concatArrayBuffers(...buffers: ArrayBuffer[]): ArrayBuffer { offset += buf.byteLength } return result.buffer +} + +export function clearNodeEventListeners(element: T): void { + element.replaceWith(cloneElement(element)) + return } \ No newline at end of file diff --git a/src/utils/navigate.ts b/src/utils/navigate.ts new file mode 100644 index 0000000..c098738 --- /dev/null +++ b/src/utils/navigate.ts @@ -0,0 +1,58 @@ +// 书籍卡片的跳转辅助。 +// +// 交互约定: +// 单击 -> 设置里选的默认去向(继续阅读 / 查看详情) +// Ctrl+单击 -> 另一个去向 +// 中键 -> 默认去向,新标签页 +// Ctrl+中键 -> 另一个去向,新标签页 +// +// macOS 上 Ctrl+单击被系统当成右键(会触发 contextmenu),所以 Cmd 也算「反向」, +// 否则 Mac 用户没有可用的反向键。 + +/** 鼠标中键 */ +const MIDDLE_BUTTON = 1 + +export interface OpenModifiers { + /** 取与默认相反的去向 */ + flip: boolean + /** 在新标签页打开 */ + newTab: boolean +} + +/** 从鼠标/键盘事件里读出修饰意图。键盘事件没有 button,只可能 flip */ +export function modifiersOf(event: MouseEvent | KeyboardEvent): OpenModifiers { + return { + flip: event.ctrlKey || event.metaKey, + newTab: 'button' in event && event.button === MIDDLE_BUTTON, + } +} + +/** 中键按下时浏览器会进入自动滚动模式,光标变成罗盘,需要拦掉 */ +export function isMiddleButton(event: MouseEvent): boolean { + return event.button === MIDDLE_BUTTON +} + +export function readerUrl(chapterId: string): string { + return `https://fanqienovel.com/reader/${chapterId}` +} + +export function bookPageUrl(bookId: string): string { + return `https://fanqienovel.com/page/${bookId}` +} + +/** + * 跳转。newTab 为真时开新标签页。 + * + * 不传 'noopener':按规范带上它 window.open 一律返回 null,就没法判断是否被拦了。 + * 目标是同源页面,opener 可访问本身无害。 + */ +export function openUrl(url: string, newTab = false): void { + if (!newTab) { + unsafeWindow.location.href = url + return + } + // 中键点击是用户手势,正常不会被拦;真被拦了也不要偷偷改成当前页跳转 + if (!unsafeWindow.open(url, '_blank')) { + console.warn('[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:', url) + } +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 46288ea..3e3799a 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -6,6 +6,30 @@ export type ApiResponse = Tampermonkey.Response & { json(): T; }; +/** + * 响应没有任何正文。 + * + * 番茄网关对「不认识的设备」就是这个回应:HTTP 200、`content-length: 0`、 + * 一个字节都没有。裸 JSON.parse 会抛 `SyntaxError: "undefined" is not valid JSON` + * (Tampermonkey 在无正文时不给 responseText),完全看不出真正的原因, + * 所以这里换成一个能认出来的错误类型。 + */ +export class EmptyResponseError extends Error { + readonly status: number; + + constructor(status: number) { + super(`服务端返回了空响应体(HTTP ${status})`); + this.name = "EmptyResponseError"; + this.status = status; + } +} + +/** 响应体是否为空。Tampermonkey 可能给 undefined,也可能给空串 */ +export function isEmptyResponse(res: Pick): boolean { + const text = res.responseText; + return typeof text !== "string" || text === ""; +} + const supportedMethods = new Set([ "GET", "HEAD", @@ -59,6 +83,9 @@ export default function apiFetch( cleanup(); resolve(Object.assign(response, { json(this: Tampermonkey.Response): T { + if (isEmptyResponse(this)) { + throw new EmptyResponseError(this.status); + } return JSON.parse(this.responseText) as T; }, })); diff --git a/src/views/AudioBar.vue b/src/views/AudioBar.vue new file mode 100644 index 0000000..788010a --- /dev/null +++ b/src/views/AudioBar.vue @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + 听 + + + + + + + {{ state.title }} + + {{ state.error || (state.loading ? '缓冲中…' : state.toneName) }} + + + + + + + + + + + + + × + + + + + + diff --git a/src/views/BookCard.vue b/src/views/BookCard.vue index 6a47823..2b57f59 100644 --- a/src/views/BookCard.vue +++ b/src/views/BookCard.vue @@ -1,13 +1,14 @@ + + + + + {{ props.current === null ? '选择音色' : '切换音色' }} + 切换后会从当前段落开头继续 + + + + + {{ tone.name.slice(0, 1) }} + + + {{ tone.name }} + + {{ genderLabel(tone.gender) }} + + + {{ tone.description }} + + + + + + 取消 + + + + diff --git a/vite.config.ts b/vite.config.ts index a3eced1..a6beb61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,7 +30,10 @@ export default defineConfig({ author: 'naiyQAQ', 'run-at': 'document-start', match: ['*://*.fanqienovel.com/*'], - grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'unsafeWindow'], + // GM_cookie 用于读 HttpOnly 的 sessionid(书评点赞需要登录态)。 + // 注意:Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, + // 用户需在 设置→通用→配置模式→高级,再 设置→高级→安全→允许脚本访问 Cookie→All + grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'GM_cookie', 'unsafeWindow'], connect: [ 'fanqienovel.com', // 主站 'jxbhmy.com', // 红烛小说 API @@ -38,6 +41,11 @@ export default defineConfig({ 'byteimg.com', // 图床 'fqnovelpic.com', // 图床 'bytecdn.cn', // 图床 + // 听书音频 CDN。页面 CSP 的 connect-src 不含这些域名, + // 走页面 fetch 会被 report-only 策略上报,所以改用 GM_xmlhttpRequest + 'fqnovelvod.com', // 番茄小说 TTS + 'novelfmvod.com', // 番茄畅听 + 'volcautovod.com', // 火山引擎回源 ], }, build: {
(点到 blk 或行内标签上也能命中), + * 再拿 idx 去时间点表里查覆盖它的那一段。 + */ + tagIndexOfNode(node) { + var _a; + const paragraph = nearestParagraph(node); + if (paragraph) { + const idx = Number(paragraph.getAttribute("idx")); + if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx); + } + if (node instanceof Node && ((_a = titleTarget(this.tags)) == null ? void 0 : _a.contains(node))) { + return this.tags.findIndex((t) => t.is_title); + } + return -1; + } + /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ + tagIndexOfIdx(idx) { + return this.tags.findIndex((t) => !t.is_title && idx >= t.startidx && idx <= t.endidx); + } + /** 第 index 个时间点的起始秒数,供 seek 用 */ + startSecondsOf(index) { + const tag = this.tags[index]; + return tag ? tag.startms / 1e3 : null; + } + } + function nearestParagraph(node) { + let current = node; + while (current) { + if (current instanceof HTMLElement && current.matches("p[idx]")) return current; + if (current instanceof HTMLElement && current.id === "fqa-reader-content") return null; + current = current.parentNode; + } + return null; + } + const TONE_STORE_KEY = "audiobook_tone"; + const state = vue.reactive({ + open: false, + collapsed: false, + tonePickerOpen: false, + loading: false, + playing: false, + spinning: false, + tones: [], + toneId: null, + toneName: "", + cover: "", + title: "", + error: "" + }); + const highlighter = new ParagraphHighlighter(); + let audio = null; + let player = null; + let itemId = ""; + let bookId = ""; + let trackTimer = null; + let pendingTagIndex = -1; + let playToken = 0; + let paragraphClickBound = false; + const onParagraphClick = (event) => { + if (!state.open || state.toneId === null) return; + const target = event.target; + if (target instanceof HTMLElement && target.closest("a, button, sup")) return; + const index = highlighter.tagIndexOfNode(target); + if (index < 0) return; + const seconds = highlighter.startSecondsOf(index); + if (seconds === null || !audio) return; + audio.currentTime = seconds; + highlighter.applyTag(index, false); + if (!state.playing) void togglePlay(); + }; + function bindParagraphClick() { + if (paragraphClickBound) return; + document.addEventListener("click", onParagraphClick); + paragraphClickBound = true; + } + function unbindParagraphClick() { + if (!paragraphClickBound) return; + document.removeEventListener("click", onParagraphClick); + paragraphClickBound = false; + } + function ensurePlayer() { + if (player) return player; + audio = document.createElement("audio"); + audio.preload = "none"; + audio.style.display = "none"; + document.body.appendChild(audio); + player = new CencAudioPlayer(audio, { + // 音频 CDN 不在页面 CSP 的 connect-src 里,页面 fetch 会被 report-only + // 策略上报到 mon.zijieapi.com。走 GM 通道绕开上报,详见 gmFetch + fetch: gmAudioFetch, + onStateChange: onPlayerState, + onMessage: (message, level) => { + if (level === "error") { + console.error("[fqa:audio]", message); + state.error = message; + } else { + console.log("[fqa:audio]", message); + } + } + }); + return player; + } + function onPlayerState(playerState) { + state.playing = playerState === "playing"; + state.spinning = playerState === "playing" || playerState === "loading"; + if (playerState === "playing") state.error = ""; + if (playerState === "playing") startTracking(); + else stopTracking(); + if (playerState === "ended") { + highlighter.clear(); + onChapterEnd(); + } + } + const NEXT_CHAPTER_SELECTOR = "div.chapter-btn.next"; + function onChapterEnd() { + var _a; + if (settings$1.audiobookChapterEnd !== "next") { + state.spinning = false; + return; + } + const next = (_a = document.querySelector(NEXT_CHAPTER_SELECTOR)) == null ? void 0 : _a.firstChild; + if (typeof (next == null ? void 0 : next.click) !== "function") { + state.spinning = false; + state.error = "已经是最后一章"; + return; + } + state.spinning = true; + const before = itemId; + next.click(); + unsafeWindow.setTimeout(() => { + var _a2; + if (!state.open || itemId !== before) return; + const current = ((_a2 = window.location.pathname.split("/").pop()) == null ? void 0 : _a2.substring(0, 19)) || ""; + if (current && current !== before) { + void switchChapter(current, { cover: state.cover, title: state.title }); + } else { + state.spinning = false; + state.error = "自动切章失败"; + } + }, 3e3); + } + function startTracking() { + if (trackTimer !== null) return; + trackTimer = unsafeWindow.setInterval(() => { + if (!audio) return; + highlighter.update(audio.currentTime * 1e3, settings$1.audiobookFollow); + }, 120); + } + function stopTracking() { + if (trackTimer === null) return; + unsafeWindow.clearInterval(trackTimer); + trackTimer = null; + } + function storedToneId() { + const raw = read(TONE_STORE_KEY); + return typeof raw === "number" ? raw : null; + } + async function openAudiobook(chapter, book, meta) { + itemId = chapter; + bookId = book; + state.cover = meta.cover; + state.title = meta.title; + state.error = ""; + state.open = true; + state.collapsed = false; + if (state.tones.length === 0) { + try { + state.tones = await getBookAvailableTones(bookId); + } catch (e) { + state.error = "取音色列表失败"; + console.error("[fqa:audio] 取音色列表失败:", e); + return; + } + } + if (state.tones.length === 0) { + state.error = "这本书没有可用音色"; + return; + } + const remembered = storedToneId(); + if (remembered !== null && state.tones.some((t) => t.id === remembered)) { + await selectTone(remembered); + return; + } + state.tonePickerOpen = true; + } + async function selectTone(toneId) { + state.tonePickerOpen = false; + const tone = state.tones.find((t) => t.id === toneId); + state.toneId = toneId; + state.toneName = (tone == null ? void 0 : tone.name) ?? ""; + write(TONE_STORE_KEY, toneId); + await playCurrent(); + } + function openTonePicker() { + state.tonePickerOpen = true; + } + function closeTonePicker() { + state.tonePickerOpen = false; + if (state.toneId === null) closeAudiobook(); + } + async function changeTone(toneId) { + if (toneId === state.toneId) { + state.tonePickerOpen = false; + return; + } + pendingTagIndex = highlighter.currentTagIndex; + await selectTone(toneId); + } + async function playCurrent() { + const toneId = state.toneId; + if (toneId === null || !itemId) return; + const token = ++playToken; + const chapter = itemId; + state.loading = true; + state.spinning = true; + state.error = ""; + try { + const [contexts, tags] = await Promise.all([ + getPlayInfo(chapter, toneId), + getChapterParagraphTimeTag(chapter, toneId) + ]); + if (token !== playToken) return; + const context = contexts.find((c) => c.item_id === chapter) ?? contexts[0]; + const url = context == null ? void 0 : context.urls[0]; + if (!context || !url) { + state.error = "这一章没有音频"; + return; + } + highlighter.reset(tags); + bindParagraphClick(); + const instance = ensurePlayer(); + await instance.start({ url, key: context.key }); + if (token !== playToken) return; + if (pendingTagIndex >= 0) { + const tag = tags[pendingTagIndex]; + if (tag && audio) { + audio.currentTime = tag.startms / 1e3; + highlighter.applyTag(pendingTagIndex, true); + } + pendingTagIndex = -1; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + state.error = message; + console.error("[fqa:audio] 播放失败:", e); + } finally { + state.loading = false; + if (!state.playing) state.spinning = false; + } + } + async function togglePlay() { + if (!player) return; + if (state.playing) { + player.pause(); + return; + } + if (player.currentState === "ended" && audio) audio.currentTime = 0; + try { + await player.play(); + } catch (e) { + console.warn("[fqa:audio] play() 被拒:", e); + } + } + function toggleCollapsed() { + state.collapsed = !state.collapsed; + } + async function switchChapter(chapter, meta) { + if (!state.open || state.toneId === null) return; + itemId = chapter; + state.cover = meta.cover; + state.title = meta.title; + pendingTagIndex = -1; + await playCurrent(); + } + function refreshParagraphs() { + if (state.open) highlighter.refresh(); + } + function closeAudiobook() { + stopTracking(); + unbindParagraphClick(); + playToken++; + highlighter.clear(); + player == null ? void 0 : player.stop(); + state.open = false; + state.collapsed = false; + state.tonePickerOpen = false; + state.playing = false; + state.spinning = false; + state.loading = false; + state.error = ""; + } + const _hoisted_1$b = { + class: "fqa-tone-box", + role: "dialog", + "aria-modal": "true", + "aria-label": "选择音色" + }; + const _hoisted_2$b = { class: "fqa-tone-title" }; + const _hoisted_3$b = { class: "fqa-tone-list" }; + const _hoisted_4$a = ["onClick"]; + const _hoisted_5$a = ["src", "alt"]; + const _hoisted_6$a = { + key: 1, + class: "fqa-tone-icon fqa-tone-icon-empty" + }; + const _hoisted_7$8 = { class: "fqa-tone-text" }; + const _hoisted_8$8 = { class: "fqa-tone-name" }; + const _hoisted_9$8 = { + key: 0, + class: "fqa-tone-gender" + }; + const _hoisted_10$8 = { + key: 0, + class: "fqa-tone-desc" + }; + const _hoisted_11$6 = { class: "fqa-tone-actions" }; + const _sfc_main$b = /* @__PURE__ */ vue.defineComponent({ + __name: "TonePicker", + props: { + tones: {}, + current: {} + }, + emits: ["select", "close"], + setup(__props, { emit: __emit }) { + const props = __props; + const emit = __emit; + function genderLabel(gender) { + if (gender === 1) return "男声"; + if (gender === 2) return "女声"; + return ""; + } + return (_ctx, _cache) => { + return vue.openBlock(), vue.createElementBlock("div", { + class: "fqa-tone-mask", + onClick: _cache[1] || (_cache[1] = vue.withModifiers(($event) => emit("close"), ["self"])) + }, [ + vue.createElementVNode("div", _hoisted_1$b, [ + vue.createElementVNode("h3", _hoisted_2$b, vue.toDisplayString(props.current === null ? "选择音色" : "切换音色"), 1), + _cache[2] || (_cache[2] = vue.createElementVNode("p", { class: "fqa-tone-sub" }, "切换后会从当前段落开头继续", -1)), + vue.createElementVNode("div", _hoisted_3$b, [ + (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(props.tones, (tone) => { + return vue.openBlock(), vue.createElementBlock("button", { + key: tone.id, + type: "button", + class: vue.normalizeClass(["fqa-tone-item", { "fqa-tone-item-active": tone.id === props.current }]), + onClick: ($event) => emit("select", tone.id) + }, [ + tone.icon ? (vue.openBlock(), vue.createElementBlock("img", { + key: 0, + class: "fqa-tone-icon", + src: tone.icon, + alt: tone.name + }, null, 8, _hoisted_5$a)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_6$a, vue.toDisplayString(tone.name.slice(0, 1)), 1)), + vue.createElementVNode("span", _hoisted_7$8, [ + vue.createElementVNode("span", _hoisted_8$8, [ + vue.createTextVNode(vue.toDisplayString(tone.name) + " ", 1), + genderLabel(tone.gender) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_9$8, vue.toDisplayString(genderLabel(tone.gender)), 1)) : vue.createCommentVNode("", true) + ]), + tone.description ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_10$8, vue.toDisplayString(tone.description), 1)) : vue.createCommentVNode("", true) + ]) + ], 10, _hoisted_4$a); + }), 128)) + ]), + vue.createElementVNode("div", _hoisted_11$6, [ + vue.createElementVNode("button", { + type: "button", + class: "fqa-tone-btn", + onClick: _cache[0] || (_cache[0] = ($event) => emit("close")) + }, "取消") + ]) + ]) + ]); + }; + } + }); + const _hoisted_1$a = ["innerHTML"]; + const _hoisted_2$a = { + key: 1, + class: "fqa-audio-bar" + }; + const _hoisted_3$a = ["title", "aria-label"]; + const _hoisted_4$9 = ["src"]; + const _hoisted_5$9 = { + key: 1, + class: "fqa-audio-cover-empty" + }; + const _hoisted_6$9 = ["innerHTML"]; + const _hoisted_7$7 = { class: "fqa-audio-meta" }; + const _hoisted_8$7 = { class: "fqa-audio-title" }; + const _hoisted_9$7 = { class: "fqa-audio-sub" }; + const _hoisted_10$7 = ["innerHTML"]; + const _sfc_main$a = /* @__PURE__ */ vue.defineComponent({ + __name: "AudioBar", + setup(__props) { + function onSelect(id) { + if (state.toneId === null) void selectTone(id); + else void changeTone(id); + } + return (_ctx, _cache) => { + return vue.unref(state).open ? (vue.openBlock(), vue.createElementBlock("div", { + key: 0, + class: vue.normalizeClass(["fqa-audio-root", { "fqa-audio-collapsed": vue.unref(state).collapsed }]) + }, [ + vue.unref(state).collapsed ? (vue.openBlock(), vue.createElementBlock("button", { + key: 0, + type: "button", + class: "fqa-audio-expand", + title: "展开听书栏", + "aria-label": "展开听书栏", + onClick: _cache[0] || (_cache[0] = //@ts-ignore + (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) + }, [ + vue.createElementVNode("span", { + class: "fqa-audio-icon fqa-audio-icon-flip", + innerHTML: vue.unref(leftIcon) + }, null, 8, _hoisted_1$a) + ])) : (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$a, [ + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-cover", + title: vue.unref(state).playing ? "暂停" : "播放", + "aria-label": vue.unref(state).playing ? "暂停" : "播放", + onClick: _cache[1] || (_cache[1] = //@ts-ignore + (...args) => vue.unref(togglePlay) && vue.unref(togglePlay)(...args)) + }, [ + vue.createElementVNode("span", { + class: vue.normalizeClass(["fqa-audio-disc", { "fqa-audio-spin": vue.unref(state).spinning }]) + }, [ + vue.unref(state).cover ? (vue.openBlock(), vue.createElementBlock("img", { + key: 0, + src: vue.unref(state).cover, + alt: "" + }, null, 8, _hoisted_4$9)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_5$9, "听")) + ], 2), + vue.createElementVNode("span", { + class: "fqa-audio-state", + innerHTML: vue.unref(state).playing ? vue.unref(playingIcon) : vue.unref(pausedIcon) + }, null, 8, _hoisted_6$9) + ], 8, _hoisted_3$a), + vue.createElementVNode("div", _hoisted_7$7, [ + vue.createElementVNode("span", _hoisted_8$7, vue.toDisplayString(vue.unref(state).title), 1), + vue.createElementVNode("span", _hoisted_9$7, vue.toDisplayString(vue.unref(state).error || (vue.unref(state).loading ? "缓冲中…" : vue.unref(state).toneName)), 1) + ]), + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-btn", + title: "切换音色", + "aria-label": "切换音色", + onClick: _cache[2] || (_cache[2] = //@ts-ignore + (...args) => vue.unref(openTonePicker) && vue.unref(openTonePicker)(...args)) + }, [..._cache[5] || (_cache[5] = [ + vue.createElementVNode("span", { class: "fqa-audio-dots" }, null, -1) + ])]), + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-btn", + title: "收起", + "aria-label": "收起听书栏", + onClick: _cache[3] || (_cache[3] = //@ts-ignore + (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) + }, [ + vue.createElementVNode("span", { + class: "fqa-audio-icon", + innerHTML: vue.unref(leftIcon) + }, null, 8, _hoisted_10$7) + ]), + vue.createElementVNode("button", { + type: "button", + class: "fqa-audio-btn fqa-audio-close", + title: "关闭听书", + "aria-label": "关闭听书", + onClick: _cache[4] || (_cache[4] = //@ts-ignore + (...args) => vue.unref(closeAudiobook) && vue.unref(closeAudiobook)(...args)) + }, " × ") + ])), + vue.unref(state).tonePickerOpen ? (vue.openBlock(), vue.createBlock(_sfc_main$b, { + key: 2, + tones: vue.unref(state).tones, + current: vue.unref(state).toneId, + onSelect, + onClose: vue.unref(closeTonePicker) + }, null, 8, ["tones", "current", "onClose"])) : vue.createCommentVNode("", true) + ], 2)) : vue.createCommentVNode("", true); + }; + } + }); + const audiobookcss = `/* 听书悬浮栏与音色弹窗。 + * 暗色跟随页面的 div.muye-reader-dark(与 userHook 的判定一致)。 */ + +.fqa-audio-root { + --fqa-audio-bg: #fff; + --fqa-audio-text: #1f2329; + --fqa-audio-sub: #8f959e; + --fqa-audio-hover: rgba(31, 35, 41, 0.06); + --fqa-audio-border: rgba(31, 35, 41, 0.1); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); + + position: fixed; + left: 20px; + bottom: 20px; + z-index: 2147483000; + font-size: 14px; + color: var(--fqa-audio-text); +} + +/* 页面切到夜间模式时整体反色 */ +.muye-reader-dark .fqa-audio-root, +.fqa-audio-root.fqa-audio-dark { + --fqa-audio-bg: #2b2b2b; + --fqa-audio-text: #b3b3b3; + --fqa-audio-sub: #7a7a7a; + --fqa-audio-hover: rgba(255, 255, 255, 0.08); + --fqa-audio-border: rgba(255, 255, 255, 0.12); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); +} + +.fqa-audio-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 8px; + border-radius: 999px; + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); +} + +/* ------------------------------- 旋转封面 ------------------------------- */ + +.fqa-audio-cover { + position: relative; + flex: 0 0 auto; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--fqa-audio-hover); + cursor: pointer; +} + +/* + * 只有这一层转。状态图标是 .fqa-audio-state,放在旋转层外面, + * 否则会跟着封面一起转,反而更看不清。 + * 播放/缓冲时转,暂停时停在当前角度(animation-play-state 比移除动画更平滑) + */ +.fqa-audio-disc { + position: absolute; + inset: 0; + border-radius: 50%; + overflow: hidden; + animation: fqa-audio-rotate 8s linear infinite; + animation-play-state: paused; +} + +.fqa-audio-disc.fqa-audio-spin { + animation-play-state: running; +} + +.fqa-audio-disc img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.fqa-audio-cover-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--fqa-audio-sub); + font-size: 18px; +} + +/* 播放/暂停状态。压一层遮罩,浅色封面上也看得清 */ +.fqa-audio-state { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.38); + color: #fff; + transition: background 0.2s ease; +} + +.fqa-audio-cover:hover .fqa-audio-state { + background: rgba(0, 0, 0, 0.55); +} + +/* + * 只给尺寸,不写 fill:paused.svg 里那块透明点击区是 fill="none", + * CSS 的 fill 优先级高于表现属性,会把它填成一个白方块盖住图标。 + * 两个 svg 自身已经用 currentColor,跟着上面的 color 走。 + */ +.fqa-audio-state svg { + width: 18px; + height: 18px; +} + +@keyframes fqa-audio-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .fqa-audio-disc { animation: none; } +} + +/* -------------------------------- 文字区 -------------------------------- */ + +.fqa-audio-meta { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + max-width: 180px; + line-height: 1.35; +} + +.fqa-audio-title, +.fqa-audio-sub { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-audio-sub { + color: var(--fqa-audio-sub); + font-size: 12px; +} + +/* -------------------------------- 按钮 -------------------------------- */ + +.fqa-audio-btn, +.fqa-audio-expand { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--fqa-audio-text); + cursor: pointer; +} + +.fqa-audio-btn:hover, +.fqa-audio-expand:hover { + background: var(--fqa-audio-hover); +} + +.fqa-audio-close { + font-size: 20px; + line-height: 1; +} + +/* svg 用 currentColor,跟着按钮的 color 走 */ +.fqa-audio-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.fqa-audio-icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +/* 收起后箭头水平翻转朝右 */ +.fqa-audio-icon-flip { + transform: scaleX(-1); +} + +.fqa-audio-expand { + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); + width: 36px; + height: 36px; +} + +/* 三个点 */ +.fqa-audio-dots, +.fqa-audio-dots::before, +.fqa-audio-dots::after { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; +} + +.fqa-audio-dots { + position: relative; +} + +.fqa-audio-dots::before, +.fqa-audio-dots::after { + content: ''; + position: absolute; + top: 0; +} + +.fqa-audio-dots::before { left: -7px; } +.fqa-audio-dots::after { left: 7px; } + +/* ----------------------------- 段落高亮 ----------------------------- */ + +.fqa-audio-active { + background: var(--web-brand_light, rgba(241, 70, 70, 0.12)); + border-radius: 4px; + transition: background 0.2s ease; +} + +/* ----------------------------- 音色弹窗 ----------------------------- */ + +.fqa-tone-mask { + position: fixed; + inset: 0; + z-index: 2147483002; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} + +.fqa-tone-box { + width: min(420px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 64px)); + display: flex; + flex-direction: column; + padding: 20px; + border-radius: 12px; + background: var(--fqa-audio-bg, #fff); + color: var(--fqa-audio-text, #1f2329); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.24); +} + +.fqa-tone-title { + margin: 0 0 4px; + font-size: 17px; + font-weight: 600; +} + +.fqa-tone-sub { + margin: 0 0 14px; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; +} + +.fqa-tone-list { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.fqa-tone-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.fqa-tone-item:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} + +.fqa-tone-item-active { + border-color: var(--web-brand, #f14646); +} + +.fqa-tone-icon { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; +} + +.fqa-tone-icon-empty { + display: flex; + align-items: center; + justify-content: center; + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); + color: var(--fqa-audio-sub, #8f959e); +} + +.fqa-tone-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.fqa-tone-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; +} + +.fqa-tone-gender { + color: var(--fqa-audio-sub, #8f959e); + font-size: 11px; +} + +.fqa-tone-desc { + overflow: hidden; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-tone-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +.fqa-tone-btn { + padding: 6px 16px; + border: 1px solid var(--fqa-audio-border, rgba(31, 35, 41, 0.1)); + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.fqa-tone-btn:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} +`; + const CONTAINER_ID$4 = "fqa-audio-root"; + const STYLE_ID$5 = "fqa-audio-style"; + let app$4 = null; + let container$4 = null; + let themeObserver = null; + function injectStyle$5() { + if (document.getElementById(STYLE_ID$5)) return; + const style = document.createElement("style"); + style.id = STYLE_ID$5; + style.textContent = audiobookcss; + document.head.appendChild(style); + } + function syncTheme() { + if (!container$4) return; + const dark = document.querySelector("div.muye-reader-dark") !== null; + container$4.classList.toggle("fqa-audio-dark", dark); + } + function watchTheme() { + if (themeObserver) return; + themeObserver = new MutationObserver(syncTheme); + themeObserver.observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ["class"] + }); + } + function initAudioPanel() { + if (app$4) return; + injectStyle$5(); + container$4 = document.createElement("div"); + container$4.id = CONTAINER_ID$4; + document.body.appendChild(container$4); + syncTheme(); + watchTheme(); + app$4 = vue.createApp({ render: () => vue.h(_sfc_main$a) }); + app$4.config.errorHandler = (err, _instance, info) => { + console.error(`[fqa:audio] Vue error (${info}):`, err); + }; + app$4.mount(container$4); + } + let currentBook = null; + let latestItemId = null; + const SCRIPT_CONTAINER_ID = "fqa-reader-content"; + let comicObserver = null; + function ensureScriptContainer(readerContainer, comic) { + let scriptContainer = document.getElementById(SCRIPT_CONTAINER_ID); + if (!scriptContainer) { + scriptContainer = cloneElement(readerContainer); + scriptContainer.id = SCRIPT_CONTAINER_ID; + scriptContainer.classList.add("fqa"); + readerContainer.insertAdjacentElement("beforebegin", scriptContainer); + } + scriptContainer.classList.toggle("fqa-comic-reader", comic); + if (settings$1.allowCopy) scriptContainer.classList.remove("noselect"); + comicObserver == null ? void 0 : comicObserver.disconnect(); + comicObserver = null; + scriptContainer.innerHTML = ""; + readerContainer.classList.add("fqa-hide"); + return scriptContainer; + } + async function insertContent() { + var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; + const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; + if (!itemId2) { + console.warn("No item_id found in URL"); + return; + } + latestItemId = itemId2; + const chapter = await getChapter(itemId2); + if (!chapter) { + console.warn("No chapter found for item_id:", itemId2); + return; + } + if (latestItemId !== itemId2) { + console.debug("Stale chapter response discarded:", itemId2); + return; + } + console.log("Chapter:", chapter); + const pageState = unsafeWindow.__INITIAL_STATE__; + const chapterTitle = ((_b = chapter.novel_data) == null ? void 0 : _b.title) || ((_d = (_c = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _c.chapterData) == null ? void 0 : _d.title); + if (typeof chapter.content === "string") { + void applyBookCss((_e = chapter.novel_data) == null ? void 0 : _e.css_map, "#fqa-reader-content"); + const dp = new DOMParser(); + const doc = dp.parseFromString(chapter.content, "text/html"); + const body = doc.body; + body.querySelectorAll('link[rel="stylesheet"]').forEach((el) => el.remove()); + let article = body.querySelector("article"); + let toProcess = article || body; + processFootnotes(toProcess); + for (let i2 = 0; i2 < toProcess.childNodes.length; i2++) { + if (i2 < 2 && ((_g = (_f = toProcess.childNodes[i2]) == null ? void 0 : _f.innerHTML) == null ? void 0 : _g.includes(chapterTitle))) { + toProcess.removeChild(toProcess.childNodes[i2]); + break; + } + } + if (!article) { + article = document.createElement("article"); + article.innerHTML = toProcess.innerHTML; + toProcess = article; + } + const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); + if (readerContainer) { + const scriptContainer = ensureScriptContainer(readerContainer, false); + scriptContainer.appendChild(toProcess); + bindFootnoteInteraction(scriptContainer); + } + } else if (chapter.content.picInfos) { + if (chapter.content.encrypt) { + const imgs = []; + for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { + const picInfo = chapter.content.picInfos[i2]; + const img = document.createElement("img"); + img.className = "fqa-comic-img fqa-comic-encrypted"; + img.alt = `第${i2 + 1}页`; + img.dataset.encryptedUrl = picInfo.picUrl; + img.dataset.encryptKey = chapter.content.encrypt_key; + img.dataset.pageIndex = i2.toString(); + img.style.minHeight = "500px"; + img.style.backgroundColor = "#f0f0f0"; + imgs.push(img); + } + const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); + if (readerContainer) { + const scriptContainer = ensureScriptContainer(readerContainer, true); + imgs.forEach((img) => scriptContainer.appendChild(img)); + const observer2 = new IntersectionObserver( + async (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const img = entry.target; + if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) { + observer2.unobserve(img); + try { + const encryptedBuffer = await fetchArrayBuffer( + img.dataset.encryptedUrl + ); + const decryptedBuffer = await decryptComicImage( + encryptedBuffer, + img.dataset.encryptKey + ); + const blob = new Blob([decryptedBuffer], { type: "image/jpeg" }); + const blobUrl = URL.createObjectURL(blob); + img.src = blobUrl; + img.style.minHeight = ""; + img.style.backgroundColor = ""; + img.onload = () => { + URL.revokeObjectURL(blobUrl); + }; + } catch (error) { + console.error(`解密图片失败 (页 ${img.dataset.pageIndex}):`, error); + img.alt = `第${Number(img.dataset.pageIndex) + 1}页 - 解密失败`; + img.style.backgroundColor = "#ffebee"; + } + } + } + } + }, + { + rootMargin: "200px" + } + ); + comicObserver = observer2; + imgs.forEach((img) => observer2.observe(img)); + } + } else { + const imgs = []; + for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { + const picInfo = chapter.content.picInfos[i2]; + const img = document.createElement("img"); + img.className = "fqa-comic-img"; + img.alt = `第${i2 + 1}页`; + img.src = picInfo.picUrl; + imgs.push(img); + } + const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); + if (readerContainer) { + const scriptContainer = ensureScriptContainer(readerContainer, true); + imgs.forEach((img) => scriptContainer.appendChild(img)); + } + } + } + const muyeReaderTitle = document.querySelector("h1.muye-reader-title"); + let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle"); + (_h = document.querySelector("#fqa-subtitle")) == null ? void 0 : _h.remove(); + if (muyeReaderSubtitle) { + let _cloned = cloneElement(muyeReaderSubtitle); + muyeReaderSubtitle.classList.add("fqa-hide"); + _cloned.id = "fqa-subtitle"; + muyeReaderSubtitle.insertAdjacentElement("afterend", _cloned); + muyeReaderSubtitle = _cloned; + _cloned.classList.remove("fqa-hide"); + console.log("clone subtitle: ", _cloned); + } + if (muyeReaderTitle) { + muyeReaderTitle.textContent = chapterTitle; + } + console.log("Current book:", currentBook); + if (!currentBook || currentBook == null || currentBook.book_id !== ((_i = chapter.novel_data) == null ? void 0 : _i.book_id)) { + currentBook = await getBookInfoAndCatalog((_j = chapter.novel_data) == null ? void 0 : _j.book_id); + console.log("Current book:", currentBook); + } + if (currentBook && currentBook.chapter_list) { + const currentChapterItem = currentBook.chapter_list.find((c) => c.item_id === itemId2); + if (currentChapterItem) { + console.log("Current chapter:", currentChapterItem); + document.title = currentChapterItem.title + " - " + currentBook.title + " - 番茄小说"; + if (document.getElementById("fqa-current-chapter-volume")) { + const c = document.getElementById("fqa-current-chapter-volume"); + if (c) { + c.textContent = currentChapterItem.volume_title; + } + } else { + const volSpan = document.createElement("span"); + volSpan.className = "desc-item"; + volSpan.id = "fqa-current-chapter-volume"; + volSpan.textContent = currentChapterItem.volume_title; + const c = muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.firstChild; + if (c) { + c.insertAdjacentElement("beforebegin", volSpan); + } + } + let updateTimeSpans = (muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.querySelectorAll("span.desc-item")) || []; + if (updateTimeSpans.length >= 2) { + let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1]; + let uttspan = updateTimeSpan.firstChild; + uttspan == null ? void 0 : uttspan.remove(); + updateTimeSpan.innerHTML = "更新时间:" + currentChapterItem.update_time; + } else { + let updateTimeSpan = document.createElement("span"); + updateTimeSpan.className = "desc-item"; + updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`; + } + } + } + if (state.open) { + await switchChapter(itemId2, { + cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", + title: (currentBook == null ? void 0 : currentBook.title) ?? document.title + }); + } else { + refreshParagraphs(); + } + } + async function startAudioPlay() { + var _a; + if (state.open) { + closeAudiobook(); + return; + } + const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; + if (!itemId2) return; + initAudioPanel(); + await openAudiobook(itemId2, (currentBook == null ? void 0 : currentBook.book_id) ?? "", { + cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", + title: (currentBook == null ? void 0 : currentBook.title) ?? document.title + }); + } + async function onUrlChange$1(_previous) { + await insertContent(); + } + async function onHashChange$1(_previous) { + } + async function onLoad$1() { + var _a; + const toolbar = document.querySelector("div.reader-toolbar > div"); + const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)"); + if (toolbarButton && toolbar) { + const c = cloneElement(toolbarButton); + c.id = "fqa-toggle-audiobook"; + const listenIcon = document.createElement("span"); + listenIcon.textContent = "听"; + listenIcon.style.width = "24px"; + listenIcon.style.height = "24px"; + listenIcon.style.fontSize = "24px"; + listenIcon.style.lineHeight = "24px"; + listenIcon.classList.add("muyeicon-icon"); + listenIcon.classList.add("reader-toolbar-item-icon"); + (_a = c.firstChild) == null ? void 0 : _a.replaceWith(listenIcon); + const l = c.lastChild; + if (l) { + l.textContent = "听书"; + } + c.addEventListener("click", () => void startAudioPlay()); + toolbar.appendChild(c); + } + document.querySelector("div.muye-reader-btns"); + await insertContent(); + } + function readerFilter(path, _query, _hash) { + return path.startsWith("/reader") || path.startsWith("reader"); + } + const _exports$5 = [ + { + id: "readerHook_load", + event: "load", + handler: onLoad$1, + filter: readerFilter + }, + { + id: "readerHook_urlChange", + event: "onUrlChange", + handler: onUrlChange$1, + filter: readerFilter + }, + { + id: "readerHook_hashChange", + event: "onHashChange", + handler: onHashChange$1, + filter: readerFilter + } + ]; + const blackList = [ + "mcs.zijieapi.com", + "vcs.zijieapi.com/vc/setting", + "mon.zijieapi.com", + "mssdk.bytedance.com/web/common", + "hm.baidu.com" + ]; + const BLOCKED_BODY = JSON.stringify({ + e: 0, + sc: 10, + tc: 10 + }); + function checkBlack(url) { + if (!settings$1.blockReport) return false; + return blackList.some((black) => url.includes(black)); + } + const originalFetch = unsafeWindow.fetch.bind(unsafeWindow); + unsafeWindow.fetch = function fetch2(input, init2) { + let url; + if (input instanceof Request) { + url = input.url; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input; + } + if (checkBlack(url)) { + console.log("blocked request: " + url); + return Promise.resolve(new Response(BLOCKED_BODY, { + status: 200, + headers: { "Content-Type": "application/json" } + })); + } + return originalFetch(input, init2); + }; + const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest; + unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { + constructor() { + super(...arguments); + __publicField(this, "_blockedUrl"); + } + open(method, url, async = true, user, password) { + if (checkBlack(url)) { + console.log("blocked request: " + url); + this._blockedUrl = url; + return; + } + this._blockedUrl = void 0; + super.open(method, url, async, user, password); + } + setRequestHeader(name2, value) { + if (this._blockedUrl !== void 0) return; + super.setRequestHeader(name2, value); + } + send(body) { + if (this._blockedUrl === void 0) { + super.send(body); + return; + } + const url = this._blockedUrl; + const shadow = (prop, value) => Object.defineProperty(this, prop, { configurable: true, get: () => value }); + setTimeout(() => { + shadow("readyState", 4); + shadow("status", 200); + shadow("statusText", "OK"); + shadow("responseURL", url); + shadow("responseText", this.responseType === "" || this.responseType === "text" ? BLOCKED_BODY : ""); + shadow("response", this.responseType === "json" ? { + "e": 0, + "sc": 10, + "tc": 10 + } : BLOCKED_BODY); + this.dispatchEvent(new Event("readystatechange")); + this.dispatchEvent(new ProgressEvent("load")); + this.dispatchEvent(new ProgressEvent("loadend")); + }, 0); + } + abort() { + if (this._blockedUrl !== void 0) return; + super.abort(); + } + getAllResponseHeaders() { + if (this._blockedUrl !== void 0) return "content-type: application/json\r\n"; + return super.getAllResponseHeaders(); + } + getResponseHeader(name2) { + if (this._blockedUrl !== void 0) { + return name2.toLowerCase() === "content-type" ? "application/json" : null; + } + return super.getResponseHeader(name2); + } + }; + const _exports$4 = []; + let userState = { + isLogin: false, + userInfo: null + }; + if (read("userState")) { + userState = read("userState"); + } + console.log("userState:", userState); + async function getDetailedUserInfo() { + if (!(userState == null ? void 0 : userState.isLogin) || !(userState == null ? void 0 : userState.userInfo)) { + return null; + } + if (userState.userInfo.gender !== void 0 && userState.userInfo.recommend_gender !== void 0 && userState.userInfo.fans_num !== void 0 && userState.userInfo.following_num !== void 0 && userState.userInfo.is_author !== void 0 && userState.userInfo.author_desc !== void 0 && userState.userInfo.read_book_num !== void 0 && userState.userInfo.read_book_time !== void 0) { + return userState.userInfo; + } + const response = await fetch$1("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967"); + const j = await response.json(); + if (j == null ? void 0 : j.data) { + const data = j.data; + userState.userInfo.gender = data.profile_gender; + userState.userInfo.recommend_gender = data.gender; + userState.userInfo.fans_num = data.fans_num; + userState.userInfo.following_num = data.follow_user_num; + userState.userInfo.is_author = data.is_author; + userState.userInfo.author_desc = data.author_desc; + userState.userInfo.read_book_num = data.read_book_num; + userState.userInfo.read_book_time = BigInt(data.read_book_time); + return userState.userInfo; + } + write("userState", userState); + return userState.userInfo; + } + async function checkLogin() { + var _a, _b, _c, _d, _e, _f; + const response = await fetch$1("https://fanqienovel.com/api/user/info/v2"); + const j = await response.json(); + const _userInfo = { + id: (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.id, + username: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.name, + avatar: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.avatar, + desc: (_d = j == null ? void 0 : j.data) == null ? void 0 : _d.desc, + age: (_e = j == null ? void 0 : j.data) == null ? void 0 : _e.age + }; + if (((_f = j == null ? void 0 : j.data) == null ? void 0 : _f.id) > 1) { + userState.isLogin = true; + userState.userInfo = _userInfo; + write("userState", userState); + return true; + } else { + del("userState"); + return false; + } + } + async function init() { + var _a; + await checkLogin(); + if (userState.isLogin) { + console.log("Hello, ", (_a = userState == null ? void 0 : userState.userInfo) == null ? void 0 : _a.username); + } + } + const bookshelf = '\r\n \r\n \r\n \r\n \r\n \r\n'; + const settings = '\r\n\r\n \r\n \r\n'; const name = "fanqie-assistant"; const version = "0.0.6"; const _hoisted_1$9 = { @@ -2663,59 +5383,68 @@ const _hoisted_9$6 = { class: "fqa-set-row" }; const _hoisted_10$6 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_11$5 = ["disabled"]; - const _hoisted_12$5 = { class: "fqa-set-row" }; - const _hoisted_13$4 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_14$4 = { + const _hoisted_12$5 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_13$4 = { class: "fqa-set-radios" }; + const _hoisted_14$4 = { class: "fqa-set-radio" }; + const _hoisted_15$2 = { class: "fqa-set-radio" }; + const _hoisted_16$1 = { class: "fqa-set-row" }; + const _hoisted_17$1 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_18$1 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; - const _hoisted_15$2 = { class: "fqa-set-row" }; - const _hoisted_16$1 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_17$1 = { class: "fqa-set-radios" }; - const _hoisted_18$1 = { class: "fqa-set-radio" }; - const _hoisted_19$1 = { class: "fqa-set-radio" }; + const _hoisted_19$1 = { class: "fqa-set-row" }; const _hoisted_20 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_21 = { class: "fqa-set-radios" }; const _hoisted_22 = { class: "fqa-set-radio" }; const _hoisted_23 = { class: "fqa-set-radio" }; const _hoisted_24 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_25 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; - const _hoisted_26 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; - const _hoisted_27 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; + const _hoisted_25 = { class: "fqa-set-radios" }; + const _hoisted_26 = { class: "fqa-set-radio" }; + const _hoisted_27 = { class: "fqa-set-radio" }; const _hoisted_28 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_29 = { class: "fqa-set-field" }; - const _hoisted_30 = { class: "fqa-set-field" }; - const _hoisted_31 = { class: "fqa-set-field" }; + const _hoisted_29 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; + const _hoisted_30 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; + const _hoisted_31 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; const _hoisted_32 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_33 = { class: "fqa-set-radios" }; - const _hoisted_34 = { class: "fqa-set-radio" }; - const _hoisted_35 = { class: "fqa-set-radio" }; + const _hoisted_33 = { class: "fqa-set-field" }; + const _hoisted_34 = { class: "fqa-set-field" }; + const _hoisted_35 = { class: "fqa-set-field" }; const _hoisted_36 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_37 = { class: "fqa-set-field" }; - const _hoisted_38 = ["placeholder"]; - const _hoisted_39 = { class: "fqa-set-field" }; - const _hoisted_40 = ["placeholder"]; - const _hoisted_41 = { class: "fqa-set-field" }; - const _hoisted_42 = ["placeholder"]; - const _hoisted_43 = { class: "fqa-set-actions" }; - const _hoisted_44 = ["disabled"]; - const _hoisted_45 = { + const _hoisted_37 = { class: "fqa-set-radios" }; + const _hoisted_38 = { class: "fqa-set-radio" }; + const _hoisted_39 = { class: "fqa-set-radio" }; + const _hoisted_40 = { class: "fqa-set-row" }; + const _hoisted_41 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_42 = { class: "fqa-set-radios" }; + const _hoisted_43 = { class: "fqa-set-radio" }; + const _hoisted_44 = { class: "fqa-set-radio" }; + const _hoisted_45 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_46 = { class: "fqa-set-field" }; + const _hoisted_47 = ["placeholder"]; + const _hoisted_48 = { class: "fqa-set-field" }; + const _hoisted_49 = ["placeholder"]; + const _hoisted_50 = { class: "fqa-set-field" }; + const _hoisted_51 = ["placeholder"]; + const _hoisted_52 = { class: "fqa-set-actions" }; + const _hoisted_53 = ["disabled"]; + const _hoisted_54 = { key: 0, class: "fqa-set-note" }; - const _hoisted_46 = { class: "fqa-set-note" }; - const _hoisted_47 = { class: "fqa-set-links" }; - const _hoisted_48 = { class: "fqa-set-link-row" }; - const _hoisted_49 = ["href"]; + const _hoisted_55 = { class: "fqa-set-note" }; + const _hoisted_56 = { class: "fqa-set-links" }; + const _hoisted_57 = { class: "fqa-set-link-row" }; + const _hoisted_58 = ["href"]; const GREASYFORK = "https://greasyfork.org/zh-CN/scripts/589115-%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E5%8A%A9%E6%89%8B"; const GITHUB = "https://github.com/naiyQAQ/fanqie-assistant"; const _sfc_main$9 = /* @__PURE__ */ vue.defineComponent({ @@ -2734,6 +5463,7 @@ { key: "ui", label: "界面" }, { key: "search", label: "搜索" }, { key: "download", label: "下载" }, + { key: "audiobook", label: "听书" }, { key: "protocol", label: "协议" }, { key: "about", label: "关于" } ]; @@ -2785,7 +5515,7 @@ onClick: close2 }, "✕"), vue.createElementVNode("nav", _hoisted_2$9, [ - _cache[24] || (_cache[24] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), + _cache[29] || (_cache[29] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, vue.renderList(SECTIONS, (s) => { return vue.createElementVNode("div", { key: s.key, @@ -2799,9 +5529,9 @@ ]), vue.createElementVNode("section", _hoisted_4$8, [ active.value === "general" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ - _cache[28] || (_cache[28] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), + _cache[33] || (_cache[33] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), vue.createElementVNode("label", _hoisted_5$8, [ - _cache[25] || (_cache[25] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), + _cache[30] || (_cache[30] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => vue.unref(settings$1).decryptFont = $event), type: "checkbox", @@ -2811,7 +5541,7 @@ ]) ]), vue.createElementVNode("label", _hoisted_6$8, [ - _cache[26] || (_cache[26] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), + _cache[31] || (_cache[31] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => vue.unref(settings$1).blockReport = $event), type: "checkbox", @@ -2821,7 +5551,7 @@ ]) ]), vue.createElementVNode("label", _hoisted_7$6, [ - _cache[27] || (_cache[27] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), + _cache[32] || (_cache[32] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => vue.unref(settings$1).allowCopy = $event), type: "checkbox", @@ -2831,9 +5561,9 @@ ]) ]) ], 64)) : active.value === "ui" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ - _cache[33] || (_cache[33] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), + _cache[42] || (_cache[42] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), vue.createElementVNode("div", _hoisted_8$6, [ - _cache[29] || (_cache[29] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), + _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => vue.unref(settings$1).readerFont = $event), class: "fqa-set-input", @@ -2842,10 +5572,10 @@ }, null, 512), [ [vue.vModelText, vue.unref(settings$1).readerFont] ]), - _cache[30] || (_cache[30] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) + _cache[35] || (_cache[35] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) ]), vue.createElementVNode("div", _hoisted_9$6, [ - _cache[31] || (_cache[31] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), + _cache[36] || (_cache[36] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => vue.unref(settings$1).customCssEnabled = $event), type: "checkbox", @@ -2864,138 +5594,164 @@ }, null, 8, _hoisted_11$5), [ [vue.vModelText, vue.unref(settings$1).customCss] ]), - _cache[32] || (_cache[32] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) + _cache[37] || (_cache[37] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) + ]), + vue.createElementVNode("div", _hoisted_12$5, [ + _cache[40] || (_cache[40] = vue.createElementVNode("span", { class: "fqa-set-label" }, "书架单击书本", -1)), + vue.createElementVNode("div", _hoisted_13$4, [ + vue.createElementVNode("label", _hoisted_14$4, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), + type: "radio", + value: "read" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] + ]), + _cache[38] || (_cache[38] = vue.createElementVNode("span", null, "继续阅读", -1)) + ]), + vue.createElementVNode("label", _hoisted_15$2, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), + type: "radio", + value: "detail" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] + ]), + _cache[39] || (_cache[39] = vue.createElementVNode("span", null, "查看详情", -1)) + ]) + ]), + _cache[41] || (_cache[41] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 按住 Ctrl 单击去往另一项;中键在新标签页打开,Ctrl+中键同样反向。 选「继续阅读」但这本书还没读过时,会退回详情页。 ", -1)) ]) ], 64)) : active.value === "search" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 2 }, [ - _cache[37] || (_cache[37] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), - vue.createElementVNode("label", _hoisted_12$5, [ - _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), + _cache[46] || (_cache[46] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), + vue.createElementVNode("label", _hoisted_16$1, [ + _cache[43] || (_cache[43] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).enhanceSearch = $event), + "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).enhanceSearch = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enhanceSearch] ]) ]), - vue.createElementVNode("div", _hoisted_13$4, [ - vue.createElementVNode("label", _hoisted_14$4, [ - _cache[35] || (_cache[35] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), + vue.createElementVNode("div", _hoisted_17$1, [ + vue.createElementVNode("label", _hoisted_18$1, [ + _cache[44] || (_cache[44] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).searchPersonalized = $event), + "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).searchPersonalized = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).searchPersonalized] ]) ]), - _cache[36] || (_cache[36] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) + _cache[45] || (_cache[45] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) ]) ], 64)) : active.value === "download" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ - _cache[58] || (_cache[58] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), - vue.createElementVNode("label", _hoisted_15$2, [ - _cache[38] || (_cache[38] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), + _cache[67] || (_cache[67] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), + vue.createElementVNode("label", _hoisted_19$1, [ + _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).enableDownload = $event), + "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).enableDownload = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enableDownload] ]) ]), - vue.createElementVNode("div", _hoisted_16$1, [ - _cache[41] || (_cache[41] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), - vue.createElementVNode("div", _hoisted_17$1, [ - vue.createElementVNode("label", _hoisted_18$1, [ + vue.createElementVNode("div", _hoisted_20, [ + _cache[50] || (_cache[50] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), + vue.createElementVNode("div", _hoisted_21, [ + vue.createElementVNode("label", _hoisted_22, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).downloadFormat = $event), + "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "epub" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), - _cache[39] || (_cache[39] = vue.createElementVNode("span", null, "EPUB", -1)) + _cache[48] || (_cache[48] = vue.createElementVNode("span", null, "EPUB", -1)) ]), - vue.createElementVNode("label", _hoisted_19$1, [ + vue.createElementVNode("label", _hoisted_23, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).downloadFormat = $event), + "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "txt" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), - _cache[40] || (_cache[40] = vue.createElementVNode("span", null, "TXT", -1)) + _cache[49] || (_cache[49] = vue.createElementVNode("span", null, "TXT", -1)) ]) ]), - _cache[42] || (_cache[42] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) + _cache[51] || (_cache[51] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) ]), - vue.createElementVNode("div", _hoisted_20, [ - _cache[45] || (_cache[45] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), - vue.createElementVNode("div", _hoisted_21, [ - vue.createElementVNode("label", _hoisted_22, [ + vue.createElementVNode("div", _hoisted_24, [ + _cache[54] || (_cache[54] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), + vue.createElementVNode("div", _hoisted_25, [ + vue.createElementVNode("label", _hoisted_26, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).downloadCharset = $event), + "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "utf-8" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), - _cache[43] || (_cache[43] = vue.createElementVNode("span", null, "UTF-8", -1)) + _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "UTF-8", -1)) ]), - vue.createElementVNode("label", _hoisted_23, [ + vue.createElementVNode("label", _hoisted_27, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).downloadCharset = $event), + "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "gbk" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), - _cache[44] || (_cache[44] = vue.createElementVNode("span", null, "GBK", -1)) + _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "GBK", -1)) ]) ]), - _cache[46] || (_cache[46] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) + _cache[55] || (_cache[55] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) ]), - vue.createElementVNode("div", _hoisted_24, [ - _cache[50] || (_cache[50] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), - vue.createElementVNode("label", _hoisted_25, [ - _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), + vue.createElementVNode("div", _hoisted_28, [ + _cache[59] || (_cache[59] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), + vue.createElementVNode("label", _hoisted_29, [ + _cache[56] || (_cache[56] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadImages = $event), + "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadImages = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadImages] ]) ]), - vue.createElementVNode("label", _hoisted_26, [ - _cache[48] || (_cache[48] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), + vue.createElementVNode("label", _hoisted_30, [ + _cache[57] || (_cache[57] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadBookCss = $event), + "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadBookCss = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadBookCss] ]) ]), - vue.createElementVNode("label", _hoisted_27, [ - _cache[49] || (_cache[49] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), + vue.createElementVNode("label", _hoisted_31, [ + _cache[58] || (_cache[58] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), + "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadVolumePage] ]) ]), - _cache[51] || (_cache[51] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) + _cache[60] || (_cache[60] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) ]), - vue.createElementVNode("div", _hoisted_28, [ - _cache[55] || (_cache[55] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), - _cache[56] || (_cache[56] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), - vue.createElementVNode("label", _hoisted_29, [ - _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), + vue.createElementVNode("div", _hoisted_32, [ + _cache[64] || (_cache[64] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), + _cache[65] || (_cache[65] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), + vue.createElementVNode("label", _hoisted_33, [ + _cache[61] || (_cache[61] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), + "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), class: "fqa-set-input", type: "number", min: "1", @@ -3009,10 +5765,10 @@ ] ]) ]), - vue.createElementVNode("label", _hoisted_30, [ - _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), + vue.createElementVNode("label", _hoisted_34, [ + _cache[62] || (_cache[62] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadInterval = $event), + "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).downloadInterval = $event), class: "fqa-set-input", type: "number", min: "0", @@ -3027,10 +5783,10 @@ ] ]) ]), - vue.createElementVNode("label", _hoisted_31, [ - _cache[54] || (_cache[54] = vue.createElementVNode("span", null, "重试轮数", -1)), + vue.createElementVNode("label", _hoisted_35, [ + _cache[63] || (_cache[63] = vue.createElementVNode("span", null, "重试轮数", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadRetries = $event), + "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).downloadRetries = $event), class: "fqa-set-input", type: "number", min: "0", @@ -3044,7 +5800,7 @@ ] ]) ]), - _cache[57] || (_cache[57] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), + _cache[66] || (_cache[66] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), vue.createElementVNode("div", { class: "fqa-set-actions" }, [ vue.createElementVNode("button", { class: "fqa-set-btn", @@ -3052,85 +5808,124 @@ }, "恢复推荐值") ]) ]) - ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ - _cache[68] || (_cache[68] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), - vue.createElementVNode("div", _hoisted_32, [ - _cache[61] || (_cache[61] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), - vue.createElementVNode("div", _hoisted_33, [ - vue.createElementVNode("label", _hoisted_34, [ + ], 64)) : active.value === "audiobook" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ + _cache[73] || (_cache[73] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "听书", -1)), + vue.createElementVNode("div", _hoisted_36, [ + _cache[70] || (_cache[70] = vue.createElementVNode("span", { class: "fqa-set-label" }, "读完一章后", -1)), + vue.createElementVNode("div", _hoisted_37, [ + vue.createElementVNode("label", _hoisted_38, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).apiPreference = $event), + "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), + type: "radio", + value: "next" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] + ]), + _cache[68] || (_cache[68] = vue.createElementVNode("span", null, "自动下一章", -1)) + ]), + vue.createElementVNode("label", _hoisted_39, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), + type: "radio", + value: "stop" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] + ]), + _cache[69] || (_cache[69] = vue.createElementVNode("span", null, "停止播放", -1)) + ]) + ]), + _cache[71] || (_cache[71] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 自动下一章会点击页面上的「下一章」,读到全书最后一章时自动停下。 ", -1)) + ]), + vue.createElementVNode("label", _hoisted_40, [ + _cache[72] || (_cache[72] = vue.createElementVNode("span", { class: "fqa-set-label" }, "高亮并跟随朗读段落", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).audiobookFollow = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings$1).audiobookFollow] + ]) + ]), + _cache[74] || (_cache[74] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 关闭后仍会高亮当前段落,但不再自动滚动页面。点击任意段落可跳到该处朗读。 ", -1)) + ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 5 }, [ + _cache[84] || (_cache[84] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), + vue.createElementVNode("div", _hoisted_41, [ + _cache[77] || (_cache[77] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), + vue.createElementVNode("div", _hoisted_42, [ + vue.createElementVNode("label", _hoisted_43, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[24] || (_cache[24] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "app" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), - _cache[59] || (_cache[59] = vue.createElementVNode("span", null, "番茄 APP", -1)) + _cache[75] || (_cache[75] = vue.createElementVNode("span", null, "番茄 APP", -1)) ]), - vue.createElementVNode("label", _hoisted_35, [ + vue.createElementVNode("label", _hoisted_44, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).apiPreference = $event), + "onUpdate:modelValue": _cache[25] || (_cache[25] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "redcandle" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), - _cache[60] || (_cache[60] = vue.createElementVNode("span", null, "红烛 APP", -1)) + _cache[76] || (_cache[76] = vue.createElementVNode("span", null, "红烛 APP", -1)) ]) ]), - _cache[62] || (_cache[62] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) + _cache[78] || (_cache[78] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) ]), - vue.createElementVNode("div", _hoisted_36, [ - _cache[66] || (_cache[66] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), - _cache[67] || (_cache[67] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), - vue.createElementVNode("label", _hoisted_37, [ - _cache[63] || (_cache[63] = vue.createElementVNode("span", null, "device_id", -1)), + vue.createElementVNode("div", _hoisted_45, [ + _cache[82] || (_cache[82] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), + _cache[83] || (_cache[83] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), + vue.createElementVNode("label", _hoisted_46, [ + _cache[79] || (_cache[79] = vue.createElementVNode("span", null, "device_id", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).deviceId = $event), + "onUpdate:modelValue": _cache[26] || (_cache[26] = ($event) => vue.unref(settings$1).deviceId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_id || "自动注册" - }, null, 8, _hoisted_38), [ + }, null, 8, _hoisted_47), [ [vue.vModelText, vue.unref(settings$1).deviceId] ]) ]), - vue.createElementVNode("label", _hoisted_39, [ - _cache[64] || (_cache[64] = vue.createElementVNode("span", null, "install_id (iid)", -1)), + vue.createElementVNode("label", _hoisted_48, [ + _cache[80] || (_cache[80] = vue.createElementVNode("span", null, "install_id (iid)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).installId = $event), + "onUpdate:modelValue": _cache[27] || (_cache[27] = ($event) => vue.unref(settings$1).installId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.install_id || "自动注册" - }, null, 8, _hoisted_40), [ + }, null, 8, _hoisted_49), [ [vue.vModelText, vue.unref(settings$1).installId] ]) ]), - vue.createElementVNode("label", _hoisted_41, [ - _cache[65] || (_cache[65] = vue.createElementVNode("span", null, "device_type", -1)), + vue.createElementVNode("label", _hoisted_50, [ + _cache[81] || (_cache[81] = vue.createElementVNode("span", null, "device_type", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).deviceType = $event), + "onUpdate:modelValue": _cache[28] || (_cache[28] = ($event) => vue.unref(settings$1).deviceType = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_type || "自动注册" - }, null, 8, _hoisted_42), [ + }, null, 8, _hoisted_51), [ [vue.vModelText, vue.unref(settings$1).deviceType] ]) ]), - vue.createElementVNode("div", _hoisted_43, [ + vue.createElementVNode("div", _hoisted_52, [ vue.createElementVNode("button", { class: "fqa-set-btn", disabled: registering.value, onClick: reRegister - }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_44), - registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_45, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) + }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_53), + registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_54, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) ]) ]) - ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 5 }, [ - _cache[73] || (_cache[73] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), - vue.createElementVNode("p", _hoisted_46, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), - vue.createElementVNode("div", _hoisted_47, [ + ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 6 }, [ + _cache[89] || (_cache[89] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), + vue.createElementVNode("p", _hoisted_55, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), + vue.createElementVNode("div", _hoisted_56, [ vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ - _cache[69] || (_cache[69] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), + _cache[85] || (_cache[85] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), vue.createElementVNode("a", { href: GREASYFORK, target: "_blank", @@ -3138,31 +5933,31 @@ }, "跳转") ]), vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ - _cache[70] || (_cache[70] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), + _cache[86] || (_cache[86] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), vue.createElementVNode("a", { href: GITHUB, target: "_blank", rel: "noreferrer noopener" }, "跳转") ]), - vue.createElementVNode("div", _hoisted_48, [ - _cache[72] || (_cache[72] = vue.createElementVNode("span", null, "问题反馈:", -1)), + vue.createElementVNode("div", _hoisted_57, [ + _cache[88] || (_cache[88] = vue.createElementVNode("span", null, "问题反馈:", -1)), vue.createElementVNode("a", { href: FEEDBACK, target: "_blank", rel: "noreferrer noopener" }, "GreasyFork"), vue.createElementVNode("span", null, [ - _cache[71] || (_cache[71] = vue.createTextVNode(" 或 ", -1)), + _cache[87] || (_cache[87] = vue.createTextVNode(" 或 ", -1)), vue.createElementVNode("a", { href: `${GITHUB}/issues`, target: "_blank", rel: "noreferrer noopener" - }, " GitHub Issues ", 8, _hoisted_49) + }, " GitHub Issues ", 8, _hoisted_58) ]) ]) ]), - _cache[74] || (_cache[74] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ + _cache[90] || (_cache[90] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ vue.createElementVNode("p", null, " 本脚本基于 GNU General Public License 3.0 授权,完全开源且免费,修改/二次开发请注意遵守开源协议。 "), vue.createElementVNode("p", null, "本脚本使用 TypeScript + Vue 开发,请避免直接修改编译产物。") ], -1)) @@ -3338,7 +6133,32 @@ handler: guestHook } ]; - const _hoisted_1$8 = ["aria-label"]; + const MIDDLE_BUTTON = 1; + function modifiersOf(event) { + return { + flip: event.ctrlKey || event.metaKey, + newTab: "button" in event && event.button === MIDDLE_BUTTON + }; + } + function isMiddleButton(event) { + return event.button === MIDDLE_BUTTON; + } + function readerUrl(chapterId) { + return `https://fanqienovel.com/reader/${chapterId}`; + } + function bookPageUrl(bookId2) { + return `https://fanqienovel.com/page/${bookId2}`; + } + function openUrl(url, newTab = false) { + if (!newTab) { + unsafeWindow.location.href = url; + return; + } + if (!unsafeWindow.open(url, "_blank")) { + console.warn("[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:", url); + } + } + const _hoisted_1$8 = ["aria-label", "onKeydown"]; const _hoisted_2$8 = { class: "fqa-cover" }; const _hoisted_3$8 = ["src", "alt"]; const _hoisted_4$7 = { @@ -3391,6 +6211,17 @@ event.preventDefault(); emit("contextmenu", { entry: props.entry, x: event.clientX, y: event.clientY }); } + function open2(event) { + emit("open", { entry: props.entry, mods: modifiersOf(event) }); + } + function onAuxClick(event) { + if (!isMiddleButton(event)) return; + event.preventDefault(); + open2(event); + } + function onMouseDown(event) { + if (isMiddleButton(event)) event.preventDefault(); + } const imgLoaded = vue.ref(false); vue.watch( () => { @@ -3433,19 +6264,21 @@ onMouseenter: onEnter2, onMouseleave: _cache[2] || (_cache[2] = ($event) => emit("leave")), onContextmenu: onContextMenu, - onClick: _cache[3] || (_cache[3] = ($event) => emit("open", __props.entry)), + onClick: open2, + onAuxclick: onAuxClick, + onMousedown: onMouseDown, onKeydown: [ - _cache[4] || (_cache[4] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.entry), ["prevent"]), ["enter"])), - _cache[5] || (_cache[5] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.entry), ["prevent"]), ["space"])) + vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["enter"]), + vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["space"]) ] }, [ !detail.value ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ - _cache[6] || (_cache[6] = vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1)), - _cache[7] || (_cache[7] = vue.createElementVNode("div", { + _cache[3] || (_cache[3] = vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1)), + _cache[4] || (_cache[4] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "90%" } }, null, -1)), - _cache[8] || (_cache[8] = vue.createElementVNode("div", { + _cache[5] || (_cache[5] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "55%" } }, null, -1)) @@ -3990,10 +6823,10 @@ } return results; } - function identify(bookId, modifyTime = 0) { + function identify(bookId2, modifyTime = 0) { return { asterisked: false, - book_id: bookId, + book_id: bookId2, book_type: 0, modify_time: modifyTime }; @@ -4011,23 +6844,23 @@ } return json; } - async function addToBookshelf(bookId) { + async function addToBookshelf(bookId2) { await shelfPost("/add", { add_book_source: 0, - identify_data: [identify(bookId)] + identify_data: [identify(bookId2)] }); } - async function removeFromBookshelf(bookId) { + async function removeFromBookshelf(bookId2) { await shelfPost("/delete", { - identify_data: [identify(bookId, Date.now())] + identify_data: [identify(bookId2, Date.now())] }); } - async function moveToGroup(bookId, groupName) { + async function moveToGroup(bookId2, groupName) { await shelfPost("/update", { book_data: [ { asterisked: false, - book_id: bookId, + book_id: bookId2, book_type: 0, group_name: groupName, has_shown: false, @@ -4254,7 +7087,7 @@ function hasContent(chapter) { return Boolean(chapter == null ? void 0 : chapter.content) && chapter.content !== "Invalid"; } - async function fetchChapters(itemIds, bookId, task, options = {}) { + async function fetchChapters(itemIds, bookId2, task, options = {}) { var _a; const batchSize = Math.min( Math.max(1, options.batchSize ?? settings$1.downloadBatchSize), @@ -4277,13 +7110,13 @@ task.throwIfCancelled(); const batch = batches[i2]; try { - const result = await getChapters(batch, bookId); - for (const itemId of batch) { - const chapter = result[itemId]; + const result = await getChapters(batch, bookId2); + for (const itemId2 of batch) { + const chapter = result[itemId2]; if (hasContent(chapter)) { - chapters[itemId] = chapter; + chapters[itemId2] = chapter; } else { - missed.push(itemId); + missed.push(itemId2); } } } catch (err) { @@ -5519,10 +8352,10 @@ ${body} } return void 0; } - async function resolveMeta(bookId, chapters) { + async function resolveMeta(bookId2, chapters) { var _a; try { - const raw = await getBookInfoRaw(bookId); + const raw = await getBookInfoRaw(bookId2); if (raw == null ? void 0 : raw.book_id) return toBookMeta(raw); } catch (err) { console.warn("[fqa:download] 获取书籍详情失败,改用章节里的信息:", err); @@ -5531,9 +8364,9 @@ ${body} var _a2; return (_a2 = c.novel_data) == null ? void 0 : _a2.book_id; })) == null ? void 0 : _a.novel_data; - return toBookMeta(fallback ?? { book_id: bookId }); + return toBookMeta(fallback ?? { book_id: bookId2 }); } - async function startDownload(bookId, options = {}) { + async function startDownload(bookId2, options = {}) { if (running) { console.warn("[fqa:download] 已有下载任务在进行"); return; @@ -5544,16 +8377,16 @@ ${body} setActiveTask(task); try { task.stage("获取目录…"); - const catalogResult = await getCatalog(bookId); + const catalogResult = await getCatalog(bookId2); const catalog = catalogResult.chapter_list; if (catalog.length === 0) throw new Error("目录为空"); task.throwIfCancelled(); const titleMap = new Map(catalog.map((item) => [item.item_id, item.title])); const { chapters, failed } = await fetchChapters( catalog.map((item) => item.item_id), - bookId, + bookId2, task, - { titleOf: (itemId) => titleMap.get(itemId) } + { titleOf: (itemId2) => titleMap.get(itemId2) } ); task.throwIfCancelled(); if (Object.keys(chapters).length === 0) { @@ -5562,9 +8395,9 @@ ${body} if (failed.length > 0) { console.warn(`[fqa:download] ${failed.length} 章缺失,仍然继续导出`); } - const meta = await resolveMeta(bookId, chapters); + const meta = await resolveMeta(bookId2, chapters); task.throwIfCancelled(); - const base = sanitizeFilename(`${meta.title}_${meta.author}`, bookId); + const base = sanitizeFilename(`${meta.title}_${meta.author}`, bookId2); if (format === "epub") { const blob = await buildEpub({ meta, @@ -5787,12 +8620,16 @@ ${body} function onGroupVisible(entries) { queueDetails(entries); } - function openBook(entry) { + function continueReadingUrl(entry) { var _a; - hideHover(true); const chapterId = ((_a = entry.detail) == null ? void 0 : _a.current_chapter_id) || entry.item.last_read_chapter_id; - const url = chapterId && chapterId !== "0" ? `https://fanqienovel.com/reader/${chapterId}` : `https://fanqienovel.com/page/${entry.item.book_id}`; - unsafeWindow.location.href = url; + return chapterId && chapterId !== "0" ? readerUrl(chapterId) : null; + } + function openBook({ entry, mods }) { + hideHover(true); + const wantRead = settings$1.bookshelfClickAction === "read" !== mods.flip; + const url = wantRead && continueReadingUrl(entry) || bookPageUrl(entry.item.book_id); + openUrl(url, mods.newTab); } function openGroup(group) { hideHover(true); @@ -5819,7 +8656,7 @@ ${body} const targets = groups.value.filter((g2) => g2.name !== current).map((g2) => ({ key: `${MOVE_PREFIX}${g2.name}`, label: g2.name })); if (current) targets.push({ key: NO_GROUP_KEY, label: "无分组" }); const items = [ - { key: "open", label: "打开" }, + { key: "read", label: "继续阅读" }, { key: "detail", label: "查看详情" }, { key: "move", @@ -5851,24 +8688,24 @@ ${body} var _a; const entry = menuEntry.value; if (!entry) return; - const bookId = entry.item.book_id; - if (key === "open") { - openBook(entry); + const bookId2 = entry.item.book_id; + if (key === "read") { + openUrl(continueReadingUrl(entry) ?? bookPageUrl(bookId2)); return; } if (key === "detail") { - unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`; + openUrl(bookPageUrl(bookId2)); return; } if (key.startsWith(DOWNLOAD_PREFIX$1)) { const format = key.slice(DOWNLOAD_PREFIX$1.length); - void startDownload(bookId, { format }); + void startDownload(bookId2, { format }); return; } if (key === "remove") { - if (!unsafeWindow.confirm(`确定要把《${((_a = entry.detail) == null ? void 0 : _a.title) ?? bookId}》从书架删除吗?`)) return; + if (!unsafeWindow.confirm(`确定要把《${((_a = entry.detail) == null ? void 0 : _a.title) ?? bookId2}》从书架删除吗?`)) return; try { - await removeFromBookshelf(bookId); + await removeFromBookshelf(bookId2); showToast("已从书架删除"); await refresh(); } catch (err) { @@ -5880,7 +8717,7 @@ ${body} if (key.startsWith(MOVE_PREFIX)) { const groupName = key.slice(MOVE_PREFIX.length); try { - await moveToGroup(bookId, groupName); + await moveToGroup(bookId2, groupName); showToast(groupName ? `已移动到「${groupName}」` : "已移出分组"); await refresh(); } catch (err) { @@ -6309,11 +9146,11 @@ ${body} } function normalizeBook(raw, cell) { var _a; - const bookId = String((raw == null ? void 0 : raw.book_id) ?? ""); - if (!bookId || bookId === "0") return null; + const bookId2 = String((raw == null ? void 0 : raw.book_id) ?? ""); + if (!bookId2 || bookId2 === "0") return null; const hl = cell == null ? void 0 : cell.search_high_light; return { - book_id: bookId, + book_id: bookId2, title: raw.book_name || raw.original_book_name || "", author: raw.author || "", cover_url: raw.thumb_url || raw.audio_thumb_url_hd || "", @@ -6542,8 +9379,8 @@ ${body} loading.value = false; } } - function openBook(bookId) { - unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`; + function openBook(bookId2) { + unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId2}`; } vue.onMounted(load2); return (_ctx, _cache) => { @@ -6707,14 +9544,14 @@ ${body} if (next !== BOOK_TAB_TYPE) selected.value = {}; void run(true); } - function toggleFilter(rowName, itemId) { + function toggleFilter(rowName, itemId2) { const cur = selected.value[rowName]; - if (cur === itemId) { + if (cur === itemId2) { const next = { ...selected.value }; delete next[rowName]; selected.value = next; } else { - selected.value = { ...selected.value, [rowName]: itemId }; + selected.value = { ...selected.value, [rowName]: itemId2 }; } void run(true); } @@ -7271,7 +10108,7 @@ ${body} } const FUNCTIONAL_CLASSES = ["add-bookshelf-btn", "info-btn"]; const BUTTON_GAP = 10; - function createButton(anchor, bookId) { + function createButton(anchor, bookId2) { const btn = document.createElement("button"); btn.type = "button"; const classes = Array.from(anchor.classList).filter((c) => !FUNCTIONAL_CLASSES.includes(c)); @@ -7282,7 +10119,7 @@ ${body} btn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - void startDownload(bookId, { format: settings$1.downloadFormat }); + void startDownload(bookId2, { format: settings$1.downloadFormat }); }); return btn; } @@ -7298,7 +10135,7 @@ ${body} btn.style.bottom = "0"; btn.style.left = left; } - function inject(bookId) { + function inject(bookId2) { const anchors = Array.from(document.querySelectorAll(ANCHOR_SELECTOR)); if (anchors.length === 0) return; const existing = document.querySelector(`.${ENTRY_CLASS}`); @@ -7308,7 +10145,7 @@ ${body} } const anchor = anchors[anchors.length - 1]; if (!anchor.parentElement) return; - const btn = createButton(anchor, bookId); + const btn = createButton(anchor, bookId2); anchor.insertAdjacentElement("afterend", btn); placeButton(btn, anchors); } @@ -7323,16 +10160,16 @@ ${body} teardown(); return; } - const bookId = parseBookId(path); - if (!bookId) return; + const bookId2 = parseBookId(path); + if (!bookId2) return; injectStyle$1(); teardown(); await waitForElement(ANCHOR_SELECTOR, 8e3); if (window.location.pathname !== path) return; - inject(bookId); + inject(bookId2); observer = new MutationObserver(() => { if (window.location.pathname !== path) return; - inject(bookId); + inject(bookId2); }); observer.observe(document.body, { childList: true, subtree: true }); } diff --git a/src/api/app.ts b/src/api/app.ts index 06ca7fd..4600632 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -1,10 +1,16 @@ -import apiFetch from '../utils/request' +import apiFetch, { isEmptyResponse } from '../utils/request' import { signRequest } from '../crypto/sign' import config, { fetch as pageFetch } from '../config' import { settings } from '../settings' +import { replaceDevice, markDeviceHealthy } from './provision' export const appBaseUrl = 'https://reading.snssdk.com/reading' +/** + * APP 网关的根地址。绝大多数接口挂在 /reading 下(见 appBaseUrl), + * 但书评那套在根路径 /novel/commentapi/*,套上 /reading 会 404。 + */ +export const appRootUrl = 'https://reading.snssdk.com' export const redcandleBaseUrl = 'https://api5-sinfonlinec.jxbhmy.com/reading' /** * 番茄网页站同源挂载的 APP 接口。走页面 fetch 时浏览器会自动带上 Cookie, @@ -33,6 +39,7 @@ export function buildAppQuery(extra?: Record): URLSearchParams { device_brand: c.device_brand || 'realme', update_version_code: '70132', manifest_version_code: '70132', + pv_player: '70132', ...extra, }) } @@ -66,6 +73,7 @@ function isUsable(res: any): boolean { const j = res.json() return !j || j.code === undefined || j.code === 0 } catch { + // 包括空响应体(设备被作废),一律当作不可用,交给下一个通道 return false } } @@ -90,6 +98,35 @@ async function requestRedcandle(path: string, query?: Record, he } +/** + * 打番茄接口,遇到「设备被作废」自动换设备重试一次。 + * + * 服务端作废设备的表现是 HTTP 200 + 完全空的响应体,没有错误码可判断 + * (详见 provision.replaceDevice)。这里是所有 /reading/* GET 的唯一出口, + * 集中处理比每个调用点各写一遍可靠。 + */ +async function requestAppWithRecovery( + path: string, + query?: Record, + headers?: Record, +) { + const res = await requestApp(path, query, headers) + if (!isEmptyResponse(res)) { + // 有响应就说明网关认这台设备,解除替换锁 + markDeviceHealthy() + return res + } + + console.warn(`[fqa:api] ${path} 返回空响应体,判定当前设备已失效`) + // 换设备失败就把空响应交回去,让调用方按原有逻辑报错 + if (!(await replaceDevice())) return res + + const retry = await requestApp(path, query, headers) + if (!isEmptyResponse(retry)) markDeviceHealthy() + return retry +} + + export async function appGet( path: string, query?: Record, @@ -104,7 +141,7 @@ export async function appGet( console.warn(`[fqa:api] 红烛接口请求失败,回落到番茄 APP: ${path}`, e) } } - return requestApp(path, query, headers) + return requestAppWithRecovery(path, query, headers) } @@ -114,7 +151,30 @@ export async function appPost( query?: Record, headers?: Record, ): Promise { - const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}` + return postSigned(appBaseUrl + path, body, query, headers) +} + + +/** + * 打 APP 网关根路径(不带 /reading 前缀)的 POST 接口,目前只有书评在用。 + */ +export async function appRootPost( + path: string, + body: string, + query?: Record, + headers?: Record, +): Promise { + return postSigned(appRootUrl + path, body, query, headers) +} + + +async function postSigned( + base: string, + body: string, + query?: Record, + headers?: Record, +): Promise { + const url = `${base}?${buildAppQuery(query).toString()}` const signed = await signRequest(url, body) console.log('---start--- APP POST ', url) const res = await apiFetch(url, { diff --git a/src/api/audiobook.ts b/src/api/audiobook.ts new file mode 100644 index 0000000..9be0078 --- /dev/null +++ b/src/api/audiobook.ts @@ -0,0 +1,68 @@ +import { appGet } from './app' +import { decryptSpadeA } from '../crypto/spade' +import type { CencAudioContext, Tone, ParagraphTimeTag } from '../types' + +export async function getPlayInfo(item_id: string[] | string, tone_id: number): Promise { + const item_ids = Array.isArray(item_id) ? item_id.join(',') : item_id + const resp = await appGet('/reader/audio/playinfo/', { + item_ids, + tone_id: tone_id.toString() + }) + const j = resp.json() + const results: CencAudioContext[] = [] + if (Array.isArray(j.data)) { + for (let i of j.data) { + results.push({ + urls: [i.main_url, i.backup_url].filter(Boolean), + vid: i.vid, + key: i.is_encrypt ? decryptSpadeA(i.encryption_key) : '', + item_id: i.item_id + }) + } + } + return results +} + +export async function getBookAvailableTones(book_id: string): Promise { + const resp = await appGet('/bookapi/audio/toneinfo/', { book_id }) + const j = resp.json() + const results: Tone[] = [] + + if (Array.isArray(j?.data?.tts_tones)) { + for (let i of j.data.tts_tones) { + results.push({ + id: i.id, + name: i.title, + gender: i.tone_gender, + icon: i.icon_url, + description: i.description + }) + } + } + return results +} + +export async function getChapterParagraphTimeTag(item_id: string, tone_id: number): Promise { + // req_type=1 必须带:不带的话响应变成 para_match_data.text_para_match_data + // (按 book_id 分组、只有 start_time 没有 end_time);带上才是 data.time_points。 + // 另外响应里的 is_title 实测恒为 false,不能用来判断标题,只能看 start_para + const resp = await appGet('/reader/audio/timepoint/', { + item_id, + tone_id: tone_id.toString(), + req_type: '1' + }) + const j = resp.json() + const results: ParagraphTimeTag[] = [] + if (Array.isArray(j?.data?.time_points)) { + for (let i of j.data.time_points) { + results.push({ + startms: i.start_time, + endms: i.end_time, + startidx: i.start_para, + endidx: i.end_para, + is_title: i.start_para === 10000 + }) + } + } + return results +} \ No newline at end of file diff --git a/src/api/comment.ts b/src/api/comment.ts new file mode 100644 index 0000000..d20ac53 --- /dev/null +++ b/src/api/comment.ts @@ -0,0 +1,478 @@ +// 书评(评论 / 回复 / 点赞)。 +// +// 接口挂在 APP 网关的根路径 /novel/commentapi/*,不在 /reading 下面。 +// 网页站只把 /reading 反代到了 APP 网关,所以这里没有同源可用, +// 必须走 GM_xmlhttpRequest 打 reading.snssdk.com,并自己带 Cookie。 +// +// 实测要点(temp/probe 逐项验证): +// - 三个接口都要签名(x-argus/x-ladon/x-khronos + 有 body 时 X-SS-STUB), +// 不签名一律 code=110001「未知异常」。仓库现成的 signRequest 直接可用, +// query 用精简版(7.0.1.32)也照过,不必照搬抓包里那一长串。 +// - 抓包里的 comment-source / server-channel / lc / sdk-version 这些请求头可省, +// 真正起作用的是 body 里的同名字段。 +// - 读列表不需要登录态;但 user_digg(我赞过没)只有带 sessionid 才准。 +// 带登录态时,接口会把「自己打的分 / 写的书评」从 data_list 里抽出来, +// 单独放进 extra.user_comment(所以列表看着少一条,不是分页 bug)。 +// - do_action / comment/add 这类写操作必须带 sessionid,否则 code=103006「用户未登录」。 +// x-tt-token 单独给不管用(那是 APP 的登录票据,与网页 sessionid 不同源)。 +// +// 请求体的必填字段比看上去多,缺一个就 code=103001「参数错误」: +// comment/list:business_param.book_id + fold_type、comment_source、comment_type、 +// group_id、group_type、server_channel、sort —— 一个都不能少 +// reply/list:business_param.book_id、comment_id、comment_source、comment_type、group_type +// do_action:action_type、comment_type、object_id、object_type(去掉 comment_type 会 502) +// comment/add:business_param.book_id + score、group_id、group_type、comment_type、 +// data_type、commit_source、text、rich_text。抓包里的 shark_param / +// text_feature 是埋点,对照 do_action 的先例按可省处理,只保留结构字段。 + +import { appRootPost } from './app' +import { getSessionId, COOKIE_PERMISSION_HINT } from '../utils/cookie' +import type { + BookComment, + CommentBase, + CommentFilterTag, + CommentListResult, + CommentReply, + CommentSort, + CommentTextExt, + CommentUser, + ReplyListResult, +} from '../types' + +/** comment_type=2 是书评。段评/章评是别的值,这里只做书评 */ +const COMMENT_TYPE = 2 + +/** + * 写书评(comment/add)时 comment_type 用的是 0,而不是读列表的 2。 + * 服务端就是这么不对称的,照抓包填 0,填 2 会归错类。 + */ +const ADD_COMMENT_TYPE = 0 + +/** group_type=1 表示 group_id 是书 id */ +const GROUP_TYPE = 1 + +/** 书评区(comment/list 只认这个值,501/1002 会报参数错误) */ +const SOURCE_BOOK = 1 + +/** 评论详情页,reply/list 打一级评论时用 */ +const SOURCE_COMMENT_REPLY = 501 + +/** 回复详情页,reply/list 打二级回复时用,且必须带 ref_reply_id */ +const SOURCE_REPLY_REPLY = 1002 + +/** comment/list 单页上限。101 起服务端只回 1 条,不报错 */ +export const MAX_COMMENT_COUNT = 100 + +/** do_action 的动作码 */ +export const CommentAction = { + Like: 8, + Unlike: 9, + Dislike: 10, + Undislike: 11, +} as const + +export type CommentAction = (typeof CommentAction)[keyof typeof CommentAction] + +/** 点赞等写操作缺少登录态 */ +export class NotLoggedInError extends Error { + constructor(message = COOKIE_PERMISSION_HINT) { + super(message) + this.name = 'NotLoggedInError' + } +} + +/* ------------------------------- 响应解析 ------------------------------- */ + +function str(v: unknown): string { + return typeof v === 'string' ? v : v == null ? '' : String(v) +} + +function int(v: unknown): number { + const n = Number(v) + return Number.isFinite(n) ? Math.trunc(n) : 0 +} + +/** + * 头衔名。接口把它塞成内嵌 JSON 字符串: + * intro: '{"title_name":"vip","title_id":"vip","icon_type":1}' + * 解析失败就退回 zh_title。 + */ +function titleName(raw: any): string { + if (typeof raw?.intro === 'string' && raw.intro) { + try { + const parsed = JSON.parse(raw.intro) + const name = str(parsed?.title_name) + if (name) return name + } catch { + // 服务端改了格式,用下面的兜底 + } + } + return str(raw?.zh_title || raw?.en_title) +} + +function parseUser(raw: any): CommentUser { + const base = raw?.base_info ?? {} + const tag = raw?.user_tag ?? {} + return { + user_id: str(base.user_id || raw?.user_id), + name: str(base.user_name), + avatar: str(base.user_avatar), + description: str(base.description), + is_author: Boolean(tag.is_author), + // 蓝 V 也算会员标记,展示上不区分 + is_vip: Boolean(tag.is_vip || tag.is_blue_vip), + titles: Array.isArray(tag.user_title_info) + ? tag.user_title_info.map(titleName).filter(Boolean) + : [], + } +} + +function parseExts(raw: any): CommentTextExt[] { + if (!Array.isArray(raw)) return [] + const out: CommentTextExt[] = [] + for (const e of raw) { + const text = str(e?.text) + if (!text) continue + out.push({ + start: int(e?.s), + end: int(e?.e), + text, + type: int(e?.tp), + uri: str(e?.uri) || undefined, + }) + } + // 服务端未保证有序,渲染时要按位置切片 + return out.sort((a, b) => a.start - b.start) +} + +/** + * 评论与回复的公共部分。 + * + * 注意大小写:一级评论用 `common`,回复用 `Common`(服务端就是这么不一致的), + * 两边都要试。 + */ +function parseBase(raw: any, id: string): CommentBase { + const common = raw?.common ?? raw?.Common ?? {} + const content = common.content ?? {} + const stat = raw?.stat ?? {} + const action = raw?.user_action ?? {} + return { + id, + text: str(content.text), + exts: parseExts(content.text_exts), + create_time: int(common.create_timestamp), + user: parseUser(common.user_info), + digg_count: int(stat.digg_count), + reply_count: int(stat.reply_count), + user_digg: Boolean(action.user_digg), + user_disagree: Boolean(action.user_disagree), + } +} + +function parseComment(raw: any): BookComment | null { + const id = str(raw?.comment_id) + if (!id) return null + const expand = raw?.expand ?? {} + const action = raw?.user_action ?? {} + + const addition = raw?.addition_comment + const additionId = str(addition?.comment_id) + + return { + ...parseBase(raw, id), + score: int(expand.score), + score_text: str(expand.score_suffix_text), + author_stick: Boolean(action.is_author_stick), + addition: additionId ? parseBase(addition, additionId) : undefined, + preview_reply_count: int(expand.reply_show_count), + } +} + +function parseReply(raw: any): CommentReply | null { + const id = str(raw?.reply_id) + if (!id) return null + return { + ...parseBase(raw, id), + to_comment_id: str(raw?.reply_to_comment_id), + to_reply_id: str(raw?.reply_to_reply_id), + sub: Array.isArray(raw?.sub_reply) + ? raw.sub_reply.map(parseReply).filter((r: CommentReply | null): r is CommentReply => !!r) + : [], + } +} + +function parseTags(raw: any): CommentFilterTag[] { + if (!Array.isArray(raw)) return [] + return raw + .map(t => ({ + tag_id: str(t?.tag_id), + tag_name: str(t?.tag_name), + count: int(t?.count), + })) + .filter(t => t.tag_id && t.tag_name) +} + +/* ------------------------------- 请求封装 ------------------------------- */ + +/** 读接口。带上 sessionid 才能拿到 user_digg,读不到就匿名请求 */ +async function commentPost(path: string, body: unknown, requireLogin = false): Promise { + const headers: Record = {} + const session = await getSessionId() + if (session.value) { + headers['Cookie'] = `sessionid=${session.value}` + } else if (requireLogin) { + throw new NotLoggedInError() + } + + const res = await appRootPost(path, JSON.stringify(body), undefined, headers) + let j: any + try { + j = res.json() + } catch { + throw new Error(`书评接口返回了非 JSON(HTTP ${res.status})`) + } + // 103006 只可能出现在写操作上,读接口不校验登录 + if (j?.code === 103006) throw new NotLoggedInError() + if (j?.code !== 0) { + throw new Error(`书评接口失败(code=${j?.code}): ${j?.message ?? '未知错误'}`) + } + return j.data +} + +export interface CommentListOptions { + /** 1=最热(默认),3=最新 */ + sort?: CommentSort + /** 单页条数,上限 100 */ + count?: number + /** 上一页返回的 cursor,首页不传 */ + cursor?: string + /** 按标签过滤,取 tags 里的 tag_id */ + tagId?: string +} + +/** + * 书评列表。 + * + * @param bookId 书 id + */ +export async function getComments( + bookId: string, + { sort = 1, count = 20, cursor, tagId }: CommentListOptions = {}, +): Promise { + const data = await commentPost(`/novel/commentapi/comment/list/${bookId}/v1`, { + business_param: { + book_id: bookId, + need_count: true, + // fold_type 必填,且只有 1 有效(0 会报参数错误) + fold_type: 1, + ...(tagId ? { tag_id: tagId } : {}), + }, + comment_source: SOURCE_BOOK, + comment_type: COMMENT_TYPE, + count: Math.min(count, MAX_COMMENT_COUNT), + group_id: bookId, + group_type: GROUP_TYPE, + server_channel: 5, + sort, + ...(cursor ? { cursor } : {}), + }) + + const info = data?.common_list_info ?? {} + const list = Array.isArray(data?.data_list) ? data.data_list : [] + const extra = data?.extra ?? {} + return { + comments: list + .map((cell: any) => parseComment(cell?.comment)) + .filter((c: BookComment | null): c is BookComment => !!c), + cursor: str(info.cursor), + has_more: Boolean(info.has_more), + total: int(info.total), + tags: parseTags(extra.filter_tag), + score_text: str(extra.score_text), + score_count: int(extra.score_cnt), + // 登录后自己那条被单独放在 extra.user_comment,形状和列表项里的 comment 一致 + user_comment: parseComment(extra.user_comment), + } +} + +/** + * 打分接口只认这几档:2/4/6/8/10 对应 1~5 星。 + * 实测服务端也收 0(隐藏档,已经有人打过零分),所以 UI 要能显示零星, + * 但主动提交时把星级换算成分请走 starToScore,别自己乘。 + */ +export const REVIEW_SCORE_STEP = 2 +export const MIN_REVIEW_STARS = 1 +export const MAX_REVIEW_STARS = 5 + +/** 星级(1-5)→ 接口分值(2-10)。越界会被夹到合法范围 */ +export function starToScore(stars: number): number { + const s = Math.round(stars) + const clamped = Math.min(MAX_REVIEW_STARS, Math.max(MIN_REVIEW_STARS, s)) + return clamped * REVIEW_SCORE_STEP +} + +/** 接口分值(0-10)→ 星级(0-5)。0 分保留为 0 星,用于回显别人打过的零分 */ +export function scoreToStars(score: number): number { + return Math.round(int(score) / REVIEW_SCORE_STEP) +} + +export interface SubmitReviewOptions { + /** + * 评分。直接传接口分值(0/2/4/6/8/10)。 + * 想用星级请先经 starToScore 换算。默认不改分(沿用当前分)。 + */ + score?: number + /** 评论正文。空串表示只打分不写评 */ + text?: string +} + +/** + * 打分 / 写书评。text 为空只打分,带 text 就是评论。 + * + * 需要登录态(sessionid),否则抛 NotLoggedInError。 + * 成功后 comment/list 会把这条放进 extra.user_comment,可据此回显。 + * + * @param bookId 书 id + * @returns 服务端回存的这条评论 + */ +export async function submitReview( + bookId: string, + { score = 0, text = '' }: SubmitReviewOptions = {}, +): Promise { + const data = await commentPost( + `/novel/commentapi/comment/add/v1/`, + { + business_param: { + book_id: bookId, + score, + has_aigc_content: false, + // 下面这些是 APP 的催更 / 二次确认开关,网页端一律走默认 + from_famous_comment_id: 0, + ignore_urge_rule: false, + is_confirm_request: false, + offset: 0, + read_item_cnt: 0, + video_is_muted: 0, + }, + comment_type: ADD_COMMENT_TYPE, + // 抓包固定值:网页提交场景 + commit_source: 1, + data_type: 2, + group_id: bookId, + group_type: GROUP_TYPE, + // 富文本(@、书名号引用等)网页端暂不支持,只发纯文本 + rich_text: [], + text, + }, + true, + ) + return parseComment(data?.comment_info) +} + +export interface ReplyListOptions { + count?: number + /** 上一页返回的 cursor(回复接口的 cursor 是十进制偏移的字符串) */ + cursor?: string +} + +/** + * 某条评论下的回复。 + * + * @param bookId 书 id,接口要求 + * @param commentId 一级评论 id + */ +export async function getReplies( + bookId: string, + commentId: string, + { count = 20, cursor }: ReplyListOptions = {}, +): Promise { + const data = await commentPost(`/novel/commentapi/reply/list/${commentId}/v1`, { + business_param: { book_id: bookId, need_count: true }, + comment_id: commentId, + comment_source: SOURCE_COMMENT_REPLY, + comment_type: COMMENT_TYPE, + count, + group_id: bookId, + group_type: GROUP_TYPE, + // cursor 必须是字符串,传数字直接 HTTP 400 + ...(cursor ? { cursor: String(cursor) } : {}), + }) + return toReplyResult(data) +} + +/** + * 某条回复下的二级回复。 + * + * 服务端要求 ref_reply_id(任取一条已知子回复的 id), + * 缺了或填 '0' 都会报参数错误。若手上没有子回复 id, + * 可以退回用 getReplies 打这条回复的 id —— source 501 同样能列出子回复, + * 只是父级会以 `comment` 而不是 `reply` 的形状返回。 + * + * @param refReplyId 已知的一条子回复 id + */ +export async function getSubReplies( + bookId: string, + replyId: string, + refReplyId: string, + { count = 20, cursor }: ReplyListOptions = {}, +): Promise { + const data = await commentPost(`/novel/commentapi/reply/list/${replyId}/v1`, { + business_param: { book_id: bookId, need_count: true, ref_reply_id: refReplyId }, + comment_id: replyId, + comment_source: SOURCE_REPLY_REPLY, + comment_type: COMMENT_TYPE, + count, + group_id: bookId, + group_type: GROUP_TYPE, + ...(cursor ? { cursor: String(cursor) } : {}), + }) + return toReplyResult(data) +} + +function toReplyResult(data: any): ReplyListResult { + const info = data?.comment_list_info ?? {} + const list = Array.isArray(data?.reply_list) ? data.reply_list : [] + return { + replies: list + .map(parseReply) + .filter((r: CommentReply | null): r is CommentReply => !!r), + cursor: str(info.cursor), + has_more: Boolean(info.has_more), + total: int(info.total), + } +} + +/** + * 点赞 / 取消 / 点踩 / 取消踩。评论和回复都用这一个接口,object_type 固定 2。 + * + * 需要登录态。读不到 sessionid 时抛 NotLoggedInError, + * 调用方应把 COOKIE_PERMISSION_HINT 提示给用户。 + * + * @param objectId 评论 id 或回复 id + */ +export async function doCommentAction(objectId: string, action: CommentAction): Promise { + await commentPost( + '/novel/commentapi/comment/do_action/v1/', + { + action_type: action, + // 抓包里塞了一堆埋点字段(shark_param 等),实测可以整个留空 + business_param: {}, + comment_type: COMMENT_TYPE, + object_id: objectId, + object_type: 2, + }, + true, + ) +} + +/** 点赞开关。传入当前状态,返回操作后的状态 */ +export async function toggleLike(objectId: string, liked: boolean): Promise { + await doCommentAction(objectId, liked ? CommentAction.Unlike : CommentAction.Like) + return !liked +} + +/** 点踩开关 */ +export async function toggleDislike(objectId: string, disliked: boolean): Promise { + await doCommentAction(objectId, disliked ? CommentAction.Undislike : CommentAction.Dislike) + return !disliked +} diff --git a/src/api/content.ts b/src/api/content.ts index e635a8c..d85da1d 100644 --- a/src/api/content.ts +++ b/src/api/content.ts @@ -80,6 +80,8 @@ export async function getChapter(itemId: string, _retry?: number): Promise await ensureKeyinfo() } const res = await appGet('/reader/full/v', { item_id: itemId, req_type: '1' }) + // appGet 已经处理过「设备被作废」(空响应体会自动换设备重试)。 + // 走到这里还是空的,说明连换设备都没救回来,如实抛错而不是继续重试 const j = res.json()?.data if (!j) { console.warn('Failed to get chapter: ', itemId, ', response: ', res.responseText) @@ -129,6 +131,8 @@ export async function getChapters( req_type: '1', }) + // appGet 已处理过「设备被作废」(空响应体会自动换设备重试); + // 这里还解析失败就是真的坏响应,直接抛给调用方,别退化成「章节全部失败」 const raw = res.json()?.data const entries: Array<[string, any]> = raw && typeof raw === 'object' ? (Array.isArray(raw) diff --git a/src/api/provision.ts b/src/api/provision.ts index 1408162..946a9a7 100644 --- a/src/api/provision.ts +++ b/src/api/provision.ts @@ -2,8 +2,8 @@ // 首次使用时注册新设备并激活会员,之后从本地缓存复用。 import config, { defaultConfig, type DeviceConfig } from '../config' -import { b64decode, b64encode } from '../crypto' -import { read, write } from '../localStorage' +import { b64encode } from '../crypto' +import { del, read, write } from '../localStorage' import { registerDevice, activatePremium, registerKey } from './device' import { settings } from '../settings' @@ -15,19 +15,31 @@ interface StoredDevice { device_type?: string; device_brand?: string; vip_expire_time?: string; - key?: string; // base64 + /** @deprecated 密钥现在只持久化在 `keyinfo` 里,读取时迁移过去后即删除 */ + key?: string; keyver?: number; } function load(): DeviceConfig | null { const s = read(STORE_KEY) as StoredDevice | null if (!s?.device_id || !s?.install_id) return null + // 旧版本把密钥嵌在 device 里,refreshKey 之后两个副本就失同步了。 + // keyinfo 已存在时它是较新的权威副本(refreshKey 只更新它), + // 不能被 device 里过期的旧密钥覆盖回去;不存在时迁移过去。 + // 迁移完删掉 device.key,之后密钥只有一个持久化副本 + if (s.key) { + if (!read('keyinfo')) { + write('keyinfo', { key: s.key, keyver: s.keyver }) + } + delete s.key + delete s.keyver + write(STORE_KEY, s) + } return { device_id: s.device_id, install_id: s.install_id, device_type: s.device_type, device_brand: s.device_brand, - key_info: s.key ? { key: b64decode(s.key), keyver: s.keyver } : undefined, } } @@ -39,10 +51,6 @@ function save(c: DeviceConfig, vipExpireTime?: string): void { device_brand: c.device_brand, vip_expire_time: vipExpireTime, } - if (c.key_info?.key) { - s.key = b64encode(c.key_info.key) - s.keyver = c.key_info.keyver - } write(STORE_KEY, s) } @@ -63,6 +71,11 @@ export async function provisionDevice(): Promise { } config.currentConfig = c save(c, vipExpireTime) + // content.ts 的 ensureKeyinfo 读的是这个键,不同步会拿到上一台设备的密钥 + write('keyinfo', { + key: b64encode(keyInfo.key!), + keyver: keyInfo.keyver, + }) return c } @@ -101,3 +114,78 @@ export async function ensureDevice(): Promise { return defaultConfig } } + +/** + * 正在进行的替换。并发请求共享同一次, + * 否则一屏书架十几个请求同时收到空响应,会一口气注册十几台设备。 + */ +let replaceInflight: Promise | null = null + +/** + * 换完之后还没有任何请求成功过。 + * + * 刚换的设备立刻又收到空响应,说明问题不在设备本身(网络被拦、接口整体故障、 + * 出口 IP 被限),继续换只是白注册。等到有请求真的成功再解锁。 + */ +let unverified = false + +/** + * 记录「设备是工作的」。 + * + * 只要网关给出了非空响应就算 —— 哪怕业务码是错的(书不存在之类), + * 也证明它认这台设备。由 api/app 在每次拿到非空响应时调用。 + */ +export function markDeviceHealthy(): void { + unverified = false +} + +/** + * 换一台设备。 + * + * 设备被服务端作废后,所有 /reading/* 接口都返回 HTTP 200 + 空响应体 + * (`content-length: 0`,一个字节都没有),没有错误码可判断。实测重新注册密钥 + * 救不回来(registerkey 对被拒设备照样回 code:0 和一个 key,但正文仍是空), + * 必须整台重注册,注册后立刻就能正常取正文。 + * + * 作废并不是按闲置时间来的:实测自动注册的设备和内置那台(4-5 月注册) + * 都还活着。所以这里不限次数,只要求两次替换之间至少有一个请求成功过 —— + * 真出问题就换,问题不在设备就不白折腾。 + * + * 手填了设备的用户不动,那是他们自己指定的,替换只会让人困惑。 + * + * @returns 换成功返回新设备,放弃时返回 null + */ +export function replaceDevice(): Promise { + if (replaceInflight) return replaceInflight + replaceInflight = doReplace().finally(() => { + replaceInflight = null + }) + return replaceInflight +} + +async function doReplace(): Promise { + if (unverified) { + console.warn( + '[fqa:device] 刚换过设备仍然收到空响应,问题多半不在设备(检查网络代理是否拦了 reading.snssdk.com)', + ) + return null + } + if (settings.deviceId.trim() && settings.installId.trim()) { + console.warn('[fqa:device] 当前是手填设备,不自动替换。若接口持续失败,请到设置里清空或改用自动注册') + return null + } + // 注册失败时也保持上锁:否则每个失败请求都会再打一次注册接口 + unverified = true + console.warn('[fqa:device] 当前设备已被服务端作废,正在注册新设备…') + try { + // 旧设备的密钥跟着旧 device_id 绑定,留着会让 ensureKeyinfo 命中错的缓存 + del('keyinfo') + config.currentConfig.key_info = undefined + const c = await provisionDevice() + console.log('[fqa:device] 已换到新设备:', c.device_id) + return c + } catch (e) { + console.error('[fqa:device] 注册新设备失败:', e) + return null + } +} diff --git a/src/assets/audiobook.css b/src/assets/audiobook.css new file mode 100644 index 0000000..49298b3 --- /dev/null +++ b/src/assets/audiobook.css @@ -0,0 +1,360 @@ +/* 听书悬浮栏与音色弹窗。 + * 暗色跟随页面的 div.muye-reader-dark(与 userHook 的判定一致)。 */ + +.fqa-audio-root { + --fqa-audio-bg: #fff; + --fqa-audio-text: #1f2329; + --fqa-audio-sub: #8f959e; + --fqa-audio-hover: rgba(31, 35, 41, 0.06); + --fqa-audio-border: rgba(31, 35, 41, 0.1); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); + + position: fixed; + left: 20px; + bottom: 20px; + z-index: 2147483000; + font-size: 14px; + color: var(--fqa-audio-text); +} + +/* 页面切到夜间模式时整体反色 */ +.muye-reader-dark .fqa-audio-root, +.fqa-audio-root.fqa-audio-dark { + --fqa-audio-bg: #2b2b2b; + --fqa-audio-text: #b3b3b3; + --fqa-audio-sub: #7a7a7a; + --fqa-audio-hover: rgba(255, 255, 255, 0.08); + --fqa-audio-border: rgba(255, 255, 255, 0.12); + --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); +} + +.fqa-audio-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 8px; + border-radius: 999px; + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); +} + +/* ------------------------------- 旋转封面 ------------------------------- */ + +.fqa-audio-cover { + position: relative; + flex: 0 0 auto; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--fqa-audio-hover); + cursor: pointer; +} + +/* + * 只有这一层转。状态图标是 .fqa-audio-state,放在旋转层外面, + * 否则会跟着封面一起转,反而更看不清。 + * 播放/缓冲时转,暂停时停在当前角度(animation-play-state 比移除动画更平滑) + */ +.fqa-audio-disc { + position: absolute; + inset: 0; + border-radius: 50%; + overflow: hidden; + animation: fqa-audio-rotate 8s linear infinite; + animation-play-state: paused; +} + +.fqa-audio-disc.fqa-audio-spin { + animation-play-state: running; +} + +.fqa-audio-disc img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.fqa-audio-cover-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--fqa-audio-sub); + font-size: 18px; +} + +/* 播放/暂停状态。压一层遮罩,浅色封面上也看得清 */ +.fqa-audio-state { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.38); + color: #fff; + transition: background 0.2s ease; +} + +.fqa-audio-cover:hover .fqa-audio-state { + background: rgba(0, 0, 0, 0.55); +} + +/* + * 只给尺寸,不写 fill:paused.svg 里那块透明点击区是 fill="none", + * CSS 的 fill 优先级高于表现属性,会把它填成一个白方块盖住图标。 + * 两个 svg 自身已经用 currentColor,跟着上面的 color 走。 + */ +.fqa-audio-state svg { + width: 18px; + height: 18px; +} + +@keyframes fqa-audio-rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .fqa-audio-disc { animation: none; } +} + +/* -------------------------------- 文字区 -------------------------------- */ + +.fqa-audio-meta { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + max-width: 180px; + line-height: 1.35; +} + +.fqa-audio-title, +.fqa-audio-sub { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-audio-sub { + color: var(--fqa-audio-sub); + font-size: 12px; +} + +/* -------------------------------- 按钮 -------------------------------- */ + +.fqa-audio-btn, +.fqa-audio-expand { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--fqa-audio-text); + cursor: pointer; +} + +.fqa-audio-btn:hover, +.fqa-audio-expand:hover { + background: var(--fqa-audio-hover); +} + +.fqa-audio-close { + font-size: 20px; + line-height: 1; +} + +/* svg 用 currentColor,跟着按钮的 color 走 */ +.fqa-audio-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.fqa-audio-icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +/* 收起后箭头水平翻转朝右 */ +.fqa-audio-icon-flip { + transform: scaleX(-1); +} + +.fqa-audio-expand { + background: var(--fqa-audio-bg); + box-shadow: var(--fqa-audio-shadow); + width: 36px; + height: 36px; +} + +/* 三个点 */ +.fqa-audio-dots, +.fqa-audio-dots::before, +.fqa-audio-dots::after { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; +} + +.fqa-audio-dots { + position: relative; +} + +.fqa-audio-dots::before, +.fqa-audio-dots::after { + content: ''; + position: absolute; + top: 0; +} + +.fqa-audio-dots::before { left: -7px; } +.fqa-audio-dots::after { left: 7px; } + +/* ----------------------------- 段落高亮 ----------------------------- */ + +.fqa-audio-active { + background: var(--web-brand_light, rgba(241, 70, 70, 0.12)); + border-radius: 4px; + transition: background 0.2s ease; +} + +/* ----------------------------- 音色弹窗 ----------------------------- */ + +.fqa-tone-mask { + position: fixed; + inset: 0; + z-index: 2147483002; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} + +.fqa-tone-box { + width: min(420px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 64px)); + display: flex; + flex-direction: column; + padding: 20px; + border-radius: 12px; + background: var(--fqa-audio-bg, #fff); + color: var(--fqa-audio-text, #1f2329); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.24); +} + +.fqa-tone-title { + margin: 0 0 4px; + font-size: 17px; + font-weight: 600; +} + +.fqa-tone-sub { + margin: 0 0 14px; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; +} + +.fqa-tone-list { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.fqa-tone-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.fqa-tone-item:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} + +.fqa-tone-item-active { + border-color: var(--web-brand, #f14646); +} + +.fqa-tone-icon { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; +} + +.fqa-tone-icon-empty { + display: flex; + align-items: center; + justify-content: center; + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); + color: var(--fqa-audio-sub, #8f959e); +} + +.fqa-tone-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.fqa-tone-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; +} + +.fqa-tone-gender { + color: var(--fqa-audio-sub, #8f959e); + font-size: 11px; +} + +.fqa-tone-desc { + overflow: hidden; + color: var(--fqa-audio-sub, #8f959e); + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fqa-tone-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +.fqa-tone-btn { + padding: 6px 16px; + border: 1px solid var(--fqa-audio-border, rgba(31, 35, 41, 0.1)); + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.fqa-tone-btn:hover { + background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); +} diff --git a/src/assets/left.svg b/src/assets/left.svg new file mode 100644 index 0000000..81895fa --- /dev/null +++ b/src/assets/left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/paused.svg b/src/assets/paused.svg new file mode 100644 index 0000000..3b961be --- /dev/null +++ b/src/assets/paused.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/playing.svg b/src/assets/playing.svg new file mode 100644 index 0000000..f5d4e03 --- /dev/null +++ b/src/assets/playing.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/src/assets/script.css b/src/assets/script.css index 6de5757..e28f288 100644 --- a/src/assets/script.css +++ b/src/assets/script.css @@ -233,4 +233,8 @@ .info { width: 100%!important; +} + +.reader-toolbar { + user-select: none; } \ No newline at end of file diff --git a/src/assets/star.svg b/src/assets/star.svg new file mode 100644 index 0000000..08d09f0 --- /dev/null +++ b/src/assets/star.svg @@ -0,0 +1,15 @@ + + + + Layer 1 + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/star_fill.svg b/src/assets/star_fill.svg new file mode 100644 index 0000000..4dd688a --- /dev/null +++ b/src/assets/star_fill.svg @@ -0,0 +1,18 @@ + + + + Layer 1 + + Layer 1 + + + + + + + + + + + + \ No newline at end of file diff --git a/src/audioPanel.ts b/src/audioPanel.ts new file mode 100644 index 0000000..984643c --- /dev/null +++ b/src/audioPanel.ts @@ -0,0 +1,72 @@ +// 听书悬浮栏的挂载/卸载。与 downloadPanel 同构:全局单例。 +// +// 暗色跟随页面:番茄切日夜间只是给 div 加/去 muye-reader-dark 类, +// 没有事件可监听,所以用 MutationObserver 盯着 class 变化。 + +import { createApp, h, type App } from 'vue' +import AudioBar from './views/AudioBar.vue' +import audiobookcss from './assets/audiobook.css?raw' + +const CONTAINER_ID = 'fqa-audio-root' +const STYLE_ID = 'fqa-audio-style' + +let app: App | null = null +let container: HTMLElement | null = null +let themeObserver: MutationObserver | null = null + +function injectStyle(): void { + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = audiobookcss + document.head.appendChild(style) +} + +/** + * 同步暗色。 + * + * 悬浮栏挂在 body 下,拿不到 .muye-reader-dark 的后代选择器, + * 所以把判定结果写成自己的类名。 + */ +function syncTheme(): void { + if (!container) return + const dark = document.querySelector('div.muye-reader-dark') !== null + container.classList.toggle('fqa-audio-dark', dark) +} + +function watchTheme(): void { + if (themeObserver) return + themeObserver = new MutationObserver(syncTheme) + themeObserver.observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ['class'], + }) +} + +/** 挂载悬浮栏。多次调用只生效一次,状态由 controller 驱动 */ +export function initAudioPanel(): void { + if (app) return + injectStyle() + + container = document.createElement('div') + container.id = CONTAINER_ID + document.body.appendChild(container) + syncTheme() + watchTheme() + + app = createApp({ render: () => h(AudioBar) }) + app.config.errorHandler = (err, _instance, info) => { + console.error(`[fqa:audio] Vue error (${info}):`, err) + } + app.mount(container) +} + +export function destroyAudioPanel(): void { + themeObserver?.disconnect() + themeObserver = null + app?.unmount() + app = null + container?.remove() + container = null +} diff --git a/src/audiobook/controller.ts b/src/audiobook/controller.ts new file mode 100644 index 0000000..ee908a9 --- /dev/null +++ b/src/audiobook/controller.ts @@ -0,0 +1,400 @@ +// 听书的状态机。UI 只读这里的响应式状态,所有播放操作都走这里。 +// +// 换音色的关键约定:不同音色的音频时长不一样,所以恢复位置只能按段落, +// 不能按时间。切完之后回到当前段落的开头(时间点表里那一段的 startms), +// 退回段首是可接受的。 + +import { reactive } from 'vue' +import { getBookAvailableTones, getChapterParagraphTimeTag, getPlayInfo } from '../api/audiobook' +import { CencAudioPlayer, type CencPlayerState } from '../player' +import { gmAudioFetch } from '../player/gmFetch' +import type { Tone } from '../types' +import { ParagraphHighlighter } from './paragraph' +import { read, write } from '../localStorage' +import { settings } from '../settings' + +/** 上次选过的音色,下次进听书直接用 */ +const TONE_STORE_KEY = 'audiobook_tone' + +export interface AudiobookState { + /** 悬浮栏是否挂在页面上 */ + open: boolean + /** 收起成一个小箭头 */ + collapsed: boolean + /** 音色选择弹窗 */ + tonePickerOpen: boolean + /** 正在取播放地址 / 缓冲首段 */ + loading: boolean + playing: boolean + /** 封面该不该转:播放中或缓冲中都转 */ + spinning: boolean + tones: Tone[] + toneId: number | null + toneName: string + cover: string + title: string + error: string +} + +export const state = reactive({ + open: false, + collapsed: false, + tonePickerOpen: false, + loading: false, + playing: false, + spinning: false, + tones: [], + toneId: null, + toneName: '', + cover: '', + title: '', + error: '', +}) + +const highlighter = new ParagraphHighlighter() + +let audio: HTMLAudioElement | null = null +let player: CencAudioPlayer | null = null + +/** 当前章节 */ +let itemId = '' +let bookId = '' + +/** 高亮跟随播放进度的定时器。timeupdate 每 250ms 才一次,段落跟不上 */ +let trackTimer: number | null = null + +/** 换音色时记下的段落,等新音频 ready 后跳回去 */ +let pendingTagIndex = -1 + +/** 递增的播放请求序号,用于丢弃过期的异步结果 */ +let playToken = 0 + +/** 正文上的「点段落跳转」监听,关掉听书时要摘掉 */ +let paragraphClickBound = false + +/** + * 点正文段落跳到对应进度。 + * + * 挂在 document 上做事件委托:正文会被整段替换(切章), + * 绑在容器上每次都得重新绑。 + */ +const onParagraphClick = (event: MouseEvent): void => { + if (!state.open || state.toneId === null) return + // 点注释上标之类的交互元素时不抢行为 + const target = event.target + if (target instanceof HTMLElement && target.closest('a, button, sup')) return + const index = highlighter.tagIndexOfNode(target as Node) + if (index < 0) return + const seconds = highlighter.startSecondsOf(index) + if (seconds === null || !audio) return + audio.currentTime = seconds + highlighter.applyTag(index, false) + // 暂停时点段落也直接开始读 + if (!state.playing) void togglePlay() +} + +function bindParagraphClick(): void { + if (paragraphClickBound) return + document.addEventListener('click', onParagraphClick) + paragraphClickBound = true +} + +function unbindParagraphClick(): void { + if (!paragraphClickBound) return + document.removeEventListener('click', onParagraphClick) + paragraphClickBound = false +} + +function ensurePlayer(): CencAudioPlayer { + if (player) return player + audio = document.createElement('audio') + audio.preload = 'none' + // 悬浮栏自己画控件,原生的不要 + audio.style.display = 'none' + document.body.appendChild(audio) + player = new CencAudioPlayer(audio, { + // 音频 CDN 不在页面 CSP 的 connect-src 里,页面 fetch 会被 report-only + // 策略上报到 mon.zijieapi.com。走 GM 通道绕开上报,详见 gmFetch + fetch: gmAudioFetch, + onStateChange: onPlayerState, + onMessage: (message, level) => { + if (level === 'error') { + console.error('[fqa:audio]', message) + state.error = message + } else { + console.log('[fqa:audio]', message) + } + }, + }) + return player +} + +function onPlayerState(playerState: CencPlayerState): void { + state.playing = playerState === 'playing' + // loading/ready 都算在缓冲,封面继续转,避免一卡就停 + state.spinning = playerState === 'playing' || playerState === 'loading' + if (playerState === 'playing') state.error = '' + if (playerState === 'playing') startTracking() + else stopTracking() + if (playerState === 'ended') { + highlighter.clear() + onChapterEnd() + } +} + +/** 网页端的「下一章」按钮。读到最后一章时这个元素会消失 */ +const NEXT_CHAPTER_SELECTOR = 'div.chapter-btn.next' + +/** + * 一章读完。 + * + * 设置成 next 时点一下页面自带的「下一章」——切页会触发 onUrlChange, + * 走 readerHook 的 switchChapter 把音频也接上,不用自己去算下一章是谁。 + */ +function onChapterEnd(): void { + if (settings.audiobookChapterEnd !== 'next') { + state.spinning = false + return + } + const next = document.querySelector(NEXT_CHAPTER_SELECTOR)?.firstChild as HTMLElement | null + if (typeof next?.click !== 'function') { + // 元素不在了就是读到最后一章了 + state.spinning = false + state.error = '已经是最后一章' + return + } + // 切页要一会儿,先让封面继续转着,别闪一下停 + state.spinning = true + const before = itemId + next.click() + // 正常情况下切页会触发 onUrlChange -> switchChapter。 + // 万一站点那个按钮没走 history(或被别的脚本拦了),这里兜一下, + // 否则会一直转圈但没有声音 + unsafeWindow.setTimeout(() => { + if (!state.open || itemId !== before) return + const current = window.location.pathname.split('/').pop()?.substring(0, 19) || '' + if (current && current !== before) { + void switchChapter(current, { cover: state.cover, title: state.title }) + } else { + state.spinning = false + state.error = '自动切章失败' + } + }, 3000) +} + +function startTracking(): void { + if (trackTimer !== null) return + trackTimer = unsafeWindow.setInterval(() => { + if (!audio) return + highlighter.update(audio.currentTime * 1000, settings.audiobookFollow) + }, 120) +} + +function stopTracking(): void { + if (trackTimer === null) return + unsafeWindow.clearInterval(trackTimer) + trackTimer = null +} + +/** 记住的音色。书里没有这个音色时返回 null,让调用方回退到第一个 */ +function storedToneId(): number | null { + const raw = read(TONE_STORE_KEY) + return typeof raw === 'number' ? raw : null +} + +/** + * 打开听书。先取音色列表,然后弹窗让用户选。 + * + * @param chapter 当前章节 item_id + * @param book 当前书 book_id,取音色列表要用 + * @param meta 悬浮栏上显示的封面与标题 + */ +export async function openAudiobook( + chapter: string, + book: string, + meta: { cover: string; title: string }, +): Promise { + itemId = chapter + bookId = book + state.cover = meta.cover + state.title = meta.title + state.error = '' + state.open = true + state.collapsed = false + + if (state.tones.length === 0) { + try { + state.tones = await getBookAvailableTones(bookId) + } catch (e) { + state.error = '取音色列表失败' + console.error('[fqa:audio] 取音色列表失败:', e) + return + } + } + if (state.tones.length === 0) { + state.error = '这本书没有可用音色' + return + } + // 上次用过的音色还在这本书里就直接开播,不每次都问 + const remembered = storedToneId() + if (remembered !== null && state.tones.some(t => t.id === remembered)) { + await selectTone(remembered) + return + } + state.tonePickerOpen = true +} + +/** 选中音色:关掉弹窗并开始播放 */ +export async function selectTone(toneId: number): Promise { + state.tonePickerOpen = false + const tone = state.tones.find(t => t.id === toneId) + state.toneId = toneId + state.toneName = tone?.name ?? '' + write(TONE_STORE_KEY, toneId) + await playCurrent() +} + +/** 重开音色弹窗(悬浮栏上的三个点) */ +export function openTonePicker(): void { + state.tonePickerOpen = true +} + +export function closeTonePicker(): void { + state.tonePickerOpen = false + // 还没选过音色就关掉弹窗,等于放弃听书 + if (state.toneId === null) closeAudiobook() +} + +/** + * 换音色。 + * + * 换之前记下当前段落,新音频 ready 后跳到那一段的开头。 + * 不能沿用时间:同一段在不同音色里的时间点完全不同。 + */ +export async function changeTone(toneId: number): Promise { + if (toneId === state.toneId) { + state.tonePickerOpen = false + return + } + pendingTagIndex = highlighter.currentTagIndex + await selectTone(toneId) +} + +/** 取播放地址 + 时间点表,然后交给播放器 */ +async function playCurrent(): Promise { + const toneId = state.toneId + if (toneId === null || !itemId) return + // 连续切章/切音色时,晚回来的旧请求不能盖掉新的 + const token = ++playToken + const chapter = itemId + state.loading = true + state.spinning = true + state.error = '' + try { + const [contexts, tags] = await Promise.all([ + getPlayInfo(chapter, toneId), + getChapterParagraphTimeTag(chapter, toneId), + ]) + if (token !== playToken) return + const context = contexts.find(c => c.item_id === chapter) ?? contexts[0] + const url = context?.urls[0] + if (!context || !url) { + state.error = '这一章没有音频' + return + } + highlighter.reset(tags) + bindParagraphClick() + + const instance = ensurePlayer() + await instance.start({ url, key: context.key }) + if (token !== playToken) return + + // 换音色回到原段落。start 之后再 seek,让播放器自己去拉对应位置 + if (pendingTagIndex >= 0) { + const tag = tags[pendingTagIndex] + if (tag && audio) { + audio.currentTime = tag.startms / 1000 + highlighter.applyTag(pendingTagIndex, true) + } + pendingTagIndex = -1 + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + state.error = message + console.error('[fqa:audio] 播放失败:', e) + } finally { + state.loading = false + // 失败时别让封面一直转 + if (!state.playing) state.spinning = false + } +} + +/** 点封面:播放 / 暂停 */ +export async function togglePlay(): Promise { + if (!player) return + if (state.playing) { + player.pause() + return + } + // 播完了再点就从头开始 + if (player.currentState === 'ended' && audio) audio.currentTime = 0 + try { + await player.play() + } catch (e) { + console.warn('[fqa:audio] play() 被拒:', e) + } +} + +export function toggleCollapsed(): void { + state.collapsed = !state.collapsed +} + +/** 切章:正文换了,段落索引和音频都要重来 */ +export async function switchChapter(chapter: string, meta: { cover: string; title: string }): Promise { + if (!state.open || state.toneId === null) return + itemId = chapter + state.cover = meta.cover + state.title = meta.title + pendingTagIndex = -1 + await playCurrent() +} + +/** 正文重新插入后段落节点都换了,重新抓一遍 */ +export function refreshParagraphs(): void { + if (state.open) highlighter.refresh() +} + +export function closeAudiobook(): void { + stopTracking() + unbindParagraphClick() + // 让还在飞的 playCurrent 结果作废,否则关掉之后又冒出来一段 + playToken++ + highlighter.clear() + player?.stop() + state.open = false + state.collapsed = false + state.tonePickerOpen = false + state.playing = false + state.spinning = false + state.loading = false + state.error = '' +} + +/** 工具栏那个「听书」按钮:在听就停,没听就开 */ +export function isListening(): boolean { + return state.open +} + +/** 离开阅读页时彻底清理 */ +export function destroyAudiobook(): void { + stopTracking() + unbindParagraphClick() + highlighter.clear() + player?.destroy() + player = null + audio?.remove() + audio = null + state.open = false + state.tones = [] + state.toneId = null +} diff --git a/src/audiobook/index.ts b/src/audiobook/index.ts new file mode 100644 index 0000000..90378f0 --- /dev/null +++ b/src/audiobook/index.ts @@ -0,0 +1,20 @@ +/** + * 听书。 + * + * 用法:readerHook 里点「听书」-> openAudiobook 取音色 -> selectTone 开播。 + * 悬浮栏由 audioPanel 挂载,只读 controller 的响应式 state。 + */ +export { + changeTone, + closeAudiobook, + destroyAudiobook, + openAudiobook, + refreshParagraphs, + selectTone, + state, + switchChapter, + togglePlay, + type AudiobookState, +} from './controller' + +export { ParagraphHighlighter, TITLE_IDX } from './paragraph' diff --git a/src/audiobook/paragraph.ts b/src/audiobook/paragraph.ts new file mode 100644 index 0000000..f8592a4 --- /dev/null +++ b/src/audiobook/paragraph.ts @@ -0,0 +1,193 @@ +// 段落定位:把时间点表和页面上的正文段落对应起来。 +// +// APP 正文里每个段落带 idx,时间点表用同一套 idx 表示「这一段从第几毫秒读到第几毫秒」。 +// 网页端把 idx 留在了外层 上: +// “凭什么爹可以喝花酒,我就不行!” +// 所以定位只看外层 ,不管里面的 blk 怎么切。 + +import type { ParagraphTimeTag } from '../types' + +/** 标题的 idx。时间点表里用这个值表示「正在读标题」 */ +export const TITLE_IDX = 10000 + +/** 脚本接管的正文容器 id,与 readerHook 保持一致 */ +const CONTENT_SELECTOR = '#fqa-reader-content' + +/** 页面原有的章节标题 */ +const TITLE_SELECTOR = 'h1.muye-reader-title' + +/** 当前高亮的段落类名 */ +export const ACTIVE_CLASS = 'fqa-audio-active' + +/** + * 收集正文里所有带 idx 的段落,按 idx 建索引。 + * + * 同一个 idx 理论上只有一个 ,但真出现重复也不丢弃 —— 一起高亮比只亮一半好。 + */ +export function collectParagraphs(): Map { + const map = new Map() + const container = document.querySelector(CONTENT_SELECTOR) + if (!container) return map + for (const node of container.querySelectorAll('p[idx]')) { + const idx = Number(node.getAttribute('idx')) + if (!Number.isFinite(idx)) continue + const list = map.get(idx) + if (list) list.push(node) + else map.set(idx, [node]) + } + return map +} + +/** + * 标题时间点该高亮谁。 + * + * 时间点表里只有一条 is_title 时是普通章节,标题就是页面上那个 h1。 + * 出现多条则说明是「复杂章节」(卷首语、分节小标题等各自成段), + * 光靠 idx=10000 分不出是哪一个,索性都不高亮 —— 与其亮错位置,不如不亮。 + */ +export function titleTarget(tags: ParagraphTimeTag[]): HTMLElement | null { + const titles = tags.filter(t => t.is_title) + if (titles.length !== 1) return null + return document.querySelector(TITLE_SELECTOR) +} + +/** 找出 timeMs 落在哪个时间点区间。找不到返回 -1 */ +export function tagIndexAt(tags: ParagraphTimeTag[], timeMs: number): number { + let low = 0 + let high = tags.length - 1 + let found = -1 + // 时间点表按时间递增,二分找最后一个 startms <= timeMs 的 + while (low <= high) { + const middle = (low + high) >> 1 + if (tags[middle]!.startms <= timeMs) { + found = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + if (found < 0) return -1 + // 落在两段之间的空隙(上一段已读完、下一段还没开始)就保持上一段亮着 + return found +} + +/** + * 高亮某个时间点覆盖的所有段落,并把上一次的高亮清掉。 + * + * 一个时间点可能跨多段(startidx..endidx),所以按区间取。 + */ +export class ParagraphHighlighter { + private paragraphs = new Map() + private tags: ParagraphTimeTag[] = [] + private active: HTMLElement[] = [] + private activeTagIndex = -1 + + /** 换章或正文重新插入后调用,重建索引 */ + reset(tags: ParagraphTimeTag[]): void { + this.clear() + this.tags = tags + this.paragraphs = collectParagraphs() + this.activeTagIndex = -1 + } + + /** 正文 DOM 被替换过(切音色不会换 DOM,但切章会),重新抓一遍段落 */ + refresh(): void { + this.paragraphs = collectParagraphs() + this.activeTagIndex = -1 + } + + get timeTags(): ParagraphTimeTag[] { + return this.tags + } + + /** 当前高亮对应的时间点下标,用于切音色时保持段落 */ + get currentTagIndex(): number { + return this.activeTagIndex + } + + clear(): void { + for (const node of this.active) node.classList.remove(ACTIVE_CLASS) + this.active = [] + } + + /** + * 按播放时间更新高亮。 + * + * @param scroll 是否把高亮段落滚进视口 + * @returns 当前时间点下标,没有变化时返回原值 + */ + update(timeMs: number, scroll: boolean): number { + if (this.tags.length === 0) return -1 + const index = tagIndexAt(this.tags, timeMs) + if (index < 0 || index === this.activeTagIndex) return this.activeTagIndex + this.activeTagIndex = index + this.applyTag(index, scroll) + return index + } + + /** 直接高亮第 index 个时间点,供切音色后恢复位置用 */ + applyTag(index: number, scroll: boolean): void { + const tag = this.tags[index] + if (!tag) return + this.clear() + const targets = tag.is_title + ? [titleTarget(this.tags)].filter((n): n is HTMLElement => n !== null) + : this.rangeTargets(tag.startidx, tag.endidx) + for (const node of targets) node.classList.add(ACTIVE_CLASS) + this.active = targets + if (scroll && targets[0]) { + targets[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + + private rangeTargets(startidx: number, endidx: number): HTMLElement[] { + const targets: HTMLElement[] = [] + for (let idx = startidx; idx <= endidx; idx++) { + const nodes = this.paragraphs.get(idx) + if (nodes) targets.push(...nodes) + } + return targets + } + + /** + * 点段落跳转用:找出这个节点属于哪个时间点。 + * + * 从点击目标往上找最近的 (点到 blk 或行内标签上也能命中), + * 再拿 idx 去时间点表里查覆盖它的那一段。 + */ + tagIndexOfNode(node: Node | null): number { + const paragraph = nearestParagraph(node) + if (paragraph) { + const idx = Number(paragraph.getAttribute('idx')) + if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx) + } + // 点在标题上 + if (node instanceof Node && titleTarget(this.tags)?.contains(node)) { + return this.tags.findIndex(t => t.is_title) + } + return -1 + } + + /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ + tagIndexOfIdx(idx: number): number { + return this.tags.findIndex(t => !t.is_title && idx >= t.startidx && idx <= t.endidx) + } + + /** 第 index 个时间点的起始秒数,供 seek 用 */ + startSecondsOf(index: number): number | null { + const tag = this.tags[index] + return tag ? tag.startms / 1000 : null + } +} + +/** 从任意节点往上找最近的带 idx 的段落 */ +function nearestParagraph(node: Node | null): HTMLElement | null { + let current: Node | null = node + while (current) { + if (current instanceof HTMLElement && current.matches('p[idx]')) return current + // 到正文容器就停,别一路找到 body + if (current instanceof HTMLElement && current.id === 'fqa-reader-content') return null + current = current.parentNode + } + return null +} diff --git a/src/crypto/spade.ts b/src/crypto/spade.ts new file mode 100644 index 0000000..81def53 --- /dev/null +++ b/src/crypto/spade.ts @@ -0,0 +1,53 @@ +import { b64decode } from '.' + +function decodeBase36(c: number): number { + if (c >= 48 && c <= 57) return c - 48; // '0'-'9' + if (c >= 97 && c <= 122) return c - 97 + 10; // 'a'-'z' + return 0xFF; +} + +function bitCount(n: number): number { + n = n - ((n >> 1) & 0x55555555); + n = (n & 0x33333333) + ((n >> 2) & 0x33333333); + return ((n + (n >> 4) & 0xF0F0F0F) * 0x1010101) >> 24; +} + +function decryptSpadeInner(spadeKey: Uint8Array): Uint8Array { + const result = new Uint8Array(spadeKey); + const buff = new Uint8Array(2 + spadeKey.length); + buff.set([0xFA, 0x55], 0); + buff.set(spadeKey, 2); + + for (let i = 0; i < result.length; i++) { + let v = (spadeKey[i]! ^ buff[i]!) - bitCount(i) - 21; + while (v < 0) { v += 0xFF; } + result[i] = v; + } + return result; +} + +function decryptSpade(spadeKeyBytes: Uint8Array): string { + const spadeKeyLen = spadeKeyBytes.length; + if (spadeKeyLen < 3) return ""; + const paddingLen = (spadeKeyBytes[0]! ^ spadeKeyBytes[1]! ^ spadeKeyBytes[2]!) - 48; + if (spadeKeyLen < paddingLen + 2) return ""; + const innerInput = spadeKeyBytes.slice(1, spadeKeyLen - paddingLen); + const tmpBuff = decryptSpadeInner(innerInput); + if (tmpBuff.length === 0) return ""; + const skipBytes = decodeBase36(tmpBuff[0]!); + const decodedMessageLen = spadeKeyLen - paddingLen - 2; + const endIndex = 1 + decodedMessageLen - skipBytes; + if (endIndex > tmpBuff.length) return ""; + const finalBytes = tmpBuff.slice(1, endIndex); + return new TextDecoder("utf-8").decode(finalBytes); +} + +export function decryptSpadeA(spadeAStr: string): string { + try { + const bytes = new Uint8Array(b64decode(spadeAStr)); + return decryptSpade(bytes); + } catch (e) { + console.error("Spade parsing error", e); + return ""; + } +} \ No newline at end of file diff --git a/src/hooks/readerHook.ts b/src/hooks/readerHook.ts index 446dd8b..19363d1 100644 --- a/src/hooks/readerHook.ts +++ b/src/hooks/readerHook.ts @@ -9,6 +9,14 @@ import { cloneElement } from '../utils'; import { fetchArrayBuffer } from '../utils/request'; import { type Book } from '../types' import { decryptComicImage } from '../crypto/content'; +import { initAudioPanel } from '../audioPanel' +import { + closeAudiobook, + openAudiobook, + refreshParagraphs, + state as audioState, + switchChapter, +} from '../audiobook/controller' // import moment from 'moment' let currentBook: Book | null = null @@ -303,6 +311,36 @@ async function insertContent() { } } } + + // 正文换了:段落节点全是新的,正在听书就把音频也切到这一章 + if (audioState.open) { + await switchChapter(itemId, { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) + } else { + refreshParagraphs() + } +} + +/** + * 工具栏「听书」按钮:停止 / 开始听书。 + * + * 已经在听的时候再点就是停止。切音色走左下角悬浮栏,不从这里进。 + */ +async function startAudioPlay(): Promise { + if (audioState.open) { + closeAudiobook() + return + } + const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || '' + if (!itemId) return + initAudioPanel() + // 封面和书名从已取到的书籍信息来;正文还没加载完时退化成空封面 + await openAudiobook(itemId, currentBook?.book_id ?? '', { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) } // let currentBook: Book | null = null @@ -322,6 +360,28 @@ async function onLoad(): Promise { document.head.appendChild(style) } */ + // 切换日夜间的按钮,不管怎么样它的状态都是完全一样的。适合作为模板。 + const toolbar = document.querySelector("div.reader-toolbar > div") + const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)") + if (toolbarButton && toolbar) { + const c = cloneElement(toolbarButton) + c.id = 'fqa-toggle-audiobook' + const listenIcon = document.createElement('span') + listenIcon.textContent = '听' + listenIcon.style.width = '24px' + listenIcon.style.height = '24px' + listenIcon.style.fontSize = '24px' + listenIcon.style.lineHeight = '24px' + listenIcon.classList.add('muyeicon-icon') + listenIcon.classList.add('reader-toolbar-item-icon') + c.firstChild?.replaceWith(listenIcon) + const l = c.lastChild as HTMLDivElement + if (l) { + l.textContent = '听书' + } + c.addEventListener('click', () => void startAudioPlay()) + toolbar.appendChild(c) + } const btns = document.querySelector("div.muye-reader-btns") // single div if (btns) { // TODO: 直接覆写按钮行为,切章节由脚本完成 diff --git a/src/player/CencAudioPlayer.ts b/src/player/CencAudioPlayer.ts new file mode 100644 index 0000000..ca97b05 --- /dev/null +++ b/src/player/CencAudioPlayer.ts @@ -0,0 +1,1083 @@ +import { fetch as pageFetch } from '../config' +import type { Bytes } from './bytes' +import { + cencIvSize, + decryptCencSample, + importCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' +import { + buildMp4Segment, + buildWebmCluster, + codecMime, + extractFlacEntry, + extractSampleEntry, + initSegment, +} from './remux' + +export type CencPlayerState = 'idle' | 'loading' | 'ready' | 'playing' | 'paused' | 'ended' | 'error' | 'destroyed' + +export interface CencAudioSource { + /** 加密音频 MP4 URL。Range 请求不会发送 Referer。 */ + url: string + /** 16 字节 AES-128 key,或 32 位 hex key。 */ + key: string | ArrayBuffer | Uint8Array +} + +export interface CencMediaInfo { + codec: CencCodec + sampleCount: number + durationSeconds: number + timescale: number + channels: number + encryptedBytes: number +} + +export interface CencProgress { + processedSamples: number + totalSamples: number + percent: number + bufferedAhead: number + durationSeconds: number +} + +export interface CencAudioPlayerCallbacks { + onStateChange?: (state: CencPlayerState) => void + onProgress?: (progress: CencProgress) => void + onMessage?: (message: string, level: 'info' | 'warn' | 'error') => void +} + +export interface CencAudioPlayerOptions extends CencAudioPlayerCallbacks { + /** 初次读取的 MP4 头大小,默认 512 KiB。 */ + headBytes?: number + /** MP4 头解析失败时逐次扩大读取,默认最多 8 MiB。 */ + maxHeadBytes?: number + /** + * 每个 Range/MSE media segment 包含的目标秒数,默认 15 秒。 + * Opus/WebM 因时间戳范围限制最高按 30 秒处理。 + */ + segmentSeconds?: number + /** 播放位置前方达到此秒数后暂停续流,默认 40 秒。 */ + maxBufferAheadSeconds?: number + /** 清理播放位置后方的缓冲,只保留此秒数,默认 10 秒。 */ + keepBehindSeconds?: number + /** 并发 AES-CTR 解密数,默认 24。 */ + decryptConcurrency?: number + /** 仅用于测试或替换网络实现;默认使用页面原始 fetch。 */ + fetch?: typeof fetch +} + +interface StreamContext { + url: string + head: Bytes + meta: CencMetadata + key: CryptoKey + counterStart: 0 | 1 + ticks: Float64Array + samplesPerSecond: number + samplesPerSegment: number +} + +const DEFAULT_HEAD_BYTES = 512 * 1024 +const DEFAULT_MAX_HEAD_BYTES = 8 * 1024 * 1024 +const DEFAULT_SEGMENT_SECONDS = 15 +const MAX_WEBM_SEGMENT_SECONDS = 30 +const DEFAULT_MAX_BUFFER_AHEAD = 40 +const DEFAULT_KEEP_BEHIND = 10 +const DEFAULT_DECRYPT_CONCURRENCY = 24 + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(): DOMException { + return new DOMException('播放器会话已取消', 'AbortError') +} + +function isAbort(value: unknown): boolean { + return value instanceof DOMException && value.name === 'AbortError' +} + +/** + * 等 SourceBuffer 处理完手头的 appendBuffer/remove。 + * + * abort 会让上一次操作的 Promise 提前兑现,但浏览器那边还在跑, + * 此时再调 appendBuffer 就会抛 "still processing"。updating 转 false 时 + * 一定会有一次 updateend/abort/error,所以监听事件即可,不用轮询。 + */ +function waitForSourceBufferIdle(sourceBuffer: SourceBuffer, signal: AbortSignal): Promise { + if (!sourceBuffer.updating) return Promise.resolve() + return new Promise(resolve => { + const done = (): void => { + sourceBuffer.removeEventListener('updateend', done) + sourceBuffer.removeEventListener('abort', done) + sourceBuffer.removeEventListener('error', done) + signal.removeEventListener('abort', done) + resolve() + } + sourceBuffer.addEventListener('updateend', done) + sourceBuffer.addEventListener('abort', done) + sourceBuffer.addEventListener('error', done) + // 会话被取消也别一直挂着;调用方随后会自己检查 signal + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * 把 的错误码翻成可读文本,附在 SourceBuffer 报错后面。 + * + * MEDIA_ERR_DECODE 基本等于「喂进去的字节不是合法的音频」—— + * 数据在传输环节被破坏时就是这个。 + */ +function describeMediaError(audio: HTMLAudioElement): string { + const error = audio.error + if (!error) return '' + const names: Record = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE(数据不是合法音频,多半在传输中被破坏)', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED', + } + const name = names[error.code] ?? `code ${error.code}` + return `:${name}${error.message ? ` - ${error.message}` : ''}` +} + +function validatePositive(value: number | undefined, fallback: number, name: string): number { + const result = value ?? fallback + if (!Number.isFinite(result) || result <= 0) throw new Error(`${name} 必须是正数`) + return result +} + +async function mapLimit( + items: readonly T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + const results = new Array(items.length) + let next = 0 + async function consume(): Promise { + while (next < items.length) { + const index = next++ + results[index] = await worker(items[index]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => consume())) + return results +} + +type ByteReader = ReadableStreamDefaultReader + +/** + * 把 fetch 的任意网络 chunk 重新切成播放器要求的 sample 段。 + * 不主动预读:调用方停止 readExactly 后,ReadableStream 的背压会限制继续下载。 + */ +class SequentialByteStream { + private readonly chunks: Bytes[] = [] + private chunkOffset = 0 + private available = 0 + private reader: ByteReader | null + private readonly openReader: (() => Promise) | null + private eof = false + + constructor( + initial: Uint8Array = new Uint8Array(), + reader: ByteReader | null = null, + openReader: (() => Promise) | null = null, + ) { + if (initial.length > 0) { + const copy = new Uint8Array(initial) as Bytes + this.chunks.push(copy) + this.available = copy.length + } + this.reader = reader + this.openReader = openReader + } + + /** 至少缓存 length 字节;流先结束时返回 false。 */ + async bufferAtLeast(length: number): Promise { + while (this.available < length && !this.eof) await this.pull() + return this.available >= length + } + + /** 复制当前全部未消费数据,不移动读取位置。 */ + snapshot(): Bytes { + const result = new Uint8Array(this.available) + let output = 0 + for (let i = 0; i < this.chunks.length; i++) { + const chunk = this.chunks[i]! + const start = i === 0 ? this.chunkOffset : 0 + result.set(chunk.subarray(start), output) + output += chunk.length - start + } + return result as Bytes + } + + async skip(length: number): Promise { + await this.readExactly(length) + } + + async readExactly(length: number): Promise { + if (!Number.isSafeInteger(length) || length < 0) { + throw new Error(`无效的流读取长度: ${length}`) + } + if (length === 0) return new Uint8Array() as Bytes + if (!await this.bufferAtLeast(length)) { + throw new Error(`音频流提前结束(还需 ${length - this.available} 字节)`) + } + + const result = new Uint8Array(length) + let output = 0 + while (output < length) { + const chunk = this.chunks[0]! + const take = Math.min(length - output, chunk.length - this.chunkOffset) + result.set(chunk.subarray(this.chunkOffset, this.chunkOffset + take), output) + output += take + this.chunkOffset += take + this.available -= take + if (this.chunkOffset === chunk.length) { + this.chunks.shift() + this.chunkOffset = 0 + } + } + return result as Bytes + } + + async cancel(): Promise { + this.eof = true + try { + await this.reader?.cancel() + } catch { + // AbortController 通常已经让 reader 进入 errored,取消失败无需再处理。 + } + this.reader = null + } + + private async pull(): Promise { + if (!this.reader && this.openReader) this.reader = await this.openReader() + if (!this.reader) { + this.eof = true + return + } + const { value, done } = await this.reader.read() + if (done) { + this.eof = true + return + } + if (value?.length) { + const copy = new Uint8Array(value) as Bytes + this.chunks.push(copy) + this.available += copy.length + } + } +} + +/** + * CENC 音频流播放器。 + * + * 负责 MP4 分段读取、AES-CTR 解密、WebM/fMP4 重封装和 MSE 缓冲;不创建任何 + * 页面控件。调用方传入一个已有的 HTMLAudioElement,再把它接到自己的界面即可。 + * + * 音频 CDN 明确拒绝 Referer,因此网络层固定使用: + * - 页面原始 fetch(不走被页面脚本改写的 fetch) + * - credentials: omit + * - referrerPolicy: no-referrer + * 不使用 GM_xmlhttpRequest 回退,也不手动添加 Referer。 + */ +export class CencAudioPlayer { + private readonly audio: HTMLAudioElement + private readonly options: Required< + Pick< + CencAudioPlayerOptions, + | 'headBytes' + | 'maxHeadBytes' + | 'segmentSeconds' + | 'maxBufferAheadSeconds' + | 'keepBehindSeconds' + | 'decryptConcurrency' + > + > & CencAudioPlayerCallbacks & { fetch: typeof fetch } + + private state: CencPlayerState = 'idle' + private session = 0 + private streamToken = 0 + private seekToken = 0 + private sessionController: AbortController | null = null + private streamController: AbortController | null = null + private sourceBuffer: SourceBuffer | null = null + private mediaSource: MediaSource | null = null + private objectUrl: string | null = null + private sourceBufferChain: Promise = Promise.resolve() + private context: StreamContext | null = null + private info: CencMediaInfo | null = null + private autoplay = false + private segmentSequence = 0 + + private readonly onSeeking = (): void => { + const context = this.context + if (!context || this.state === 'destroyed' || this.session === 0) return + // 已经进入错误态时,任何 appendBuffer 都会被拒 + // ("The HTMLMediaElement.error attribute is not null.")。 + // 这时重启流只会把同一个错误刷满控制台,直接放弃 + if (this.audio.error) return + const target = this.audio.currentTime + if (!Number.isFinite(target) || this.isTimeBuffered(target)) return + const wasPlaying = !this.audio.paused + void this.restartAt(target, wasPlaying) + } + + private readonly onPlay = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('playing') + } + + private readonly onPause = (): void => { + if (this.context && this.state === 'playing' && !this.audio.ended) this.emitState('paused') + } + + private readonly onEnded = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('ended') + } + + constructor(audio: HTMLAudioElement, options: CencAudioPlayerOptions = {}) { + this.audio = audio + // Window.fetch 不能作为普通对象的方法调用,否则 Edge 会报 Illegal invocation。 + const fetcher = (options.fetch ?? pageFetch).bind(unsafeWindow) + this.options = { + headBytes: validatePositive(options.headBytes, DEFAULT_HEAD_BYTES, 'headBytes'), + maxHeadBytes: validatePositive(options.maxHeadBytes, DEFAULT_MAX_HEAD_BYTES, 'maxHeadBytes'), + segmentSeconds: validatePositive(options.segmentSeconds, DEFAULT_SEGMENT_SECONDS, 'segmentSeconds'), + maxBufferAheadSeconds: validatePositive(options.maxBufferAheadSeconds, DEFAULT_MAX_BUFFER_AHEAD, 'maxBufferAheadSeconds'), + keepBehindSeconds: validatePositive(options.keepBehindSeconds, DEFAULT_KEEP_BEHIND, 'keepBehindSeconds'), + decryptConcurrency: Math.max(1, Math.floor(validatePositive(options.decryptConcurrency, DEFAULT_DECRYPT_CONCURRENCY, 'decryptConcurrency'))), + onStateChange: options.onStateChange, + onProgress: options.onProgress, + onMessage: options.onMessage, + // config.fetch 保存的是页面 fetch 的原始引用,避免被字节 SDK 改写。 + fetch: fetcher, + } + this.audio.addEventListener('seeking', this.onSeeking) + this.audio.addEventListener('play', this.onPlay) + this.audio.addEventListener('pause', this.onPause) + this.audio.addEventListener('ended', this.onEnded) + } + + get currentState(): CencPlayerState { + return this.state + } + + get mediaInfo(): CencMediaInfo | null { + return this.info + } + + get element(): HTMLAudioElement { + return this.audio + } + + /** 加载并开始后台缓冲;autoplay 只表示首次缓冲后尝试调用 audio.play。 */ + async load(source: CencAudioSource, autoplay = false): Promise { + this.ensureAlive() + const session = this.beginSession() + this.autoplay = autoplay + this.emitState('loading') + let initialStream: SequentialByteStream | null = null + + try { + const url = this.validateUrl(source.url) + this.streamController = new AbortController() + initialStream = this.createNetworkStream(url, 0, this.streamController.signal) + let { head, meta } = await this.parseHeadFromStream(initialStream, session) + this.assertSession(session) + const key = await importCencKey(source.key) + this.assertSession(session) + + let counterStart: 0 | 1 = 0 + if (meta.codec === 'opus') { + // counter 探测仍消费同一条 fetch;若前六帧超出初始头,就继续多读一点。 + const probeEnd = meta.mdatStart + meta.offsets[Math.min(6, meta.sampleCount)]! + if (head.length < probeEnd) { + await initialStream.bufferAtLeast(probeEnd) + head = initialStream.snapshot() + } + counterStart = await this.detectCounterStart(meta, head, key) + } + this.assertSession(session) + + const sampleEntry = meta.codec === 'aac' + ? extractSampleEntry(head, meta.mdatStart) + : meta.codec === 'flac' + ? extractFlacEntry(head, meta.mdatStart) + : undefined + const mime = codecMime(meta.codec) + const ctor = this.mediaSourceConstructor() + if (!ctor.isTypeSupported(mime)) throw new Error(`浏览器不支持 MSE 音频格式: ${mime}`) + + const mediaSource = new ctor() + this.mediaSource = mediaSource + this.objectUrl = unsafeWindow.URL.createObjectURL(mediaSource) + const opened = this.waitForSourceOpen(mediaSource, session) + this.audio.src = this.objectUrl + await opened + this.assertSession(session) + this.sourceBuffer = mediaSource.addSourceBuffer(mime) + this.segmentSequence = 0 + const init = initSegment(meta, sampleEntry) + this.emitMessage(`追加 init 段 ${init.byteLength} 字节 (${mime})`, 'info') + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(init)) + }) + this.assertSession(session) + + const ticks = new Float64Array(meta.sampleCount + 1) + for (let i = 0; i < meta.sampleCount; i++) { + ticks[i + 1] = ticks[i]! + meta.durations[i]! + } + const durationSeconds = meta.totalTicks / meta.timescale + const samplesPerSecond = meta.sampleCount / durationSeconds + // WebM SimpleBlock 的相对时间戳是 Int16,Cluster 最长必须小于约 32.7 秒。 + const segmentSeconds = meta.codec === 'opus' + ? Math.min(this.options.segmentSeconds, MAX_WEBM_SEGMENT_SECONDS) + : this.options.segmentSeconds + this.context = { + url, + head, + meta, + key, + counterStart, + ticks, + samplesPerSecond, + samplesPerSegment: Math.max(1, Math.round(samplesPerSecond * segmentSeconds)), + } + this.info = { + codec: meta.codec, + sampleCount: meta.sampleCount, + durationSeconds, + timescale: meta.timescale, + channels: meta.channels, + encryptedBytes: meta.offsets[meta.sampleCount]!, + } + // MP4 头和音频正文来自同一个 fetch;丢掉头部后继续消费现有 reader。 + await initialStream.skip(meta.mdatStart) + this.assertSession(session) + this.emitState('ready') + this.startStream(0, initialStream) + initialStream = null // reader 的所有权已转交给 runStream + return this.info + } catch (error) { + void initialStream?.cancel() + if (this.isCurrent(session) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + throw error + } + } + + /** load(..., true) 的便捷形式。 */ + start(source: CencAudioSource): Promise { + return this.load(source, true) + } + + play(): Promise { + this.ensureAlive() + return this.audio.play().then(() => undefined) + } + + pause(): void { + this.audio.pause() + } + + /** 停止当前流并释放 MSE URL,但保留播放器实例以便再次 load。 */ + stop(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.emitState('idle') + } + + destroy(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.audio.removeEventListener('seeking', this.onSeeking) + this.audio.removeEventListener('play', this.onPlay) + this.audio.removeEventListener('pause', this.onPause) + this.audio.removeEventListener('ended', this.onEnded) + this.emitState('destroyed') + } + + private ensureAlive(): void { + if (this.state === 'destroyed') throw new Error('播放器已销毁') + } + + private beginSession(): number { + this.session++ + this.streamToken++ + this.seekToken++ + this.sessionController?.abort() + this.streamController?.abort() + this.sessionController = new AbortController() + this.streamController = null + this.context = null + this.info = null + this.autoplay = false + this.sourceBuffer = null + this.mediaSource = null + this.sourceBufferChain = Promise.resolve() + this.audio.pause() + if (this.audio.src) { + this.audio.removeAttribute('src') + this.audio.load() + } + if (this.objectUrl) { + unsafeWindow.URL.revokeObjectURL(this.objectUrl) + this.objectUrl = null + } + return this.session + } + + private validateUrl(raw: string): string { + const url = raw.trim() + const parsed = new URL(url) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('音频 URL 必须是 HTTP(S) 地址') + } + return parsed.toString() + } + + private mediaSourceConstructor(): typeof MediaSource { + const win = unsafeWindow as Window & { MediaSource?: typeof MediaSource } + const ctor = win.MediaSource ?? globalThis.MediaSource + if (!ctor) throw new Error('当前浏览器没有 MediaSource 支持') + return ctor + } + + /** + * 把字节搬到页面 realm,再交给 appendBuffer。 + * + * MediaSource 取自 unsafeWindow(页面 realm),而我们构造的 Uint8Array + * 属于用户脚本沙箱 realm。跨 realm 的 ArrayBuffer 传进 appendBuffer 会被拒, + * 表现是 SourceBuffer 立刻抛 error 事件、而 .error 仍是 null + * —— 只看报错完全看不出是 realm 问题。 + * + * 页面 realm 没暴露 Uint8Array 时(少见)就原样返回,交给浏览器自己判断。 + */ + private toPageBuffer(bytes: Uint8Array): BufferSource { + const win = unsafeWindow as Window & { Uint8Array?: Uint8ArrayConstructor } + const PageUint8Array = win.Uint8Array + if (!PageUint8Array || PageUint8Array === Uint8Array) return bytes as Bytes + const copy = new PageUint8Array(bytes.byteLength) as Bytes + copy.set(bytes) + return copy + } + + private async parseHeadFromStream( + stream: SequentialByteStream, + session: number, + ): Promise<{ head: Bytes; meta: CencMetadata }> { + let size = Math.min(this.options.headBytes, this.options.maxHeadBytes) + let lastError: unknown = null + while (size <= this.options.maxHeadBytes) { + const complete = await stream.bufferAtLeast(size) + this.assertSession(session) + const head = stream.snapshot() + try { + return { head, meta: parseCencMetadata(head) } + } catch (error) { + lastError = error + const message = asError(error).message + const incomplete = /未找到|不完整|缺少/.test(message) + if (!incomplete || !complete || size === this.options.maxHeadBytes) throw error + size = Math.min(this.options.maxHeadBytes, size * 2) + } + } + throw lastError instanceof Error ? lastError : new Error('无法解析音频 MP4 头') + } + + private createNetworkStream( + url: string, + start: number, + signal: AbortSignal, + initial: Uint8Array = new Uint8Array(), + ): SequentialByteStream { + return new SequentialByteStream( + initial, + null, + () => this.openAudioReader(url, start, signal), + ) + } + + /** + * 打开一条从指定字节一直到文件末尾的流。 + * 所有 CDN 请求都明确禁止 Referer、Cookie;顺序播放期间只会打开一次。 + */ + private async openAudioReader(url: string, start: number, signal: AbortSignal): Promise { + this.emitMessage(`打开音频流:bytes=${start}-`, 'info') + const response = await this.options.fetch(url, { + method: 'GET', + headers: { Range: `bytes=${start}-` }, + credentials: 'omit', + // 两项都设:policy 禁止浏览器生成 Referer,空 referrer 防止调用方 Request 继承。 + referrer: '', + referrerPolicy: 'no-referrer', + signal, + }) + if (!response.ok && response.status !== 206) { + throw new Error(`音频 CDN 请求失败(HTTP ${response.status})`) + } + if (start > 0 && response.status !== 206) { + throw new Error('音频 CDN 忽略了 Range,无法从拖动位置续流') + } + if (response.body) return response.body.getReader() + + // 极少数 WebView 不暴露 response.body,退回一次性读取,接口语义仍保持一致。 + const bytes = await response.arrayBuffer() + const fallback = new Response(bytes).body + if (!fallback) throw new Error('浏览器不支持流式读取 Response') + return fallback.getReader() + } + + private sessionSignal(session: number): AbortSignal { + this.assertSession(session) + return this.sessionController!.signal + } + + private assertSession(session: number): void { + if (!this.isCurrent(session)) throw abortError() + } + + private isCurrent(session: number): boolean { + return this.session === session && !this.sessionController?.signal.aborted + } + + private async waitForSourceOpen(mediaSource: MediaSource, session: number): Promise { + const signal = this.sessionSignal(session) + await new Promise((resolve, reject) => { + if (mediaSource.readyState === 'open') { + resolve() + return + } + const onOpen = (): void => { + cleanup() + resolve() + } + const onError = (): void => { + cleanup() + reject(new Error('MediaSource 打开失败')) + } + const onAbort = (): void => { + cleanup() + reject(abortError()) + } + const cleanup = (): void => { + mediaSource.removeEventListener('sourceopen', onOpen) + mediaSource.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + } + mediaSource.addEventListener('sourceopen', onOpen, { once: true }) + mediaSource.addEventListener('error', onError, { once: true }) + signal.addEventListener('abort', onAbort, { once: true }) + }) + } + + private enqueueSourceBuffer( + session: number, + operation: (sourceBuffer: SourceBuffer) => void, + ): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer) return Promise.reject(new Error('SourceBuffer 尚未创建')) + const signal = this.sessionSignal(session) + const run = async (): Promise => { + if (signal.aborted) throw abortError() + // 关键:上一次操作可能因为 abort/error 提前兑现了 Promise, + // 但浏览器仍在处理那次 appendBuffer/remove。这时直接下一个操作会抛 + // "This SourceBuffer is still processing an 'appendBuffer' or 'remove' + // operation.",所以必须等它真的空闲,不能只靠 Promise 链的顺序。 + await waitForSourceBufferIdle(sourceBuffer, signal) + if (signal.aborted) throw abortError() + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) return + settled = true + sourceBuffer.removeEventListener('updateend', onUpdateEnd) + sourceBuffer.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + if (error) reject(error) + else resolve() + } + const onUpdateEnd = (): void => finish() + // SourceBuffer 的 error 事件不带原因。注意 MSE 规范里这个事件 + // 先于媒体元素的错误传播,所以此刻 audio.error 常常还是 null, + // 不能据此断定「不是解码问题」。真实错误码要等一轮事件循环, + // 由下面 catch 里的 reportAppendFailure 补报 + const onError = (): void => finish(new Error('SourceBuffer 更新失败')) + const onAbort = (): void => finish(abortError()) + sourceBuffer.addEventListener('updateend', onUpdateEnd) + sourceBuffer.addEventListener('error', onError) + signal.addEventListener('abort', onAbort, { once: true }) + try { + operation(sourceBuffer) + } catch (error) { + finish(asError(error)) + } + }) + } + const next = this.sourceBufferChain.catch(() => undefined).then(run) + this.sourceBufferChain = next + return next + } + + private startStream(startSample: number, existingStream?: SequentialByteStream): void { + const context = this.context + if (!context || this.state === 'destroyed') return + if (!existingStream) { + this.streamController?.abort() + this.streamController = new AbortController() + } + const controller = this.streamController + if (!controller) return + const token = ++this.streamToken + void this.runStream( + context, + this.session, + token, + startSample, + controller.signal, + existingStream, + ) + } + + private async runStream( + context: StreamContext, + session: number, + token: number, + startSample: number, + signal: AbortSignal, + existingStream?: SequentialByteStream, + ): Promise { + let sample = startSample + let first = true + let stream: SequentialByteStream | null = existingStream ?? null + try { + stream ??= this.createSampleStream(context, startSample, signal) + while (this.isStreamCurrent(session, token, signal) && sample < context.meta.sampleCount) { + await this.waitForRoom(session, token, signal) + if (!this.isStreamCurrent(session, token, signal)) return + const endSample = Math.min(context.meta.sampleCount, sample + context.samplesPerSegment) + const byteLength = context.meta.offsets[endSample]! - context.meta.offsets[sample]! + const encrypted = await stream.readExactly(byteLength) + if (!this.isStreamCurrent(session, token, signal)) return + const plain = await this.decryptRange(context, encrypted, sample, endSample) + if (!this.isStreamCurrent(session, token, signal)) return + + const segment = context.meta.codec === 'opus' + ? buildWebmCluster( + Math.round(context.ticks[sample]! * 1000 / context.meta.timescale), + plain.map((_, index) => Math.round( + context.ticks[sample + index]! * 1000 / context.meta.timescale, + ) - Math.round(context.ticks[sample]! * 1000 / context.meta.timescale)), + plain, + ) + : buildMp4Segment( + context.ticks[sample]!, + plain.map((data, index) => ({ + data, + duration: context.meta.durations[sample + index]!, + })), + ++this.segmentSequence, + ) + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(segment)) + }) + if (first) { + // 首段最容易暴露数据问题:把解密后的特征打出来, + // 便于判断是取流坏了还是封装坏了 + const toc = plain[0]?.[0] + this.emitMessage( + `首段 samples=${sample}..${endSample} 密文${encrypted.byteLength}B ` + + `段长${segment.byteLength}B TOC=0x${(toc ?? 0).toString(16)}`, + 'info', + ) + } + if (!this.isStreamCurrent(session, token, signal)) return + await this.evictBehind(session) + + sample = endSample + this.emitProgress({ + processedSamples: sample, + totalSamples: context.meta.sampleCount, + percent: Math.round(sample / context.meta.sampleCount * 100), + bufferedAhead: this.bufferedAhead(), + durationSeconds: context.meta.totalTicks / context.meta.timescale, + }) + if (first && this.autoplay) { + first = false + void this.audio.play().catch(() => { + this.emitMessage('自动播放被浏览器拦截,请点击音频控件播放', 'warn') + }) + } else { + first = false + } + } + if (this.isStreamCurrent(session, token, signal)) { + // 缓冲到结尾必须通知 MediaSource 收口:不调 endOfStream 的话 + // duration 一直是 Infinity, 永远到不了结尾, + // ended 事件不触发(表现为播到最后还显示播放中但没声音, + // 自动切章也就不会发生)。真正的「播完」仍由 ended 事件上报。 + await this.finishStream(session, sample) + this.emitMessage('音频已缓冲到结尾', 'info') + } + } catch (error) { + if (this.isStreamCurrent(session, token, signal) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + } finally { + void stream?.cancel() + } + } + + /** + * 补报媒体元素的错误码。 + * + * SourceBuffer 的 error 事件先于媒体元素错误传播,同步读 audio.error + * 大概率是 null。让出一轮事件循环再读,才能拿到真正的 MediaError。 + */ + private async reportAppendFailure(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) + const detail = describeMediaError(this.audio) + if (detail) this.emitMessage(`媒体元素错误${detail}`, 'error') + } + + private isStreamCurrent(session: number, token: number, signal: AbortSignal): boolean { + return this.isCurrent(session) && this.streamToken === token && !signal.aborted + } + + /** + * 全部样本都送进 SourceBuffer 后收口 MediaSource。 + * + * 只有真的跑到最后一个样本才收口。seek 之后的流也会走到循环末尾, + * 但那时前面还有没缓冲的区间,提前 endOfStream 会把 duration 定在错的位置。 + */ + private async finishStream(session: number, lastSample: number): Promise { + const context = this.context + const mediaSource = this.mediaSource + if (!context || !mediaSource) return + if (lastSample < context.meta.sampleCount) return + if (mediaSource.readyState !== 'open') return + // 等 append 队列排空:还在 updating 时调 endOfStream 会抛 InvalidStateError。 + // 不能用 enqueueSourceBuffer 塞个空操作 —— 它靠 updateend 兑现, + // 空操作不产生事件,会永远挂住 + await this.sourceBufferChain.catch(() => undefined) + if (!this.isCurrent(session)) return + if (this.sourceBuffer) { + await waitForSourceBufferIdle(this.sourceBuffer, this.sessionSignal(session)) + } + if (!this.isCurrent(session) || mediaSource.readyState !== 'open') return + try { + mediaSource.endOfStream() + } catch (error) { + // 收口失败不影响已经缓冲好的部分,照常播,只是没有 ended + this.emitMessage(`标记音频结尾失败: ${asError(error).message}`, 'warn') + } + } + + private async waitForRoom( + session: number, + token: number, + signal: AbortSignal, + ): Promise { + while ( + this.isStreamCurrent(session, token, signal) && + this.bufferedAhead() >= this.options.maxBufferAheadSeconds + ) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + if (!this.isStreamCurrent(session, token, signal)) throw abortError() + } + + /** + * seek 后的新流。目标仍在已保存的 MP4 头内时先复用内存字节,读完再从 + * head.length 开一条网络流;否则直接从目标 sample 的绝对偏移续传。 + */ + private createSampleStream( + context: StreamContext, + startSample: number, + signal: AbortSignal, + ): SequentialByteStream { + const start = context.meta.mdatStart + context.meta.offsets[startSample]! + if (start < context.head.length) { + return this.createNetworkStream( + context.url, + context.head.length, + signal, + context.head.slice(start), + ) + } + return this.createNetworkStream(context.url, start, signal) + } + + private async decryptRange( + context: StreamContext, + encrypted: Uint8Array, + startSample: number, + endSample: number, + ): Promise { + const ivSize = cencIvSize(context.meta) + const jobs = [] as Array<{ iv: Uint8Array; ciphertext: Uint8Array }> + for (let i = startSample; i < endSample; i++) { + const relative = context.meta.offsets[i]! - context.meta.offsets[startSample]! + const size = context.meta.sizes[i]! + jobs.push({ + iv: context.meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext: encrypted.slice(relative, relative + size), + }) + } + return mapLimit(jobs, this.options.decryptConcurrency, job => decryptCencSample( + context.key, + job.iv, + job.ciphertext, + context.counterStart, + )) + } + + private async detectCounterStart( + meta: CencMetadata, + head: Bytes, + key: CryptoKey, + ): Promise<0 | 1> { + const count = Math.min(6, meta.sampleCount) + if (count === 0) return 0 + const start = meta.mdatStart + meta.offsets[0]! + const end = meta.mdatStart + meta.offsets[count]! - 1 + if (end >= head.length) throw new Error('Opus counter 探测数据不完整') + const encrypted = head.slice(start, end + 1) + const ivSize = cencIvSize(meta) + for (const candidate of [0, 1] as const) { + const firstBytes = new Set() + for (let i = 0; i < count; i++) { + const relative = meta.offsets[i]! + const ciphertext = encrypted.slice(relative, relative + meta.sizes[i]!) + const plain = await decryptCencSample( + key, + meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext, + candidate, + ) + if (plain.length > 0) firstBytes.add(plain[0]!) + } + if (firstBytes.size === 1) { + this.emitMessage(`CENC counter 低位起始=${candidate}`, 'info') + return candidate + } + } + this.emitMessage('CENC counter 未收敛,按 0 继续', 'warn') + return 0 + } + + private async evictBehind(session: number): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer || !sourceBuffer.buffered.length) return + const current = this.audio.currentTime + if (current <= this.options.keepBehindSeconds + 5) return + const start = sourceBuffer.buffered.start(0) + const end = current - this.options.keepBehindSeconds + if (start < end) { + try { + await this.enqueueSourceBuffer(session, buffer => buffer.remove(start, end)) + } catch { + // 清理失败不影响继续播放,下一段仍可能触发浏览器自己的回收。 + } + } + } + + private async restartAt(target: number, wasPlaying: boolean): Promise { + const context = this.context + if (!context) return + if (this.audio.error) { + this.emitMessage('播放元素已进入错误态,无法跳转,请重新开始听书', 'warn') + return + } + const session = this.session + const seek = ++this.seekToken + this.streamToken++ + this.streamController?.abort() + + let low = 0 + let high = context.meta.sampleCount - 1 + let index = 0 + const targetTicks = target * context.meta.timescale + while (low <= high) { + const middle = (low + high) >> 1 + if (context.ticks[middle]! <= targetTicks) { + index = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + const preSamples = Math.max(1, Math.round(context.samplesPerSecond * 2)) + const startSample = Math.max(0, index - preSamples) + this.emitMessage(`跳转到 ${target.toFixed(1)} 秒`, 'info') + + // 先让上一条流排队中的 append 走完,再读 buffered。 + // 否则 remove 的范围是旧的,而且紧接着的 append 会撞上还在处理的操作 + await this.sourceBufferChain.catch(() => undefined) + if (seek !== this.seekToken || !this.isCurrent(session)) return + + const bufferedEnd = this.sourceBuffer?.buffered.length + ? this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) + : 0 + if (bufferedEnd > 0) { + try { + await this.enqueueSourceBuffer(session, sourceBuffer => sourceBuffer.remove(0, bufferedEnd + 0.5)) + } catch { + // 后续 append 会给出更具体的错误;这里不让一次清理失败阻断 seek。 + } + } + if (seek !== this.seekToken || !this.isCurrent(session)) return + // 已经 endOfStream 过时 readyState 是 'ended',append 会让它自动回到 'open'; + // 只有 'closed'(SourceBuffer 已被 detach)才真的没救 + if (this.mediaSource && this.mediaSource.readyState === 'closed') { + this.emitMessage('媒体源已关闭,无法跳转', 'warn') + return + } + this.startStream(startSample) + if (wasPlaying) void this.audio.play().catch(() => undefined) + } + + private isTimeBuffered(time: number): boolean { + const ranges = this.sourceBuffer?.buffered + if (!ranges) return false + for (let i = 0; i < ranges.length; i++) { + if (time >= ranges.start(i) - 0.05 && time <= ranges.end(i) + 0.05) return true + } + return false + } + + private bufferedAhead(): number { + const ranges = this.sourceBuffer?.buffered + if (!ranges || !ranges.length) return 0 + const current = this.audio.currentTime + for (let i = 0; i < ranges.length; i++) { + if (current >= ranges.start(i) - 0.05 && current <= ranges.end(i) + 0.05) { + return Math.max(0, ranges.end(i) - current) + } + } + return Math.max(0, ranges.end(ranges.length - 1) - current) + } + + private emitState(state: CencPlayerState): void { + this.state = state + this.options.onStateChange?.(state) + } + + private emitProgress(progress: CencProgress): void { + this.options.onProgress?.(progress) + } + + private emitMessage(message: string, level: 'info' | 'warn' | 'error'): void { + this.options.onMessage?.(message, level) + } +} diff --git a/src/player/bytes.ts b/src/player/bytes.ts new file mode 100644 index 0000000..219f5ae --- /dev/null +++ b/src/player/bytes.ts @@ -0,0 +1,109 @@ +/** 二进制播放器使用的基础字节工具。 */ + +export type Bytes = Uint8Array + +export function asBytes(value: ArrayBuffer | Uint8Array): Bytes { + if (value instanceof Uint8Array) { + return new Uint8Array(value) as Bytes + } + return new Uint8Array(value) as Bytes +} + +export function concatBytes(...arrays: readonly Uint8Array[]): Bytes { + const length = arrays.reduce((sum, array) => sum + array.byteLength, 0) + const result = new Uint8Array(length) + let offset = 0 + for (const array of arrays) { + result.set(array, offset) + offset += array.byteLength + } + return result as Bytes +} + +export function ascii(text: string): Bytes { + const result = new Uint8Array(text.length) + for (let i = 0; i < text.length; i++) { + result[i] = text.charCodeAt(i) & 0xff + } + return result as Bytes +} + +export function hexToBytes(text: string): Bytes { + const hex = text.replace(/\s+/g, '') + if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) { + throw new Error('无效的十六进制数据') + } + const result = new Uint8Array(hex.length / 2) + for (let i = 0; i < result.length; i++) { + result[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return result as Bytes +} + +export function uintBytes(value: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`无效的无符号整数: ${value}`) + } + if (value === 0) return new Uint8Array([0]) as Bytes + + const result: number[] = [] + let current = value + while (current > 0) { + result.unshift(current & 0xff) + current = Math.floor(current / 256) + } + return new Uint8Array(result) as Bytes +} + +export function be16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setUint16(0, value) + return result as Bytes +} + +export function be32(value: number): Bytes { + const result = new Uint8Array(4) + new DataView(result.buffer).setUint32(0, value >>> 0) + return result as Bytes +} + +export function be64(value: number | bigint): Bytes { + const result = new Uint8Array(8) + new DataView(result.buffer).setBigUint64(0, BigInt(value)) + return result as Bytes +} + +export function findBytes( + buffer: Uint8Array, + fourcc: string, + from = 0, + to = buffer.length - 4, +): number { + if (fourcc.length !== 4) throw new Error(`fourcc 必须是 4 个字符: ${fourcc}`) + const c0 = fourcc.charCodeAt(0) + const c1 = fourcc.charCodeAt(1) + const c2 = fourcc.charCodeAt(2) + const c3 = fourcc.charCodeAt(3) + for (let i = from; i <= to; i++) { + if ( + buffer[i] === c0 && + buffer[i + 1] === c1 && + buffer[i + 2] === c2 && + buffer[i + 3] === c3 + ) return i + } + return -1 +} + +export function strBytes(text: string): Bytes { + return new TextEncoder().encode(text) as Bytes +} + +export function readFourcc(buffer: Uint8Array, offset: number): string { + return String.fromCharCode( + buffer[offset] ?? 0, + buffer[offset + 1] ?? 0, + buffer[offset + 2] ?? 0, + buffer[offset + 3] ?? 0, + ) +} diff --git a/src/player/cenc.ts b/src/player/cenc.ts new file mode 100644 index 0000000..0945b24 --- /dev/null +++ b/src/player/cenc.ts @@ -0,0 +1,287 @@ +import { asBytes, type Bytes, hexToBytes, findBytes, readFourcc } from './bytes' +import { getSubtle } from '../crypto' + +export type CencCodec = 'opus' | 'aac' | 'flac' + +export interface CencMetadata { + codec: CencCodec + sampleCount: number + sizes: Uint32Array + /** 每个采样的 8/16 字节 CENC IV,按 sample 顺序拼接 */ + ivs: Bytes + durations: Uint32Array + timescale: number + channels: number + preSkip: number + /** mdat 的负载起点,相对于 head buffer */ + mdatStart: number + /** 每个 sample 在 mdat 负载中的累计偏移,长度为 sampleCount + 1 */ + offsets: Float64Array + totalTicks: number +} + +export interface CencKey { + key: CryptoKey + /** AES-CTR counter 的低位初值,CENC 常见为 0;某些流使用 1 */ + counterStart: 0 | 1 +} + +interface IsoBox { + type: string + offset: number + size: number + headerSize: number + end: number +} + +const CONTAINERS = new Set([ + 'moov', 'trak', 'mdia', 'minf', 'stbl', 'edts', 'dinf', 'udta', 'mvex', 'meta', +]) + +function readBox(buffer: Uint8Array, offset: number, limit: number): IsoBox | null { + if (offset + 8 > limit) return null + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + let headerSize = 8 + let size: number + + if (size32 === 1) { + if (offset + 16 > limit) return null + const extended = view.getBigUint64(offset + 8) + if (extended > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`MP4 box 过大: ${type}`) + } + size = Number(extended) + headerSize = 16 + } else if (size32 === 0) { + size = limit - offset + } else { + size = size32 + } + + if (size < headerSize || offset + size > limit) return null + return { type, offset, size, headerSize, end: offset + size } +} + +function walkBoxes( + buffer: Uint8Array, + start: number, + end: number, + visit: (box: IsoBox) => void, + depth = 0, +): void { + if (depth > 16) return + let offset = start + while (offset + 8 <= end) { + const box = readBox(buffer, offset, end) + if (!box) return + visit(box) + if (CONTAINERS.has(box.type)) { + // meta 是 FullBox,版本/flags 后才开始嵌套 box。 + const childStart = box.offset + box.headerSize + (box.type === 'meta' ? 4 : 0) + if (childStart < box.end) walkBoxes(buffer, childStart, box.end, visit, depth + 1) + } + offset = box.end + } +} + +function findBox(boxes: readonly IsoBox[], type: string): IsoBox | undefined { + return boxes.find(box => box.type === type) +} + +/** 读取顶层 mdat 的负载起点,即使 mdat 本体不在当前 head buffer 内也能定位。 */ +function findMdatStart(buffer: Uint8Array): number { + let offset = 0 + while (offset + 8 <= buffer.length) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + const headerSize = size32 === 1 ? 16 : 8 + if (type === 'mdat') return offset + headerSize + if (size32 === 0) return -1 + if (size32 === 1) { + if (offset + 16 > buffer.length) return -1 + const size = view.getBigUint64(offset + 8) + if (size > BigInt(Number.MAX_SAFE_INTEGER)) return -1 + offset += Number(size) + } else { + offset += size32 + } + } + return -1 +} + +function parseSampleSizes(box: IsoBox, view: DataView): Uint32Array { + if (box.offset + 20 > box.end) throw new Error('stsz box 不完整') + const sampleSize = view.getUint32(box.offset + 12) + const count = view.getUint32(box.offset + 16) + const sizes = new Uint32Array(count) + if (sampleSize !== 0) { + sizes.fill(sampleSize) + return sizes + } + if (box.offset + 20 + count * 4 > box.end) throw new Error('stsz 采样表不完整') + for (let i = 0; i < count; i++) sizes[i] = view.getUint32(box.offset + 20 + i * 4) + return sizes +} + +function parseDurations(box: IsoBox, sampleCount: number, view: DataView): Uint32Array { + if (box.offset + 16 > box.end) throw new Error('stts box 不完整') + const entryCount = view.getUint32(box.offset + 12) + const durations = new Uint32Array(sampleCount) + let index = 0 + for (let i = 0; i < entryCount; i++) { + const offset = box.offset + 16 + i * 8 + if (offset + 8 > box.end) throw new Error('stts 采样表不完整') + const count = view.getUint32(offset) + const duration = view.getUint32(offset + 4) + for (let j = 0; j < count && index < sampleCount; j++) durations[index++] = duration + } + if (index !== sampleCount) throw new Error(`stts 采样数不一致: ${index}/${sampleCount}`) + return durations +} + +function parseIvs(box: IsoBox, sampleCount: number, view: DataView): Bytes { + if (box.offset + 16 > box.end) throw new Error('senc box 不完整') + const flags = view.getUint32(box.offset + 8) & 0xffffff + if ((flags & 0x02) !== 0) throw new Error('暂不支持带 subsample encryption 的音频') + const count = view.getUint32(box.offset + 12) + if (count !== sampleCount) throw new Error(`senc 采样数不一致: ${count}/${sampleCount}`) + const dataStart = box.offset + 16 + const dataLength = box.end - dataStart + if (count === 0 || dataLength % count !== 0) throw new Error('senc IV 表不完整') + const ivLength = dataLength / count + if (ivLength !== 8 && ivLength !== 16) throw new Error(`不支持的 CENC IV 长度: ${ivLength}`) + return asBytes(new Uint8Array(view.buffer, view.byteOffset + dataStart, dataLength)) +} + +function parseTimescale(box: IsoBox, view: DataView): number { + if (box.offset + 24 > box.end) throw new Error('mdhd box 不完整') + const version = view.getUint8(box.offset + 8) + const offset = version === 1 ? box.offset + 28 : box.offset + 20 + if (offset + 4 > box.end) throw new Error('mdhd timescale 缺失') + const timescale = view.getUint32(offset) + if (timescale === 0) throw new Error('MP4 timescale 为 0') + return timescale +} + +function parseCodec(buffer: Uint8Array, mdatStart: number): Pick { + const end = Math.max(0, Math.min(mdatStart - 1, buffer.length - 4)) + const dOps = findBytes(buffer, 'dOps', 0, end) + if (dOps >= 0) { + const channels = buffer[dOps + 5] ?? 1 + const preSkip = ((buffer[dOps + 6] ?? 0) << 8) | (buffer[dOps + 7] ?? 0) + return { codec: 'opus', channels, preSkip } + } + + // AudioSampleEntry 的 channelcount 位于 type 后 20 字节;加密入口也保留这段布局。 + const encodedEntry = findBytes(buffer, 'enca', 0, end) + const plainEntry = findBytes(buffer, 'mp4a', 0, end) + const entryType = encodedEntry >= 0 ? encodedEntry : plainEntry + const channels = entryType >= 0 && entryType + 22 <= buffer.length + ? new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16(entryType + 20) + : 1 + if (findBytes(buffer, 'dfLa', 0, end) >= 0) { + return { codec: 'flac', channels: channels || 1, preSkip: 0 } + } + if (entryType >= 0) { + return { codec: 'aac', channels: channels || 1, preSkip: 0 } + } + throw new Error('未识别的音频编码(需要 Opus、AAC 或 FLAC)') +} + +/** 解析一份已包含 moov 元数据的 MP4 CENC 头。 */ +export function parseCencMetadata(head: Uint8Array): CencMetadata { + const mdatStart = findMdatStart(head) + if (mdatStart < 0) throw new Error('未找到 mdat,MP4 头可能尚未拉完整') + + const boxes: IsoBox[] = [] + walkBoxes(head, 0, Math.min(mdatStart - 8, head.length), box => boxes.push(box)) + const stsz = findBox(boxes, 'stsz') + const stts = findBox(boxes, 'stts') + const senc = findBox(boxes, 'senc') + const mdhd = findBox(boxes, 'mdhd') + if (!stsz || !stts || !senc || !mdhd) { + throw new Error('CENC MP4 头缺少 stsz/stts/senc/mdhd') + } + + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const sizes = parseSampleSizes(stsz, view) + const durations = parseDurations(stts, sizes.length, view) + const ivs = parseIvs(senc, sizes.length, view) + const timescale = parseTimescale(mdhd, view) + const codec = parseCodec(head, mdatStart) + const offsets = new Float64Array(sizes.length + 1) + for (let i = 0; i < sizes.length; i++) { + offsets[i + 1] = offsets[i]! + sizes[i]! + if (!Number.isSafeInteger(offsets[i + 1])) throw new Error('音频采样数据超过 JavaScript 安全整数范围') + } + + let totalTicks = 0 + for (const duration of durations) { + totalTicks += duration + if (!Number.isSafeInteger(totalTicks)) throw new Error('音频总时长超过 JavaScript 安全整数范围') + } + return { + ...codec, + sampleCount: sizes.length, + sizes, + ivs, + durations, + timescale, + mdatStart, + offsets, + totalTicks, + } +} + +export function cencIvSize(meta: CencMetadata): number { + return meta.ivs.length / meta.sampleCount +} + +export function normalizeCencKey(value: string | ArrayBuffer | Uint8Array): Bytes { + if (typeof value === 'string') { + const bytes = hexToBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 32 位 hex') + return bytes + } + const bytes = asBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 16 字节') + return bytes.slice() as Bytes +} + +export async function importCencKey(value: string | ArrayBuffer | Uint8Array): Promise { + return getSubtle().importKey( + 'raw', + normalizeCencKey(value), + { name: 'AES-CTR' }, + false, + ['decrypt'], + ) +} + +/** 按 CENC 的 8/16 字节 IV 构造 Web Crypto 的 16 字节 counter。 */ +export function makeCencCounter(iv: Uint8Array, counterStart: 0 | 1): Bytes { + if (iv.length !== 8 && iv.length !== 16) throw new Error(`无效的 CENC IV 长度: ${iv.length}`) + const counter = new Uint8Array(16) + counter.set(iv) + if (counterStart === 1) counter[15] = 1 + return counter as Bytes +} + +export async function decryptCencSample( + key: CryptoKey, + iv: Uint8Array, + ciphertext: Uint8Array, + counterStart: 0 | 1, +): Promise { + // TS 6 会把普通 Uint8Array 视为可能由 SharedArrayBuffer 支撑;Web Crypto 不接受它。 + const data = asBytes(ciphertext) + const plain = await getSubtle().decrypt( + { name: 'AES-CTR', counter: makeCencCounter(iv, counterStart), length: 64 }, + key, + data, + ) + return new Uint8Array(plain) as Bytes +} diff --git a/src/player/gmFetch.ts b/src/player/gmFetch.ts new file mode 100644 index 0000000..1c5c4a6 --- /dev/null +++ b/src/player/gmFetch.ts @@ -0,0 +1,191 @@ +// 音频 CDN 的取流通道。 +// +// 为什么不用页面 fetch:番茄网页站的 CSP(report-only)里 connect-src 没有任何 +// *vod.com 域名,音频 CDN 一个都不在白名单上。report-only 不会真拦请求,但每次 +// 都会往 mon.zijieapi.com/monitor_browser/collect/batch/security/ 上报一条违规, +// 等于把听书行为连着 URL 一起报给服务端。所以走 GM_xmlhttpRequest: +// 它在页面 CSP 之外,既不触发上报,也天然不带页面 Referer 和 Cookie。 +// +// 试过换 aid 让服务端返回白名单内的域名,行不通 —— CDN 域名跟着 aid 所属产品走: +// 1967 番茄小说 -> *.fqnovelvod.com 3040 番茄畅听 -> *.novelfmvod.com +// 13/35 头条 -> *.toutiaovod.com 6589 -> *.wkbrowser.com +// 1217/1128/8478/32/596084 -> PARAM_INVALID(不支持听书接口) +// 没有一个落在 connect-src 里,而且签名里的 aid 必须与 query 一致, +// 否则网关直接回 200 空响应体。 + +type AnyResponse = Tampermonkey.Response & { response?: unknown } + +/** + * 用 GM_xmlhttpRequest 实现的、够播放器用的 fetch 子集。 + * + * 只支持播放器实际用到的部分:GET + Range + AbortSignal + 流式 body。 + * + * 关于 responseType 的坑(踩过两次,记在这): + * - `'stream'`:TM 会把 ReadableStream 放进 res.response,但**不是**在 + * readyState 2 那一刻就绪。在 readyState 2 判断 instanceof 永远失败。 + * - `'arraybuffer'`:res.response 只在 readyState 4 才有值,onprogress + * 期间一直是 null,拿不到增量。 + * - `responseText`:绝对不能用来还原二进制。它按字符解码过,>0xFF 的码位 + * 已经丢了原始字节,喂进 MSE 会直接报 SourceBuffer 更新失败。 + * 所以这里两种都试:优先等 'stream',拿不到就退回整段 arraybuffer。 + */ +export function gmAudioFetch( + input: string | URL | Request, + init: RequestInit = {}, +): Promise { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const headers = normalizeHeaders(init.headers) + const signal = init.signal ?? null + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError()) + return + } + + let settled = false + let request: { abort: () => void } | undefined + + const onAbort = (): void => { + try { request?.abort() } catch { /* 尚未开始或已结束 */ } + if (settled) return + settled = true + reject(abortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + + const cleanup = (): void => { + signal?.removeEventListener('abort', onAbort) + } + + const succeed = (res: Tampermonkey.ResponseBase, body: BodyInit | null): void => { + if (settled) return + settled = true + cleanup() + resolve(makeResponse(res, body)) + } + + const fail = (error: Error): void => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + request = GM_xmlhttpRequest({ + method: 'GET', + url, + headers, + responseType: 'stream', + // GM 请求不带页面 Referer;anonymous 同时也不带 Cookie + anonymous: true, + onreadystatechange(res) { + // readyState 3/4 时 TM 才把 stream 挂上来。拿到就立刻交出去, + // 播放器那边就能边下边解密,不必等整段下完 + if (settled || res.readyState < 3) return + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) succeed(res, stream) + }, + onload(res) { + if (settled) return + // 没走成 stream:这时 readyState 已是 4,response 里是完整数据 + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) { + succeed(res, stream) + return + } + const bytes = toBytes(res) + if (!bytes) { + fail(new Error( + 'GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)', + )) + return + } + if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) { + fail(new Error('音频响应为空')) + return + } + succeed(res, bytes as unknown as BodyInit) + }, + onerror(res) { + fail(new Error(`音频 CDN 请求失败:${describe(res)}`)) + }, + ontimeout() { + fail(new Error('音频 CDN 请求超时')) + }, + }) as unknown as { abort: () => void } + }) +} + +function abortError(): DOMException { + return new DOMException('音频请求已取消', 'AbortError') +} + +/** 把 GM 的响应头文本解析成 Headers */ +function parseHeaders(raw: string | undefined): Headers { + const headers = new Headers() + if (!raw) return headers + for (const line of raw.split(/\r?\n/)) { + const colon = line.indexOf(':') + if (colon <= 0) continue + const name = line.slice(0, colon).trim() + const value = line.slice(colon + 1).trim() + if (!name) continue + try { + headers.append(name, value) + } catch { + // 个别响应头名字不合规范(比如带空格),跳过即可 + } + } + return headers +} + +/** + * 用 GM 的状态和响应头拼一个 Response。 + * + * 播放器只读 status / ok / body,不需要还原全部语义。 + * body 为空时给一条空流:206 是合法状态,但 `new Response(null, {status:206})` + * 会抛错(只有 204/304 允许空 body)。 + */ +function makeResponse(res: Tampermonkey.ResponseBase, body: BodyInit | null): Response { + return new Response(body ?? new Uint8Array(), { + status: res.status || 200, + statusText: res.statusText || '', + headers: parseHeaders(res.responseHeaders), + }) +} + +/** + * 从 GM 响应里取出完整字节。 + * + * 只认真正的二进制载荷 —— 见文件头关于 responseText 的说明, + * 拿不到就返回 null 让上层报错,比悄悄喂坏数据好。 + */ +function toBytes(res: Tampermonkey.Response): Uint8Array | null { + const raw = (res as AnyResponse).response + if (raw instanceof ArrayBuffer) return new Uint8Array(raw) + if (raw instanceof Uint8Array) return raw + return null +} + +function normalizeHeaders(init: HeadersInit | undefined): Record { + const out: Record = {} + if (!init) return out + if (init instanceof Headers) { + init.forEach((value, key) => { out[key] = value }) + return out + } + if (Array.isArray(init)) { + for (const [key, value] of init) { + if (key !== undefined && value !== undefined) out[key] = value + } + return out + } + return { ...init } +} + +function describe(res: Tampermonkey.ErrorResponse | Tampermonkey.ResponseBase): string { + if ('error' in res && res.error) return String(res.error) + const status = (res as Tampermonkey.ResponseBase).status + return status ? `HTTP ${status}` : '网络错误' +} diff --git a/src/player/index.ts b/src/player/index.ts new file mode 100644 index 0000000..01ff874 --- /dev/null +++ b/src/player/index.ts @@ -0,0 +1,24 @@ +/** + * CENC 听书播放器。 + * + * 最小用法: + * const player = new CencAudioPlayer(audioElement) + * await player.start({ url, key }) + * // 切章:再次 start;离开听书页:player.destroy() + */ +export { + CencAudioPlayer, + type CencAudioPlayerCallbacks, + type CencAudioPlayerOptions, + type CencAudioSource, + type CencMediaInfo, + type CencPlayerState, + type CencProgress, +} from './CencAudioPlayer' + +export { + normalizeCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' diff --git a/src/player/remux.ts b/src/player/remux.ts new file mode 100644 index 0000000..b419d33 --- /dev/null +++ b/src/player/remux.ts @@ -0,0 +1,260 @@ +import { ascii, be16, be32, be64, concatBytes, findBytes, hexToBytes, strBytes, uintBytes, type Bytes } from './bytes' +import type { CencCodec, CencMetadata } from './cenc' + +function ebmlVint(value: number, length: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0 || length < 1 || length > 8) { + throw new Error(`无效的 EBML VINT: ${value}`) + } + const result = new Uint8Array(length) + result[0] = 0x80 >> (length - 1) + let current = value + for (let i = length - 1; i > 0; i--) { + result[i] = current & 0xff + current = Math.floor(current / 256) + } + const max = Math.pow(2, 7 * length) + if (current >= max) throw new Error(`EBML VINT 溢出: ${value}`) + result[0] |= current + return result as Bytes +} + +/** + * 编码 EBML 元素长度。 + * + * 关键约束:全 1 的 VINT(1 字节的 0xFF、2 字节的 0x7FFF……)在 EBML 里是保留值, + * 表示「长度未知」。所以 2^(7n)-1 这些边界值必须多用一个字节来编, + * 否则解析器会把这个元素当成 unknown-size,从它开始一路吞掉后面的数据。 + * + * 踩过的坑:Opus 帧正好 127 字节时 SimpleBlock 的 payload 长度就是 127, + * 编成 0xFF 之后 Chromium 报 CHUNK_DEMUXER_ERROR_APPEND_FAILED, + * ffmpeg 报 "Unknown-sized element inside parent with finite size", + * 750 帧里只解出 8 帧。 + */ +function ebmlSize(value: number): Bytes { + for (let length = 1; length <= 8; length++) { + // < 2^(7n)-1:留出全 1 这个保留值,等于时进位到下一个长度 + if (value < Math.pow(2, 7 * length) - 1) return ebmlVint(value, length) + } + throw new Error(`EBML size 过大: ${value}`) +} + +function ebmlElement(id: string, payload: Uint8Array): Bytes { + return concatBytes(hexToBytes(id), ebmlSize(payload.length), payload) +} + +function mp4Box(type: string, payload: Uint8Array): Bytes { + return concatBytes(be32(8 + payload.length), ascii(type), payload) +} + +function matrixIdentity(): Bytes { + const matrix = new Uint8Array(36) + const view = new DataView(matrix.buffer) + view.setUint32(0, 0x10000) + view.setUint32(16, 0x10000) + view.setUint32(32, 0x40000000) + return matrix as Bytes +} + +function signedBe16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setInt16(0, value) + return result as Bytes +} + +/** 构造只包含 Opus 音轨的 WebM 初始化段。 */ +export function buildWebmInit(channels: number, preSkip: number, durationSeconds: number): Bytes { + const ebml = ebmlElement('1A45DFA3', concatBytes( + ebmlElement('4286', hexToBytes('01')), + ebmlElement('42F7', hexToBytes('01')), + ebmlElement('42F2', hexToBytes('04')), + ebmlElement('42F3', hexToBytes('08')), + ebmlElement('4282', strBytes('webm')), + ebmlElement('4287', hexToBytes('04')), + ebmlElement('4285', hexToBytes('02')), + )) + const codecDelayNs = Math.round(preSkip * 1e9 / 48000) + const duration = new Uint8Array(8) + new DataView(duration.buffer).setFloat64(0, durationSeconds, false) + const info = ebmlElement('1549A966', concatBytes( + ebmlElement('2AD7B1', uintBytes(1000000)), + ebmlElement('4489', duration as Bytes), + ebmlElement('4D80', strBytes('clearKeyStreamer')), + ebmlElement('5741', strBytes('clearKeyStreamer')), + )) + const sampleRate = new Uint8Array(4) + new DataView(sampleRate.buffer).setFloat32(0, 48000, false) + const audio = ebmlElement('E1', concatBytes( + ebmlElement('B5', sampleRate as Bytes), + ebmlElement('9F', uintBytes(channels)), + )) + const track = ebmlElement('AE', concatBytes( + ebmlElement('D7', hexToBytes('01')), + ebmlElement('73C5', uintBytes(1)), + ebmlElement('83', hexToBytes('02')), + ebmlElement('86', strBytes('A_OPUS')), + ebmlElement('63A2', buildOpusHead(channels, preSkip)), + ebmlElement('56AA', uintBytes(codecDelayNs)), + ebmlElement('56BB', uintBytes(80000000)), + audio, + )) + const tracks = ebmlElement('1654AE6B', track) + // Unknown-size Segment permits later clusters and seek-triggered re-buffering. + return concatBytes(ebml, hexToBytes('18538067'), hexToBytes('01FFFFFFFFFFFFFF'), info, tracks) +} + +function buildOpusHead(channels: number, preSkip: number): Bytes { + const head = new Uint8Array(19) + head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0) + const view = new DataView(head.buffer) + head[8] = 1 + head[9] = channels + view.setUint16(10, preSkip, true) + view.setUint32(12, 48000, true) + view.setInt16(16, 0, true) + head[18] = 0 + return head as Bytes +} + +/** 构造一个包含若干 Opus Block 的 WebM Cluster。 */ +export function buildWebmCluster(baseMs: number, relativeMs: readonly number[], packets: readonly Uint8Array[]): Bytes { + if (relativeMs.length !== packets.length) throw new Error('WebM 时间戳和采样数不一致') + const blocks: Bytes[] = [] + for (let i = 0; i < packets.length; i++) { + const relative = relativeMs[i] + if (relative === undefined || relative < -32768 || relative > 32767) { + throw new Error(`WebM 相对时间戳超出 Int16: ${relative}`) + } + const trackAndFlags = new Uint8Array([0x81, 0x80]) + blocks.push(ebmlElement('A3', concatBytes( + trackAndFlags.slice(0, 1), + signedBe16(relative), + trackAndFlags.slice(1), + packets[i]!, + ))) + } + return ebmlElement('1F43B675', concatBytes( + ebmlElement('E7', uintBytes(baseMs)), + concatBytes(...blocks), + )) +} + +function replaceFourcc(buffer: Bytes, offset: number, type: string): void { + buffer.set(ascii(type), offset) +} + +/** 从原始 MP4 头复制采样入口,并把加密入口改回浏览器可识别的明文入口。 */ +export function extractSampleEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + const plain = findBytes(head, 'mp4a', 0, mdatStart - 1) + const typeOffset = encoded >= 0 ? encoded : plain + if (typeOffset < 4) throw new Error('未找到 AAC 音频采样入口') + const entryOffset = typeOffset - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('AAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + const enca = findBytes(entry, 'enca', 0) + if (enca >= 0) replaceFourcc(entry, enca, 'mp4a') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 从原始 MP4 头复制 FLAC 采样入口,并去掉 CENC 标记。 */ +export function extractFlacEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + if (encoded < 4) throw new Error('未找到 FLAC 加密采样入口') + const entryOffset = encoded - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('FLAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + replaceFourcc(entry, encoded - entryOffset, 'fLaC') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 构造 AAC/FLAC 的 fragmented MP4 初始化段。 */ +export function buildMp4Init(sampleEntry: Uint8Array, timescale: number, totalTicks: number): Bytes { + const totalSeconds = totalTicks / timescale + const matrix = matrixIdentity() + const mvhd = mp4Box('mvhd', concatBytes( + be32(0), be32(0), be32(0), be32(1000), be32(Math.round(totalSeconds * 1000)), + be32(0x10000), be16(0x100), be16(0), new Uint8Array(8), + matrix, new Uint8Array(24), be32(2), + )) + const tkhd = mp4Box('tkhd', concatBytes( + be32(7), be32(0), be32(0), be32(1), be32(0), be32(0), new Uint8Array(8), + be16(0), be16(0), be16(0x100), be16(0), matrix, be32(0), be32(0), + )) + const mdhd = mp4Box('mdhd', concatBytes( + be32(0), be32(0), be32(0), be32(timescale), be32(totalTicks), be16(0x55c4), be16(0), + )) + const hdlr = mp4Box('hdlr', concatBytes( + be32(0), be32(0), ascii('soun'), new Uint8Array(12), + )) + const smhd = mp4Box('smhd', concatBytes(be32(0), be16(0), be16(0))) + const url = mp4Box('url ', be32(1)) + const dref = mp4Box('dref', concatBytes(be32(0), be32(1), url)) + const dinf = mp4Box('dinf', dref) + const stsd = mp4Box('stsd', concatBytes(be32(0), be32(1), sampleEntry)) + const stts = mp4Box('stts', concatBytes(be32(0), be32(0))) + const stsc = mp4Box('stsc', concatBytes(be32(0), be32(0))) + const stsz = mp4Box('stsz', concatBytes(be32(0), be32(0), be32(0))) + const stco = mp4Box('stco', concatBytes(be32(0), be32(0))) + const stbl = mp4Box('stbl', concatBytes(stsd, stts, stsc, stsz, stco)) + const minf = mp4Box('minf', concatBytes(smhd, dinf, stbl)) + const mdia = mp4Box('mdia', concatBytes(mdhd, hdlr, minf)) + const trak = mp4Box('trak', concatBytes(tkhd, mdia)) + const trex = mp4Box('trex', concatBytes(be32(0), be32(1), be32(1), be32(0), be32(0), be32(0))) + const mvex = mp4Box('mvex', trex) + const moov = mp4Box('moov', concatBytes(mvhd, trak, mvex)) + const ftyp = mp4Box('ftyp', concatBytes(ascii('isom'), be32(512), ascii('isomiso2mp41dash'))) + return concatBytes(ftyp, moov) +} + +export interface Mp4Sample { + data: Uint8Array + duration: number +} + +/** 构造一个 fragmented MP4 media segment。 */ +export function buildMp4Segment( + baseDecodeTick: number, + samples: readonly Mp4Sample[], + sequence: number, +): Bytes { + if (samples.length === 0) throw new Error('MP4 segment 不能没有采样') + const sampleTable = samples.flatMap(sample => [be32(sample.duration), be32(sample.data.length)]) + const mfhd = mp4Box('mfhd', concatBytes(be32(0), be32(sequence))) + const tfhd = mp4Box('tfhd', concatBytes(be32(0x020000), be32(1))) + const tfdt = mp4Box('tfdt', concatBytes(be32(0x01000000), be64(baseDecodeTick))) + const makeTrun = (offset: number) => mp4Box('trun', concatBytes( + be32(0x000301), be32(samples.length), be32(offset), concatBytes(...sampleTable), + )) + const makeMoof = (offset: number) => mp4Box('moof', concatBytes( + mfhd, mp4Box('traf', concatBytes(tfhd, tfdt, makeTrun(offset))), + )) + const mediaPayload = concatBytes(...samples.map(sample => sample.data)) + const dataOffset = makeMoof(0).length + 8 + return concatBytes(makeMoof(dataOffset), mp4Box('mdat', mediaPayload)) +} + +export function codecMime(codec: CencCodec): string { + if (codec === 'opus') return 'audio/webm;codecs="opus"' + if (codec === 'flac') return 'audio/mp4; codecs="fLaC"' + return 'audio/mp4; codecs="mp4a.40.2"' +} + +export function initSegment( + meta: CencMetadata, + sampleEntry?: Uint8Array, +): Bytes { + if (meta.codec === 'opus') { + const playableTicks = Math.max(0, meta.totalTicks - meta.preSkip) + return buildWebmInit(meta.channels, meta.preSkip, playableTicks / meta.timescale) + } + if (!sampleEntry) throw new Error('MP4 编码缺少 sample entry') + return buildMp4Init(sampleEntry, meta.timescale, meta.totalTicks) +} diff --git a/src/settings.ts b/src/settings.ts index bf3c5c4..05de70b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,9 +9,15 @@ export type ApiPreference = 'app' | 'redcandle' /** 下载格式 */ export type DownloadFormat = 'epub' | 'txt' +/** 书架里单击书本的去向 */ +export type BookshelfClickAction = 'read' | 'detail' + /** TXT 编码 */ export type DownloadCharset = 'utf-8' | 'gbk' +/** 听书读完一章之后做什么 */ +export type AudiobookChapterEnd = 'next' | 'stop' + export interface Settings { /* --- 常规 --- */ /** 解密网页端混淆字体 */ @@ -28,6 +34,11 @@ export interface Settings { customCssEnabled: boolean /** 自定义 CSS 内容。关闭开关时仍然保留,只是不应用 */ customCss: string + /** + * 书架里单击书本的默认去向。 + * 按住 Ctrl 单击取相反项;中键(或 Ctrl+中键)在新标签页打开对应目标。 + */ + bookshelfClickAction: BookshelfClickAction /* --- 搜索 --- */ /** 接管网页搜索界面 */ @@ -55,6 +66,12 @@ export interface Settings { /** EPUB:保留书籍自带的排版样式(css_map) */ downloadBookCss: boolean + /* --- 听书 --- */ + /** 读完一章之后:next=自动下一章,stop=停下 */ + audiobookChapterEnd: AudiobookChapterEnd + /** 高亮朗读中的段落,并跟随滚动 */ + audiobookFollow: boolean + /* --- 协议 --- */ apiPreference: ApiPreference /** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */ @@ -71,6 +88,8 @@ export const DEFAULT_SETTINGS: Settings = { readerFont: '', customCssEnabled: false, customCss: '', + // 原站书架点封面是继续阅读,保持一致 + bookshelfClickAction: 'read', enhanceSearch: true, // 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启 @@ -88,6 +107,9 @@ export const DEFAULT_SETTINGS: Settings = { downloadImages: true, downloadBookCss: true, + audiobookChapterEnd: 'next', + audiobookFollow: true, + apiPreference: 'app', deviceId: '', installId: '', @@ -116,6 +138,12 @@ function normalize(raw: unknown): Settings { if (s.downloadCharset !== 'utf-8' && s.downloadCharset !== 'gbk') { s.downloadCharset = DEFAULT_SETTINGS.downloadCharset } + if (s.bookshelfClickAction !== 'read' && s.bookshelfClickAction !== 'detail') { + s.bookshelfClickAction = DEFAULT_SETTINGS.bookshelfClickAction + } + if (s.audiobookChapterEnd !== 'next' && s.audiobookChapterEnd !== 'stop') { + s.audiobookChapterEnd = DEFAULT_SETTINGS.audiobookChapterEnd + } // 数字项来自输入框,可能是 NaN 或越界值 s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize) s.downloadInterval = clampInt(s.downloadInterval, 0, 10_000, DEFAULT_SETTINGS.downloadInterval) diff --git a/src/types.ts b/src/types.ts index b2c01d0..35b3934 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,3 +210,131 @@ export interface SearchLandingSection { words: SearchHotWord[] books: SearchSuggestBook[] } + +/* --------------------------------- 书评 --------------------------------- */ + +/** 评论作者。头衔(vip/作者等)接口下发的是内嵌 JSON 字符串,这里只留解析后的名字 */ +export interface CommentUser { + user_id: string + name: string + avatar: string + description: string + is_author: boolean + is_vip: boolean + /** 头衔名,如 vip、榜一大哥 */ + titles: string[] +} + +/** + * 正文里的富文本片段(书名号引用、话题等)。 + * + * s/e 是 UTF-16 码元下标(实测与 String.prototype.slice 一致, + * 不是 UTF-8 字节,也不是码点序号)。tp 目前只见过 4 = 搜索跳转。 + */ +export interface CommentTextExt { + start: number + end: number + text: string + type: number + /** dragon1967:// 开头的 APP 内部 schema,网页端点不了,仅保留原文 */ + uri?: string +} + +/** 评论与回复共有的部分 */ +export interface CommentBase { + id: string + text: string + /** 富文本片段,可能为空 */ + exts: CommentTextExt[] + /** 秒级时间戳 */ + create_time: number + user: CommentUser + digg_count: number + reply_count: number + /** 当前用户是否赞过。未登录(或读不到 sessionid)时恒为 false */ + user_digg: boolean + user_disagree: boolean +} + +/** 一楼书评 */ +export interface BookComment extends CommentBase { + /** 评分,1-10,缺失为 0 */ + score: number + /** 接口下发的展示文案,如「阅读19小时后点评」 */ + score_text: string + /** 作者置顶 */ + author_stick: boolean + /** 追评。同一用户在原评论之后补写的内容 */ + addition?: CommentBase + /** 接口下发的前几条回复,展开前先用它 */ + preview_reply_count: number +} + +/** 楼中楼回复。二级回复挂在 sub 里 */ +export interface CommentReply extends CommentBase { + /** 所属评论 id */ + to_comment_id: string + /** 二级回复时,父回复的 id */ + to_reply_id: string + /** 接口预览下发的子回复 */ + sub: CommentReply[] +} + +/** 书评区筛选标签,如「长评」「文笔很好」 */ +export interface CommentFilterTag { + tag_id: string + tag_name: string + count: number +} + +/** 评论排序。1=最热,3=最新(其余值接口报参数错误) */ +export type CommentSort = 1 | 3 + +export interface CommentListResult { + comments: BookComment[] + /** 服务端游标,原样回传即可翻页 */ + cursor: string + has_more: boolean + /** 该书评论总数(受 tag 过滤影响) */ + total: number + tags: CommentFilterTag[] + /** 点评人数展示文案,如「1.1万人点评」 */ + score_text: string + score_count: number + /** + * 当前登录用户自己写的那条书评 / 打分。 + * 服务端把它单独放在 extra.user_comment,不混在列表里; + * 没登录或还没点评过时为 null。打过分后即可用它回显星级。 + */ + user_comment: BookComment | null +} + +export interface ReplyListResult { + replies: CommentReply[] + cursor: string + has_more: boolean + total: number +} + +export interface CencAudioContext { + urls: string[] + vid: string + key: string // empty for a not encrypted audio + item_id: string +} + +export interface Tone { + id: number + name: string + icon: string // 空字符串是没有图标 + gender: number // 1=男声 2=女声 + description: string +} + +export interface ParagraphTimeTag { + is_title: boolean // idx=10000 就是标题 + startidx: number + endidx: number + startms: number + endms: number +} \ No newline at end of file diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts new file mode 100644 index 0000000..1d9e20c --- /dev/null +++ b/src/utils/cookie.ts @@ -0,0 +1,127 @@ +// Cookie 读取。 +// +// 网页站的 sessionid 是 HttpOnly,document.cookie 读不到,而书评接口挂在 +// reading.snssdk.com 根路径(网页站只把 /reading 反代到了 APP 网关), +// 拿不到同源那份 Cookie,只能自己把 sessionid 塞进请求头。 +// +// GM_cookie 能读 HttpOnly,但 Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, +// 需要用户手动放开: +// 设置 → 通用 → 配置模式 → 高级 +// 设置 → 高级 → 安全 → 允许脚本访问 Cookie → All +// 见 https://github.com/Tampermonkey/tampermonkey/issues/465#issuecomment-2479061865 +// +// 没有任何接口能告诉我们「是不是被这个开关挡住了」,所以只能反推: +// 用户明明是登录状态(checkLogin 通过),却读不到 sessionid —— 那就是被挡了。 + +/** 放开 Cookie 访问的操作路径,取不到 sessionid 时提示用户 */ +export const COOKIE_PERMISSION_HINT = + '读不到登录态(sessionid)。若确认已登录,请在 Tampermonkey 里放开 Cookie 访问:' + + '设置 → 通用 → 配置模式 改为「高级」,再到 设置 → 高级 → 安全 →' + + '「允许脚本访问 Cookie」改为 All。' + +/** GM_cookie.list 迟迟不回调时的等待上限 */ +const LIST_TIMEOUT = 5_000 + +export type CookieFailure = + /** 脚本管理器不支持,或没授权 GM_cookie */ + | 'no-api' + /** GM_cookie.list 报错或超时 */ + | 'api-error' + /** 接口正常但没有这条 Cookie(未登录,或被 HttpOnly 开关挡住) */ + | 'not-found' + +export interface CookieLookup { + value: string | null + reason?: CookieFailure + /** api-error 时的原始错误信息 */ + error?: string +} + +function hasGMCookie(): boolean { + // @grant GM_cookie 缺失时这个标识符压根不存在,typeof 才不会抛 + return typeof GM_cookie !== 'undefined' && typeof GM_cookie?.list === 'function' +} + +/** 把回调式的 GM_cookie.list 包成 Promise,并防它不回调 */ +function listCookies(details: Tampermonkey.ListCookiesDetails): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (fn: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + fn() + } + const timer = setTimeout( + () => finish(() => reject(new Error('GM_cookie.list 无响应'))), + LIST_TIMEOUT, + ) + try { + GM_cookie.list(details, (cookies, error) => { + finish(() => (error ? reject(new Error(error)) : resolve(cookies ?? []))) + }) + } catch (err) { + finish(() => reject(err instanceof Error ? err : new Error(String(err)))) + } + }) +} + +/** document.cookie 兜底,只能读到非 HttpOnly 的 */ +function readFromDocument(name: string): string | null { + for (const part of document.cookie.split(';')) { + const raw = part.trim() + if (raw.startsWith(name + '=')) return raw.slice(name.length + 1) + } + return null +} + +/** + * 读一条 Cookie。先试 GM_cookie(能读 HttpOnly),再退到 document.cookie。 + * + * @param name Cookie 名 + * @param url 归属页面,默认当前文档 + */ +export async function readCookie(name: string, url?: string): Promise { + if (hasGMCookie()) { + try { + const cookies = await listCookies(url ? { url, name } : { name }) + // 指定了 name 仍可能匹配到多条(不同 domain/path),取第一条有值的 + const hit = cookies.find(c => c.name === name && c.value) + if (hit) return { value: hit.value } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn('[fqa:cookie] GM_cookie.list 失败,退到 document.cookie:', message) + const fallback = readFromDocument(name) + if (fallback) return { value: fallback } + return { value: null, reason: 'api-error', error: message } + } + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'not-found' } + } + + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'no-api' } +} + +const SESSION_COOKIE = 'sessionid' + +/** 成功读到的 sessionid。同一页面内复用,避免每次点赞都问一遍脚本管理器 */ +let cached: string | null = null + +/** + * 读网页站的 sessionid。 + * + * 只在需要写操作(点赞等)或需要「我是否赞过」状态时调用; + * 拿到的值仅随请求发往字节官方的 reading.snssdk.com,不经任何第三方。 + */ +export async function getSessionId(): Promise { + if (cached) return { value: cached } + const found = await readCookie(SESSION_COOKIE) + if (found.value) cached = found.value + return found +} + +/** 登录状态变化后调用,丢掉旧的 sessionid */ +export function clearSessionIdCache(): void { + cached = null +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 67ffdeb..4055234 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -78,4 +78,9 @@ export function concatArrayBuffers(...buffers: ArrayBuffer[]): ArrayBuffer { offset += buf.byteLength } return result.buffer +} + +export function clearNodeEventListeners(element: T): void { + element.replaceWith(cloneElement(element)) + return } \ No newline at end of file diff --git a/src/utils/navigate.ts b/src/utils/navigate.ts new file mode 100644 index 0000000..c098738 --- /dev/null +++ b/src/utils/navigate.ts @@ -0,0 +1,58 @@ +// 书籍卡片的跳转辅助。 +// +// 交互约定: +// 单击 -> 设置里选的默认去向(继续阅读 / 查看详情) +// Ctrl+单击 -> 另一个去向 +// 中键 -> 默认去向,新标签页 +// Ctrl+中键 -> 另一个去向,新标签页 +// +// macOS 上 Ctrl+单击被系统当成右键(会触发 contextmenu),所以 Cmd 也算「反向」, +// 否则 Mac 用户没有可用的反向键。 + +/** 鼠标中键 */ +const MIDDLE_BUTTON = 1 + +export interface OpenModifiers { + /** 取与默认相反的去向 */ + flip: boolean + /** 在新标签页打开 */ + newTab: boolean +} + +/** 从鼠标/键盘事件里读出修饰意图。键盘事件没有 button,只可能 flip */ +export function modifiersOf(event: MouseEvent | KeyboardEvent): OpenModifiers { + return { + flip: event.ctrlKey || event.metaKey, + newTab: 'button' in event && event.button === MIDDLE_BUTTON, + } +} + +/** 中键按下时浏览器会进入自动滚动模式,光标变成罗盘,需要拦掉 */ +export function isMiddleButton(event: MouseEvent): boolean { + return event.button === MIDDLE_BUTTON +} + +export function readerUrl(chapterId: string): string { + return `https://fanqienovel.com/reader/${chapterId}` +} + +export function bookPageUrl(bookId: string): string { + return `https://fanqienovel.com/page/${bookId}` +} + +/** + * 跳转。newTab 为真时开新标签页。 + * + * 不传 'noopener':按规范带上它 window.open 一律返回 null,就没法判断是否被拦了。 + * 目标是同源页面,opener 可访问本身无害。 + */ +export function openUrl(url: string, newTab = false): void { + if (!newTab) { + unsafeWindow.location.href = url + return + } + // 中键点击是用户手势,正常不会被拦;真被拦了也不要偷偷改成当前页跳转 + if (!unsafeWindow.open(url, '_blank')) { + console.warn('[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:', url) + } +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 46288ea..3e3799a 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -6,6 +6,30 @@ export type ApiResponse = Tampermonkey.Response & { json(): T; }; +/** + * 响应没有任何正文。 + * + * 番茄网关对「不认识的设备」就是这个回应:HTTP 200、`content-length: 0`、 + * 一个字节都没有。裸 JSON.parse 会抛 `SyntaxError: "undefined" is not valid JSON` + * (Tampermonkey 在无正文时不给 responseText),完全看不出真正的原因, + * 所以这里换成一个能认出来的错误类型。 + */ +export class EmptyResponseError extends Error { + readonly status: number; + + constructor(status: number) { + super(`服务端返回了空响应体(HTTP ${status})`); + this.name = "EmptyResponseError"; + this.status = status; + } +} + +/** 响应体是否为空。Tampermonkey 可能给 undefined,也可能给空串 */ +export function isEmptyResponse(res: Pick): boolean { + const text = res.responseText; + return typeof text !== "string" || text === ""; +} + const supportedMethods = new Set([ "GET", "HEAD", @@ -59,6 +83,9 @@ export default function apiFetch( cleanup(); resolve(Object.assign(response, { json(this: Tampermonkey.Response): T { + if (isEmptyResponse(this)) { + throw new EmptyResponseError(this.status); + } return JSON.parse(this.responseText) as T; }, })); diff --git a/src/views/AudioBar.vue b/src/views/AudioBar.vue new file mode 100644 index 0000000..788010a --- /dev/null +++ b/src/views/AudioBar.vue @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + 听 + + + + + + + {{ state.title }} + + {{ state.error || (state.loading ? '缓冲中…' : state.toneName) }} + + + + + + + + + + + + + × + + + + + + diff --git a/src/views/BookCard.vue b/src/views/BookCard.vue index 6a47823..2b57f59 100644 --- a/src/views/BookCard.vue +++ b/src/views/BookCard.vue @@ -1,13 +1,14 @@ + + + + + {{ props.current === null ? '选择音色' : '切换音色' }} + 切换后会从当前段落开头继续 + + + + + {{ tone.name.slice(0, 1) }} + + + {{ tone.name }} + + {{ genderLabel(tone.gender) }} + + + {{ tone.description }} + + + + + + 取消 + + + + diff --git a/vite.config.ts b/vite.config.ts index a3eced1..a6beb61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,7 +30,10 @@ export default defineConfig({ author: 'naiyQAQ', 'run-at': 'document-start', match: ['*://*.fanqienovel.com/*'], - grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'unsafeWindow'], + // GM_cookie 用于读 HttpOnly 的 sessionid(书评点赞需要登录态)。 + // 注意:Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, + // 用户需在 设置→通用→配置模式→高级,再 设置→高级→安全→允许脚本访问 Cookie→All + grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'GM_cookie', 'unsafeWindow'], connect: [ 'fanqienovel.com', // 主站 'jxbhmy.com', // 红烛小说 API @@ -38,6 +41,11 @@ export default defineConfig({ 'byteimg.com', // 图床 'fqnovelpic.com', // 图床 'bytecdn.cn', // 图床 + // 听书音频 CDN。页面 CSP 的 connect-src 不含这些域名, + // 走页面 fetch 会被 report-only 策略上报,所以改用 GM_xmlhttpRequest + 'fqnovelvod.com', // 番茄小说 TTS + 'novelfmvod.com', // 番茄畅听 + 'volcautovod.com', // 火山引擎回源 ], }, build: {
上: +//
“凭什么爹可以喝花酒,我就不行!”
,不管里面的 blk 怎么切。 + +import type { ParagraphTimeTag } from '../types' + +/** 标题的 idx。时间点表里用这个值表示「正在读标题」 */ +export const TITLE_IDX = 10000 + +/** 脚本接管的正文容器 id,与 readerHook 保持一致 */ +const CONTENT_SELECTOR = '#fqa-reader-content' + +/** 页面原有的章节标题 */ +const TITLE_SELECTOR = 'h1.muye-reader-title' + +/** 当前高亮的段落类名 */ +export const ACTIVE_CLASS = 'fqa-audio-active' + +/** + * 收集正文里所有带 idx 的段落,按 idx 建索引。 + * + * 同一个 idx 理论上只有一个
,但真出现重复也不丢弃 —— 一起高亮比只亮一半好。 + */ +export function collectParagraphs(): Map { + const map = new Map() + const container = document.querySelector(CONTENT_SELECTOR) + if (!container) return map + for (const node of container.querySelectorAll('p[idx]')) { + const idx = Number(node.getAttribute('idx')) + if (!Number.isFinite(idx)) continue + const list = map.get(idx) + if (list) list.push(node) + else map.set(idx, [node]) + } + return map +} + +/** + * 标题时间点该高亮谁。 + * + * 时间点表里只有一条 is_title 时是普通章节,标题就是页面上那个 h1。 + * 出现多条则说明是「复杂章节」(卷首语、分节小标题等各自成段), + * 光靠 idx=10000 分不出是哪一个,索性都不高亮 —— 与其亮错位置,不如不亮。 + */ +export function titleTarget(tags: ParagraphTimeTag[]): HTMLElement | null { + const titles = tags.filter(t => t.is_title) + if (titles.length !== 1) return null + return document.querySelector(TITLE_SELECTOR) +} + +/** 找出 timeMs 落在哪个时间点区间。找不到返回 -1 */ +export function tagIndexAt(tags: ParagraphTimeTag[], timeMs: number): number { + let low = 0 + let high = tags.length - 1 + let found = -1 + // 时间点表按时间递增,二分找最后一个 startms <= timeMs 的 + while (low <= high) { + const middle = (low + high) >> 1 + if (tags[middle]!.startms <= timeMs) { + found = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + if (found < 0) return -1 + // 落在两段之间的空隙(上一段已读完、下一段还没开始)就保持上一段亮着 + return found +} + +/** + * 高亮某个时间点覆盖的所有段落,并把上一次的高亮清掉。 + * + * 一个时间点可能跨多段(startidx..endidx),所以按区间取。 + */ +export class ParagraphHighlighter { + private paragraphs = new Map() + private tags: ParagraphTimeTag[] = [] + private active: HTMLElement[] = [] + private activeTagIndex = -1 + + /** 换章或正文重新插入后调用,重建索引 */ + reset(tags: ParagraphTimeTag[]): void { + this.clear() + this.tags = tags + this.paragraphs = collectParagraphs() + this.activeTagIndex = -1 + } + + /** 正文 DOM 被替换过(切音色不会换 DOM,但切章会),重新抓一遍段落 */ + refresh(): void { + this.paragraphs = collectParagraphs() + this.activeTagIndex = -1 + } + + get timeTags(): ParagraphTimeTag[] { + return this.tags + } + + /** 当前高亮对应的时间点下标,用于切音色时保持段落 */ + get currentTagIndex(): number { + return this.activeTagIndex + } + + clear(): void { + for (const node of this.active) node.classList.remove(ACTIVE_CLASS) + this.active = [] + } + + /** + * 按播放时间更新高亮。 + * + * @param scroll 是否把高亮段落滚进视口 + * @returns 当前时间点下标,没有变化时返回原值 + */ + update(timeMs: number, scroll: boolean): number { + if (this.tags.length === 0) return -1 + const index = tagIndexAt(this.tags, timeMs) + if (index < 0 || index === this.activeTagIndex) return this.activeTagIndex + this.activeTagIndex = index + this.applyTag(index, scroll) + return index + } + + /** 直接高亮第 index 个时间点,供切音色后恢复位置用 */ + applyTag(index: number, scroll: boolean): void { + const tag = this.tags[index] + if (!tag) return + this.clear() + const targets = tag.is_title + ? [titleTarget(this.tags)].filter((n): n is HTMLElement => n !== null) + : this.rangeTargets(tag.startidx, tag.endidx) + for (const node of targets) node.classList.add(ACTIVE_CLASS) + this.active = targets + if (scroll && targets[0]) { + targets[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + + private rangeTargets(startidx: number, endidx: number): HTMLElement[] { + const targets: HTMLElement[] = [] + for (let idx = startidx; idx <= endidx; idx++) { + const nodes = this.paragraphs.get(idx) + if (nodes) targets.push(...nodes) + } + return targets + } + + /** + * 点段落跳转用:找出这个节点属于哪个时间点。 + * + * 从点击目标往上找最近的 (点到 blk 或行内标签上也能命中), + * 再拿 idx 去时间点表里查覆盖它的那一段。 + */ + tagIndexOfNode(node: Node | null): number { + const paragraph = nearestParagraph(node) + if (paragraph) { + const idx = Number(paragraph.getAttribute('idx')) + if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx) + } + // 点在标题上 + if (node instanceof Node && titleTarget(this.tags)?.contains(node)) { + return this.tags.findIndex(t => t.is_title) + } + return -1 + } + + /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ + tagIndexOfIdx(idx: number): number { + return this.tags.findIndex(t => !t.is_title && idx >= t.startidx && idx <= t.endidx) + } + + /** 第 index 个时间点的起始秒数,供 seek 用 */ + startSecondsOf(index: number): number | null { + const tag = this.tags[index] + return tag ? tag.startms / 1000 : null + } +} + +/** 从任意节点往上找最近的带 idx 的段落 */ +function nearestParagraph(node: Node | null): HTMLElement | null { + let current: Node | null = node + while (current) { + if (current instanceof HTMLElement && current.matches('p[idx]')) return current + // 到正文容器就停,别一路找到 body + if (current instanceof HTMLElement && current.id === 'fqa-reader-content') return null + current = current.parentNode + } + return null +} diff --git a/src/crypto/spade.ts b/src/crypto/spade.ts new file mode 100644 index 0000000..81def53 --- /dev/null +++ b/src/crypto/spade.ts @@ -0,0 +1,53 @@ +import { b64decode } from '.' + +function decodeBase36(c: number): number { + if (c >= 48 && c <= 57) return c - 48; // '0'-'9' + if (c >= 97 && c <= 122) return c - 97 + 10; // 'a'-'z' + return 0xFF; +} + +function bitCount(n: number): number { + n = n - ((n >> 1) & 0x55555555); + n = (n & 0x33333333) + ((n >> 2) & 0x33333333); + return ((n + (n >> 4) & 0xF0F0F0F) * 0x1010101) >> 24; +} + +function decryptSpadeInner(spadeKey: Uint8Array): Uint8Array { + const result = new Uint8Array(spadeKey); + const buff = new Uint8Array(2 + spadeKey.length); + buff.set([0xFA, 0x55], 0); + buff.set(spadeKey, 2); + + for (let i = 0; i < result.length; i++) { + let v = (spadeKey[i]! ^ buff[i]!) - bitCount(i) - 21; + while (v < 0) { v += 0xFF; } + result[i] = v; + } + return result; +} + +function decryptSpade(spadeKeyBytes: Uint8Array): string { + const spadeKeyLen = spadeKeyBytes.length; + if (spadeKeyLen < 3) return ""; + const paddingLen = (spadeKeyBytes[0]! ^ spadeKeyBytes[1]! ^ spadeKeyBytes[2]!) - 48; + if (spadeKeyLen < paddingLen + 2) return ""; + const innerInput = spadeKeyBytes.slice(1, spadeKeyLen - paddingLen); + const tmpBuff = decryptSpadeInner(innerInput); + if (tmpBuff.length === 0) return ""; + const skipBytes = decodeBase36(tmpBuff[0]!); + const decodedMessageLen = spadeKeyLen - paddingLen - 2; + const endIndex = 1 + decodedMessageLen - skipBytes; + if (endIndex > tmpBuff.length) return ""; + const finalBytes = tmpBuff.slice(1, endIndex); + return new TextDecoder("utf-8").decode(finalBytes); +} + +export function decryptSpadeA(spadeAStr: string): string { + try { + const bytes = new Uint8Array(b64decode(spadeAStr)); + return decryptSpade(bytes); + } catch (e) { + console.error("Spade parsing error", e); + return ""; + } +} \ No newline at end of file diff --git a/src/hooks/readerHook.ts b/src/hooks/readerHook.ts index 446dd8b..19363d1 100644 --- a/src/hooks/readerHook.ts +++ b/src/hooks/readerHook.ts @@ -9,6 +9,14 @@ import { cloneElement } from '../utils'; import { fetchArrayBuffer } from '../utils/request'; import { type Book } from '../types' import { decryptComicImage } from '../crypto/content'; +import { initAudioPanel } from '../audioPanel' +import { + closeAudiobook, + openAudiobook, + refreshParagraphs, + state as audioState, + switchChapter, +} from '../audiobook/controller' // import moment from 'moment' let currentBook: Book | null = null @@ -303,6 +311,36 @@ async function insertContent() { } } } + + // 正文换了:段落节点全是新的,正在听书就把音频也切到这一章 + if (audioState.open) { + await switchChapter(itemId, { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) + } else { + refreshParagraphs() + } +} + +/** + * 工具栏「听书」按钮:停止 / 开始听书。 + * + * 已经在听的时候再点就是停止。切音色走左下角悬浮栏,不从这里进。 + */ +async function startAudioPlay(): Promise { + if (audioState.open) { + closeAudiobook() + return + } + const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || '' + if (!itemId) return + initAudioPanel() + // 封面和书名从已取到的书籍信息来;正文还没加载完时退化成空封面 + await openAudiobook(itemId, currentBook?.book_id ?? '', { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) } // let currentBook: Book | null = null @@ -322,6 +360,28 @@ async function onLoad(): Promise { document.head.appendChild(style) } */ + // 切换日夜间的按钮,不管怎么样它的状态都是完全一样的。适合作为模板。 + const toolbar = document.querySelector("div.reader-toolbar > div") + const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)") + if (toolbarButton && toolbar) { + const c = cloneElement(toolbarButton) + c.id = 'fqa-toggle-audiobook' + const listenIcon = document.createElement('span') + listenIcon.textContent = '听' + listenIcon.style.width = '24px' + listenIcon.style.height = '24px' + listenIcon.style.fontSize = '24px' + listenIcon.style.lineHeight = '24px' + listenIcon.classList.add('muyeicon-icon') + listenIcon.classList.add('reader-toolbar-item-icon') + c.firstChild?.replaceWith(listenIcon) + const l = c.lastChild as HTMLDivElement + if (l) { + l.textContent = '听书' + } + c.addEventListener('click', () => void startAudioPlay()) + toolbar.appendChild(c) + } const btns = document.querySelector("div.muye-reader-btns") // single div if (btns) { // TODO: 直接覆写按钮行为,切章节由脚本完成 diff --git a/src/player/CencAudioPlayer.ts b/src/player/CencAudioPlayer.ts new file mode 100644 index 0000000..ca97b05 --- /dev/null +++ b/src/player/CencAudioPlayer.ts @@ -0,0 +1,1083 @@ +import { fetch as pageFetch } from '../config' +import type { Bytes } from './bytes' +import { + cencIvSize, + decryptCencSample, + importCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' +import { + buildMp4Segment, + buildWebmCluster, + codecMime, + extractFlacEntry, + extractSampleEntry, + initSegment, +} from './remux' + +export type CencPlayerState = 'idle' | 'loading' | 'ready' | 'playing' | 'paused' | 'ended' | 'error' | 'destroyed' + +export interface CencAudioSource { + /** 加密音频 MP4 URL。Range 请求不会发送 Referer。 */ + url: string + /** 16 字节 AES-128 key,或 32 位 hex key。 */ + key: string | ArrayBuffer | Uint8Array +} + +export interface CencMediaInfo { + codec: CencCodec + sampleCount: number + durationSeconds: number + timescale: number + channels: number + encryptedBytes: number +} + +export interface CencProgress { + processedSamples: number + totalSamples: number + percent: number + bufferedAhead: number + durationSeconds: number +} + +export interface CencAudioPlayerCallbacks { + onStateChange?: (state: CencPlayerState) => void + onProgress?: (progress: CencProgress) => void + onMessage?: (message: string, level: 'info' | 'warn' | 'error') => void +} + +export interface CencAudioPlayerOptions extends CencAudioPlayerCallbacks { + /** 初次读取的 MP4 头大小,默认 512 KiB。 */ + headBytes?: number + /** MP4 头解析失败时逐次扩大读取,默认最多 8 MiB。 */ + maxHeadBytes?: number + /** + * 每个 Range/MSE media segment 包含的目标秒数,默认 15 秒。 + * Opus/WebM 因时间戳范围限制最高按 30 秒处理。 + */ + segmentSeconds?: number + /** 播放位置前方达到此秒数后暂停续流,默认 40 秒。 */ + maxBufferAheadSeconds?: number + /** 清理播放位置后方的缓冲,只保留此秒数,默认 10 秒。 */ + keepBehindSeconds?: number + /** 并发 AES-CTR 解密数,默认 24。 */ + decryptConcurrency?: number + /** 仅用于测试或替换网络实现;默认使用页面原始 fetch。 */ + fetch?: typeof fetch +} + +interface StreamContext { + url: string + head: Bytes + meta: CencMetadata + key: CryptoKey + counterStart: 0 | 1 + ticks: Float64Array + samplesPerSecond: number + samplesPerSegment: number +} + +const DEFAULT_HEAD_BYTES = 512 * 1024 +const DEFAULT_MAX_HEAD_BYTES = 8 * 1024 * 1024 +const DEFAULT_SEGMENT_SECONDS = 15 +const MAX_WEBM_SEGMENT_SECONDS = 30 +const DEFAULT_MAX_BUFFER_AHEAD = 40 +const DEFAULT_KEEP_BEHIND = 10 +const DEFAULT_DECRYPT_CONCURRENCY = 24 + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(): DOMException { + return new DOMException('播放器会话已取消', 'AbortError') +} + +function isAbort(value: unknown): boolean { + return value instanceof DOMException && value.name === 'AbortError' +} + +/** + * 等 SourceBuffer 处理完手头的 appendBuffer/remove。 + * + * abort 会让上一次操作的 Promise 提前兑现,但浏览器那边还在跑, + * 此时再调 appendBuffer 就会抛 "still processing"。updating 转 false 时 + * 一定会有一次 updateend/abort/error,所以监听事件即可,不用轮询。 + */ +function waitForSourceBufferIdle(sourceBuffer: SourceBuffer, signal: AbortSignal): Promise { + if (!sourceBuffer.updating) return Promise.resolve() + return new Promise(resolve => { + const done = (): void => { + sourceBuffer.removeEventListener('updateend', done) + sourceBuffer.removeEventListener('abort', done) + sourceBuffer.removeEventListener('error', done) + signal.removeEventListener('abort', done) + resolve() + } + sourceBuffer.addEventListener('updateend', done) + sourceBuffer.addEventListener('abort', done) + sourceBuffer.addEventListener('error', done) + // 会话被取消也别一直挂着;调用方随后会自己检查 signal + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * 把 的错误码翻成可读文本,附在 SourceBuffer 报错后面。 + * + * MEDIA_ERR_DECODE 基本等于「喂进去的字节不是合法的音频」—— + * 数据在传输环节被破坏时就是这个。 + */ +function describeMediaError(audio: HTMLAudioElement): string { + const error = audio.error + if (!error) return '' + const names: Record = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE(数据不是合法音频,多半在传输中被破坏)', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED', + } + const name = names[error.code] ?? `code ${error.code}` + return `:${name}${error.message ? ` - ${error.message}` : ''}` +} + +function validatePositive(value: number | undefined, fallback: number, name: string): number { + const result = value ?? fallback + if (!Number.isFinite(result) || result <= 0) throw new Error(`${name} 必须是正数`) + return result +} + +async function mapLimit( + items: readonly T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + const results = new Array(items.length) + let next = 0 + async function consume(): Promise { + while (next < items.length) { + const index = next++ + results[index] = await worker(items[index]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => consume())) + return results +} + +type ByteReader = ReadableStreamDefaultReader + +/** + * 把 fetch 的任意网络 chunk 重新切成播放器要求的 sample 段。 + * 不主动预读:调用方停止 readExactly 后,ReadableStream 的背压会限制继续下载。 + */ +class SequentialByteStream { + private readonly chunks: Bytes[] = [] + private chunkOffset = 0 + private available = 0 + private reader: ByteReader | null + private readonly openReader: (() => Promise) | null + private eof = false + + constructor( + initial: Uint8Array = new Uint8Array(), + reader: ByteReader | null = null, + openReader: (() => Promise) | null = null, + ) { + if (initial.length > 0) { + const copy = new Uint8Array(initial) as Bytes + this.chunks.push(copy) + this.available = copy.length + } + this.reader = reader + this.openReader = openReader + } + + /** 至少缓存 length 字节;流先结束时返回 false。 */ + async bufferAtLeast(length: number): Promise { + while (this.available < length && !this.eof) await this.pull() + return this.available >= length + } + + /** 复制当前全部未消费数据,不移动读取位置。 */ + snapshot(): Bytes { + const result = new Uint8Array(this.available) + let output = 0 + for (let i = 0; i < this.chunks.length; i++) { + const chunk = this.chunks[i]! + const start = i === 0 ? this.chunkOffset : 0 + result.set(chunk.subarray(start), output) + output += chunk.length - start + } + return result as Bytes + } + + async skip(length: number): Promise { + await this.readExactly(length) + } + + async readExactly(length: number): Promise { + if (!Number.isSafeInteger(length) || length < 0) { + throw new Error(`无效的流读取长度: ${length}`) + } + if (length === 0) return new Uint8Array() as Bytes + if (!await this.bufferAtLeast(length)) { + throw new Error(`音频流提前结束(还需 ${length - this.available} 字节)`) + } + + const result = new Uint8Array(length) + let output = 0 + while (output < length) { + const chunk = this.chunks[0]! + const take = Math.min(length - output, chunk.length - this.chunkOffset) + result.set(chunk.subarray(this.chunkOffset, this.chunkOffset + take), output) + output += take + this.chunkOffset += take + this.available -= take + if (this.chunkOffset === chunk.length) { + this.chunks.shift() + this.chunkOffset = 0 + } + } + return result as Bytes + } + + async cancel(): Promise { + this.eof = true + try { + await this.reader?.cancel() + } catch { + // AbortController 通常已经让 reader 进入 errored,取消失败无需再处理。 + } + this.reader = null + } + + private async pull(): Promise { + if (!this.reader && this.openReader) this.reader = await this.openReader() + if (!this.reader) { + this.eof = true + return + } + const { value, done } = await this.reader.read() + if (done) { + this.eof = true + return + } + if (value?.length) { + const copy = new Uint8Array(value) as Bytes + this.chunks.push(copy) + this.available += copy.length + } + } +} + +/** + * CENC 音频流播放器。 + * + * 负责 MP4 分段读取、AES-CTR 解密、WebM/fMP4 重封装和 MSE 缓冲;不创建任何 + * 页面控件。调用方传入一个已有的 HTMLAudioElement,再把它接到自己的界面即可。 + * + * 音频 CDN 明确拒绝 Referer,因此网络层固定使用: + * - 页面原始 fetch(不走被页面脚本改写的 fetch) + * - credentials: omit + * - referrerPolicy: no-referrer + * 不使用 GM_xmlhttpRequest 回退,也不手动添加 Referer。 + */ +export class CencAudioPlayer { + private readonly audio: HTMLAudioElement + private readonly options: Required< + Pick< + CencAudioPlayerOptions, + | 'headBytes' + | 'maxHeadBytes' + | 'segmentSeconds' + | 'maxBufferAheadSeconds' + | 'keepBehindSeconds' + | 'decryptConcurrency' + > + > & CencAudioPlayerCallbacks & { fetch: typeof fetch } + + private state: CencPlayerState = 'idle' + private session = 0 + private streamToken = 0 + private seekToken = 0 + private sessionController: AbortController | null = null + private streamController: AbortController | null = null + private sourceBuffer: SourceBuffer | null = null + private mediaSource: MediaSource | null = null + private objectUrl: string | null = null + private sourceBufferChain: Promise = Promise.resolve() + private context: StreamContext | null = null + private info: CencMediaInfo | null = null + private autoplay = false + private segmentSequence = 0 + + private readonly onSeeking = (): void => { + const context = this.context + if (!context || this.state === 'destroyed' || this.session === 0) return + // 已经进入错误态时,任何 appendBuffer 都会被拒 + // ("The HTMLMediaElement.error attribute is not null.")。 + // 这时重启流只会把同一个错误刷满控制台,直接放弃 + if (this.audio.error) return + const target = this.audio.currentTime + if (!Number.isFinite(target) || this.isTimeBuffered(target)) return + const wasPlaying = !this.audio.paused + void this.restartAt(target, wasPlaying) + } + + private readonly onPlay = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('playing') + } + + private readonly onPause = (): void => { + if (this.context && this.state === 'playing' && !this.audio.ended) this.emitState('paused') + } + + private readonly onEnded = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('ended') + } + + constructor(audio: HTMLAudioElement, options: CencAudioPlayerOptions = {}) { + this.audio = audio + // Window.fetch 不能作为普通对象的方法调用,否则 Edge 会报 Illegal invocation。 + const fetcher = (options.fetch ?? pageFetch).bind(unsafeWindow) + this.options = { + headBytes: validatePositive(options.headBytes, DEFAULT_HEAD_BYTES, 'headBytes'), + maxHeadBytes: validatePositive(options.maxHeadBytes, DEFAULT_MAX_HEAD_BYTES, 'maxHeadBytes'), + segmentSeconds: validatePositive(options.segmentSeconds, DEFAULT_SEGMENT_SECONDS, 'segmentSeconds'), + maxBufferAheadSeconds: validatePositive(options.maxBufferAheadSeconds, DEFAULT_MAX_BUFFER_AHEAD, 'maxBufferAheadSeconds'), + keepBehindSeconds: validatePositive(options.keepBehindSeconds, DEFAULT_KEEP_BEHIND, 'keepBehindSeconds'), + decryptConcurrency: Math.max(1, Math.floor(validatePositive(options.decryptConcurrency, DEFAULT_DECRYPT_CONCURRENCY, 'decryptConcurrency'))), + onStateChange: options.onStateChange, + onProgress: options.onProgress, + onMessage: options.onMessage, + // config.fetch 保存的是页面 fetch 的原始引用,避免被字节 SDK 改写。 + fetch: fetcher, + } + this.audio.addEventListener('seeking', this.onSeeking) + this.audio.addEventListener('play', this.onPlay) + this.audio.addEventListener('pause', this.onPause) + this.audio.addEventListener('ended', this.onEnded) + } + + get currentState(): CencPlayerState { + return this.state + } + + get mediaInfo(): CencMediaInfo | null { + return this.info + } + + get element(): HTMLAudioElement { + return this.audio + } + + /** 加载并开始后台缓冲;autoplay 只表示首次缓冲后尝试调用 audio.play。 */ + async load(source: CencAudioSource, autoplay = false): Promise { + this.ensureAlive() + const session = this.beginSession() + this.autoplay = autoplay + this.emitState('loading') + let initialStream: SequentialByteStream | null = null + + try { + const url = this.validateUrl(source.url) + this.streamController = new AbortController() + initialStream = this.createNetworkStream(url, 0, this.streamController.signal) + let { head, meta } = await this.parseHeadFromStream(initialStream, session) + this.assertSession(session) + const key = await importCencKey(source.key) + this.assertSession(session) + + let counterStart: 0 | 1 = 0 + if (meta.codec === 'opus') { + // counter 探测仍消费同一条 fetch;若前六帧超出初始头,就继续多读一点。 + const probeEnd = meta.mdatStart + meta.offsets[Math.min(6, meta.sampleCount)]! + if (head.length < probeEnd) { + await initialStream.bufferAtLeast(probeEnd) + head = initialStream.snapshot() + } + counterStart = await this.detectCounterStart(meta, head, key) + } + this.assertSession(session) + + const sampleEntry = meta.codec === 'aac' + ? extractSampleEntry(head, meta.mdatStart) + : meta.codec === 'flac' + ? extractFlacEntry(head, meta.mdatStart) + : undefined + const mime = codecMime(meta.codec) + const ctor = this.mediaSourceConstructor() + if (!ctor.isTypeSupported(mime)) throw new Error(`浏览器不支持 MSE 音频格式: ${mime}`) + + const mediaSource = new ctor() + this.mediaSource = mediaSource + this.objectUrl = unsafeWindow.URL.createObjectURL(mediaSource) + const opened = this.waitForSourceOpen(mediaSource, session) + this.audio.src = this.objectUrl + await opened + this.assertSession(session) + this.sourceBuffer = mediaSource.addSourceBuffer(mime) + this.segmentSequence = 0 + const init = initSegment(meta, sampleEntry) + this.emitMessage(`追加 init 段 ${init.byteLength} 字节 (${mime})`, 'info') + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(init)) + }) + this.assertSession(session) + + const ticks = new Float64Array(meta.sampleCount + 1) + for (let i = 0; i < meta.sampleCount; i++) { + ticks[i + 1] = ticks[i]! + meta.durations[i]! + } + const durationSeconds = meta.totalTicks / meta.timescale + const samplesPerSecond = meta.sampleCount / durationSeconds + // WebM SimpleBlock 的相对时间戳是 Int16,Cluster 最长必须小于约 32.7 秒。 + const segmentSeconds = meta.codec === 'opus' + ? Math.min(this.options.segmentSeconds, MAX_WEBM_SEGMENT_SECONDS) + : this.options.segmentSeconds + this.context = { + url, + head, + meta, + key, + counterStart, + ticks, + samplesPerSecond, + samplesPerSegment: Math.max(1, Math.round(samplesPerSecond * segmentSeconds)), + } + this.info = { + codec: meta.codec, + sampleCount: meta.sampleCount, + durationSeconds, + timescale: meta.timescale, + channels: meta.channels, + encryptedBytes: meta.offsets[meta.sampleCount]!, + } + // MP4 头和音频正文来自同一个 fetch;丢掉头部后继续消费现有 reader。 + await initialStream.skip(meta.mdatStart) + this.assertSession(session) + this.emitState('ready') + this.startStream(0, initialStream) + initialStream = null // reader 的所有权已转交给 runStream + return this.info + } catch (error) { + void initialStream?.cancel() + if (this.isCurrent(session) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + throw error + } + } + + /** load(..., true) 的便捷形式。 */ + start(source: CencAudioSource): Promise { + return this.load(source, true) + } + + play(): Promise { + this.ensureAlive() + return this.audio.play().then(() => undefined) + } + + pause(): void { + this.audio.pause() + } + + /** 停止当前流并释放 MSE URL,但保留播放器实例以便再次 load。 */ + stop(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.emitState('idle') + } + + destroy(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.audio.removeEventListener('seeking', this.onSeeking) + this.audio.removeEventListener('play', this.onPlay) + this.audio.removeEventListener('pause', this.onPause) + this.audio.removeEventListener('ended', this.onEnded) + this.emitState('destroyed') + } + + private ensureAlive(): void { + if (this.state === 'destroyed') throw new Error('播放器已销毁') + } + + private beginSession(): number { + this.session++ + this.streamToken++ + this.seekToken++ + this.sessionController?.abort() + this.streamController?.abort() + this.sessionController = new AbortController() + this.streamController = null + this.context = null + this.info = null + this.autoplay = false + this.sourceBuffer = null + this.mediaSource = null + this.sourceBufferChain = Promise.resolve() + this.audio.pause() + if (this.audio.src) { + this.audio.removeAttribute('src') + this.audio.load() + } + if (this.objectUrl) { + unsafeWindow.URL.revokeObjectURL(this.objectUrl) + this.objectUrl = null + } + return this.session + } + + private validateUrl(raw: string): string { + const url = raw.trim() + const parsed = new URL(url) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('音频 URL 必须是 HTTP(S) 地址') + } + return parsed.toString() + } + + private mediaSourceConstructor(): typeof MediaSource { + const win = unsafeWindow as Window & { MediaSource?: typeof MediaSource } + const ctor = win.MediaSource ?? globalThis.MediaSource + if (!ctor) throw new Error('当前浏览器没有 MediaSource 支持') + return ctor + } + + /** + * 把字节搬到页面 realm,再交给 appendBuffer。 + * + * MediaSource 取自 unsafeWindow(页面 realm),而我们构造的 Uint8Array + * 属于用户脚本沙箱 realm。跨 realm 的 ArrayBuffer 传进 appendBuffer 会被拒, + * 表现是 SourceBuffer 立刻抛 error 事件、而 .error 仍是 null + * —— 只看报错完全看不出是 realm 问题。 + * + * 页面 realm 没暴露 Uint8Array 时(少见)就原样返回,交给浏览器自己判断。 + */ + private toPageBuffer(bytes: Uint8Array): BufferSource { + const win = unsafeWindow as Window & { Uint8Array?: Uint8ArrayConstructor } + const PageUint8Array = win.Uint8Array + if (!PageUint8Array || PageUint8Array === Uint8Array) return bytes as Bytes + const copy = new PageUint8Array(bytes.byteLength) as Bytes + copy.set(bytes) + return copy + } + + private async parseHeadFromStream( + stream: SequentialByteStream, + session: number, + ): Promise<{ head: Bytes; meta: CencMetadata }> { + let size = Math.min(this.options.headBytes, this.options.maxHeadBytes) + let lastError: unknown = null + while (size <= this.options.maxHeadBytes) { + const complete = await stream.bufferAtLeast(size) + this.assertSession(session) + const head = stream.snapshot() + try { + return { head, meta: parseCencMetadata(head) } + } catch (error) { + lastError = error + const message = asError(error).message + const incomplete = /未找到|不完整|缺少/.test(message) + if (!incomplete || !complete || size === this.options.maxHeadBytes) throw error + size = Math.min(this.options.maxHeadBytes, size * 2) + } + } + throw lastError instanceof Error ? lastError : new Error('无法解析音频 MP4 头') + } + + private createNetworkStream( + url: string, + start: number, + signal: AbortSignal, + initial: Uint8Array = new Uint8Array(), + ): SequentialByteStream { + return new SequentialByteStream( + initial, + null, + () => this.openAudioReader(url, start, signal), + ) + } + + /** + * 打开一条从指定字节一直到文件末尾的流。 + * 所有 CDN 请求都明确禁止 Referer、Cookie;顺序播放期间只会打开一次。 + */ + private async openAudioReader(url: string, start: number, signal: AbortSignal): Promise { + this.emitMessage(`打开音频流:bytes=${start}-`, 'info') + const response = await this.options.fetch(url, { + method: 'GET', + headers: { Range: `bytes=${start}-` }, + credentials: 'omit', + // 两项都设:policy 禁止浏览器生成 Referer,空 referrer 防止调用方 Request 继承。 + referrer: '', + referrerPolicy: 'no-referrer', + signal, + }) + if (!response.ok && response.status !== 206) { + throw new Error(`音频 CDN 请求失败(HTTP ${response.status})`) + } + if (start > 0 && response.status !== 206) { + throw new Error('音频 CDN 忽略了 Range,无法从拖动位置续流') + } + if (response.body) return response.body.getReader() + + // 极少数 WebView 不暴露 response.body,退回一次性读取,接口语义仍保持一致。 + const bytes = await response.arrayBuffer() + const fallback = new Response(bytes).body + if (!fallback) throw new Error('浏览器不支持流式读取 Response') + return fallback.getReader() + } + + private sessionSignal(session: number): AbortSignal { + this.assertSession(session) + return this.sessionController!.signal + } + + private assertSession(session: number): void { + if (!this.isCurrent(session)) throw abortError() + } + + private isCurrent(session: number): boolean { + return this.session === session && !this.sessionController?.signal.aborted + } + + private async waitForSourceOpen(mediaSource: MediaSource, session: number): Promise { + const signal = this.sessionSignal(session) + await new Promise((resolve, reject) => { + if (mediaSource.readyState === 'open') { + resolve() + return + } + const onOpen = (): void => { + cleanup() + resolve() + } + const onError = (): void => { + cleanup() + reject(new Error('MediaSource 打开失败')) + } + const onAbort = (): void => { + cleanup() + reject(abortError()) + } + const cleanup = (): void => { + mediaSource.removeEventListener('sourceopen', onOpen) + mediaSource.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + } + mediaSource.addEventListener('sourceopen', onOpen, { once: true }) + mediaSource.addEventListener('error', onError, { once: true }) + signal.addEventListener('abort', onAbort, { once: true }) + }) + } + + private enqueueSourceBuffer( + session: number, + operation: (sourceBuffer: SourceBuffer) => void, + ): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer) return Promise.reject(new Error('SourceBuffer 尚未创建')) + const signal = this.sessionSignal(session) + const run = async (): Promise => { + if (signal.aborted) throw abortError() + // 关键:上一次操作可能因为 abort/error 提前兑现了 Promise, + // 但浏览器仍在处理那次 appendBuffer/remove。这时直接下一个操作会抛 + // "This SourceBuffer is still processing an 'appendBuffer' or 'remove' + // operation.",所以必须等它真的空闲,不能只靠 Promise 链的顺序。 + await waitForSourceBufferIdle(sourceBuffer, signal) + if (signal.aborted) throw abortError() + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) return + settled = true + sourceBuffer.removeEventListener('updateend', onUpdateEnd) + sourceBuffer.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + if (error) reject(error) + else resolve() + } + const onUpdateEnd = (): void => finish() + // SourceBuffer 的 error 事件不带原因。注意 MSE 规范里这个事件 + // 先于媒体元素的错误传播,所以此刻 audio.error 常常还是 null, + // 不能据此断定「不是解码问题」。真实错误码要等一轮事件循环, + // 由下面 catch 里的 reportAppendFailure 补报 + const onError = (): void => finish(new Error('SourceBuffer 更新失败')) + const onAbort = (): void => finish(abortError()) + sourceBuffer.addEventListener('updateend', onUpdateEnd) + sourceBuffer.addEventListener('error', onError) + signal.addEventListener('abort', onAbort, { once: true }) + try { + operation(sourceBuffer) + } catch (error) { + finish(asError(error)) + } + }) + } + const next = this.sourceBufferChain.catch(() => undefined).then(run) + this.sourceBufferChain = next + return next + } + + private startStream(startSample: number, existingStream?: SequentialByteStream): void { + const context = this.context + if (!context || this.state === 'destroyed') return + if (!existingStream) { + this.streamController?.abort() + this.streamController = new AbortController() + } + const controller = this.streamController + if (!controller) return + const token = ++this.streamToken + void this.runStream( + context, + this.session, + token, + startSample, + controller.signal, + existingStream, + ) + } + + private async runStream( + context: StreamContext, + session: number, + token: number, + startSample: number, + signal: AbortSignal, + existingStream?: SequentialByteStream, + ): Promise { + let sample = startSample + let first = true + let stream: SequentialByteStream | null = existingStream ?? null + try { + stream ??= this.createSampleStream(context, startSample, signal) + while (this.isStreamCurrent(session, token, signal) && sample < context.meta.sampleCount) { + await this.waitForRoom(session, token, signal) + if (!this.isStreamCurrent(session, token, signal)) return + const endSample = Math.min(context.meta.sampleCount, sample + context.samplesPerSegment) + const byteLength = context.meta.offsets[endSample]! - context.meta.offsets[sample]! + const encrypted = await stream.readExactly(byteLength) + if (!this.isStreamCurrent(session, token, signal)) return + const plain = await this.decryptRange(context, encrypted, sample, endSample) + if (!this.isStreamCurrent(session, token, signal)) return + + const segment = context.meta.codec === 'opus' + ? buildWebmCluster( + Math.round(context.ticks[sample]! * 1000 / context.meta.timescale), + plain.map((_, index) => Math.round( + context.ticks[sample + index]! * 1000 / context.meta.timescale, + ) - Math.round(context.ticks[sample]! * 1000 / context.meta.timescale)), + plain, + ) + : buildMp4Segment( + context.ticks[sample]!, + plain.map((data, index) => ({ + data, + duration: context.meta.durations[sample + index]!, + })), + ++this.segmentSequence, + ) + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(segment)) + }) + if (first) { + // 首段最容易暴露数据问题:把解密后的特征打出来, + // 便于判断是取流坏了还是封装坏了 + const toc = plain[0]?.[0] + this.emitMessage( + `首段 samples=${sample}..${endSample} 密文${encrypted.byteLength}B ` + + `段长${segment.byteLength}B TOC=0x${(toc ?? 0).toString(16)}`, + 'info', + ) + } + if (!this.isStreamCurrent(session, token, signal)) return + await this.evictBehind(session) + + sample = endSample + this.emitProgress({ + processedSamples: sample, + totalSamples: context.meta.sampleCount, + percent: Math.round(sample / context.meta.sampleCount * 100), + bufferedAhead: this.bufferedAhead(), + durationSeconds: context.meta.totalTicks / context.meta.timescale, + }) + if (first && this.autoplay) { + first = false + void this.audio.play().catch(() => { + this.emitMessage('自动播放被浏览器拦截,请点击音频控件播放', 'warn') + }) + } else { + first = false + } + } + if (this.isStreamCurrent(session, token, signal)) { + // 缓冲到结尾必须通知 MediaSource 收口:不调 endOfStream 的话 + // duration 一直是 Infinity, 永远到不了结尾, + // ended 事件不触发(表现为播到最后还显示播放中但没声音, + // 自动切章也就不会发生)。真正的「播完」仍由 ended 事件上报。 + await this.finishStream(session, sample) + this.emitMessage('音频已缓冲到结尾', 'info') + } + } catch (error) { + if (this.isStreamCurrent(session, token, signal) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + } finally { + void stream?.cancel() + } + } + + /** + * 补报媒体元素的错误码。 + * + * SourceBuffer 的 error 事件先于媒体元素错误传播,同步读 audio.error + * 大概率是 null。让出一轮事件循环再读,才能拿到真正的 MediaError。 + */ + private async reportAppendFailure(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) + const detail = describeMediaError(this.audio) + if (detail) this.emitMessage(`媒体元素错误${detail}`, 'error') + } + + private isStreamCurrent(session: number, token: number, signal: AbortSignal): boolean { + return this.isCurrent(session) && this.streamToken === token && !signal.aborted + } + + /** + * 全部样本都送进 SourceBuffer 后收口 MediaSource。 + * + * 只有真的跑到最后一个样本才收口。seek 之后的流也会走到循环末尾, + * 但那时前面还有没缓冲的区间,提前 endOfStream 会把 duration 定在错的位置。 + */ + private async finishStream(session: number, lastSample: number): Promise { + const context = this.context + const mediaSource = this.mediaSource + if (!context || !mediaSource) return + if (lastSample < context.meta.sampleCount) return + if (mediaSource.readyState !== 'open') return + // 等 append 队列排空:还在 updating 时调 endOfStream 会抛 InvalidStateError。 + // 不能用 enqueueSourceBuffer 塞个空操作 —— 它靠 updateend 兑现, + // 空操作不产生事件,会永远挂住 + await this.sourceBufferChain.catch(() => undefined) + if (!this.isCurrent(session)) return + if (this.sourceBuffer) { + await waitForSourceBufferIdle(this.sourceBuffer, this.sessionSignal(session)) + } + if (!this.isCurrent(session) || mediaSource.readyState !== 'open') return + try { + mediaSource.endOfStream() + } catch (error) { + // 收口失败不影响已经缓冲好的部分,照常播,只是没有 ended + this.emitMessage(`标记音频结尾失败: ${asError(error).message}`, 'warn') + } + } + + private async waitForRoom( + session: number, + token: number, + signal: AbortSignal, + ): Promise { + while ( + this.isStreamCurrent(session, token, signal) && + this.bufferedAhead() >= this.options.maxBufferAheadSeconds + ) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + if (!this.isStreamCurrent(session, token, signal)) throw abortError() + } + + /** + * seek 后的新流。目标仍在已保存的 MP4 头内时先复用内存字节,读完再从 + * head.length 开一条网络流;否则直接从目标 sample 的绝对偏移续传。 + */ + private createSampleStream( + context: StreamContext, + startSample: number, + signal: AbortSignal, + ): SequentialByteStream { + const start = context.meta.mdatStart + context.meta.offsets[startSample]! + if (start < context.head.length) { + return this.createNetworkStream( + context.url, + context.head.length, + signal, + context.head.slice(start), + ) + } + return this.createNetworkStream(context.url, start, signal) + } + + private async decryptRange( + context: StreamContext, + encrypted: Uint8Array, + startSample: number, + endSample: number, + ): Promise { + const ivSize = cencIvSize(context.meta) + const jobs = [] as Array<{ iv: Uint8Array; ciphertext: Uint8Array }> + for (let i = startSample; i < endSample; i++) { + const relative = context.meta.offsets[i]! - context.meta.offsets[startSample]! + const size = context.meta.sizes[i]! + jobs.push({ + iv: context.meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext: encrypted.slice(relative, relative + size), + }) + } + return mapLimit(jobs, this.options.decryptConcurrency, job => decryptCencSample( + context.key, + job.iv, + job.ciphertext, + context.counterStart, + )) + } + + private async detectCounterStart( + meta: CencMetadata, + head: Bytes, + key: CryptoKey, + ): Promise<0 | 1> { + const count = Math.min(6, meta.sampleCount) + if (count === 0) return 0 + const start = meta.mdatStart + meta.offsets[0]! + const end = meta.mdatStart + meta.offsets[count]! - 1 + if (end >= head.length) throw new Error('Opus counter 探测数据不完整') + const encrypted = head.slice(start, end + 1) + const ivSize = cencIvSize(meta) + for (const candidate of [0, 1] as const) { + const firstBytes = new Set() + for (let i = 0; i < count; i++) { + const relative = meta.offsets[i]! + const ciphertext = encrypted.slice(relative, relative + meta.sizes[i]!) + const plain = await decryptCencSample( + key, + meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext, + candidate, + ) + if (plain.length > 0) firstBytes.add(plain[0]!) + } + if (firstBytes.size === 1) { + this.emitMessage(`CENC counter 低位起始=${candidate}`, 'info') + return candidate + } + } + this.emitMessage('CENC counter 未收敛,按 0 继续', 'warn') + return 0 + } + + private async evictBehind(session: number): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer || !sourceBuffer.buffered.length) return + const current = this.audio.currentTime + if (current <= this.options.keepBehindSeconds + 5) return + const start = sourceBuffer.buffered.start(0) + const end = current - this.options.keepBehindSeconds + if (start < end) { + try { + await this.enqueueSourceBuffer(session, buffer => buffer.remove(start, end)) + } catch { + // 清理失败不影响继续播放,下一段仍可能触发浏览器自己的回收。 + } + } + } + + private async restartAt(target: number, wasPlaying: boolean): Promise { + const context = this.context + if (!context) return + if (this.audio.error) { + this.emitMessage('播放元素已进入错误态,无法跳转,请重新开始听书', 'warn') + return + } + const session = this.session + const seek = ++this.seekToken + this.streamToken++ + this.streamController?.abort() + + let low = 0 + let high = context.meta.sampleCount - 1 + let index = 0 + const targetTicks = target * context.meta.timescale + while (low <= high) { + const middle = (low + high) >> 1 + if (context.ticks[middle]! <= targetTicks) { + index = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + const preSamples = Math.max(1, Math.round(context.samplesPerSecond * 2)) + const startSample = Math.max(0, index - preSamples) + this.emitMessage(`跳转到 ${target.toFixed(1)} 秒`, 'info') + + // 先让上一条流排队中的 append 走完,再读 buffered。 + // 否则 remove 的范围是旧的,而且紧接着的 append 会撞上还在处理的操作 + await this.sourceBufferChain.catch(() => undefined) + if (seek !== this.seekToken || !this.isCurrent(session)) return + + const bufferedEnd = this.sourceBuffer?.buffered.length + ? this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) + : 0 + if (bufferedEnd > 0) { + try { + await this.enqueueSourceBuffer(session, sourceBuffer => sourceBuffer.remove(0, bufferedEnd + 0.5)) + } catch { + // 后续 append 会给出更具体的错误;这里不让一次清理失败阻断 seek。 + } + } + if (seek !== this.seekToken || !this.isCurrent(session)) return + // 已经 endOfStream 过时 readyState 是 'ended',append 会让它自动回到 'open'; + // 只有 'closed'(SourceBuffer 已被 detach)才真的没救 + if (this.mediaSource && this.mediaSource.readyState === 'closed') { + this.emitMessage('媒体源已关闭,无法跳转', 'warn') + return + } + this.startStream(startSample) + if (wasPlaying) void this.audio.play().catch(() => undefined) + } + + private isTimeBuffered(time: number): boolean { + const ranges = this.sourceBuffer?.buffered + if (!ranges) return false + for (let i = 0; i < ranges.length; i++) { + if (time >= ranges.start(i) - 0.05 && time <= ranges.end(i) + 0.05) return true + } + return false + } + + private bufferedAhead(): number { + const ranges = this.sourceBuffer?.buffered + if (!ranges || !ranges.length) return 0 + const current = this.audio.currentTime + for (let i = 0; i < ranges.length; i++) { + if (current >= ranges.start(i) - 0.05 && current <= ranges.end(i) + 0.05) { + return Math.max(0, ranges.end(i) - current) + } + } + return Math.max(0, ranges.end(ranges.length - 1) - current) + } + + private emitState(state: CencPlayerState): void { + this.state = state + this.options.onStateChange?.(state) + } + + private emitProgress(progress: CencProgress): void { + this.options.onProgress?.(progress) + } + + private emitMessage(message: string, level: 'info' | 'warn' | 'error'): void { + this.options.onMessage?.(message, level) + } +} diff --git a/src/player/bytes.ts b/src/player/bytes.ts new file mode 100644 index 0000000..219f5ae --- /dev/null +++ b/src/player/bytes.ts @@ -0,0 +1,109 @@ +/** 二进制播放器使用的基础字节工具。 */ + +export type Bytes = Uint8Array + +export function asBytes(value: ArrayBuffer | Uint8Array): Bytes { + if (value instanceof Uint8Array) { + return new Uint8Array(value) as Bytes + } + return new Uint8Array(value) as Bytes +} + +export function concatBytes(...arrays: readonly Uint8Array[]): Bytes { + const length = arrays.reduce((sum, array) => sum + array.byteLength, 0) + const result = new Uint8Array(length) + let offset = 0 + for (const array of arrays) { + result.set(array, offset) + offset += array.byteLength + } + return result as Bytes +} + +export function ascii(text: string): Bytes { + const result = new Uint8Array(text.length) + for (let i = 0; i < text.length; i++) { + result[i] = text.charCodeAt(i) & 0xff + } + return result as Bytes +} + +export function hexToBytes(text: string): Bytes { + const hex = text.replace(/\s+/g, '') + if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) { + throw new Error('无效的十六进制数据') + } + const result = new Uint8Array(hex.length / 2) + for (let i = 0; i < result.length; i++) { + result[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return result as Bytes +} + +export function uintBytes(value: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`无效的无符号整数: ${value}`) + } + if (value === 0) return new Uint8Array([0]) as Bytes + + const result: number[] = [] + let current = value + while (current > 0) { + result.unshift(current & 0xff) + current = Math.floor(current / 256) + } + return new Uint8Array(result) as Bytes +} + +export function be16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setUint16(0, value) + return result as Bytes +} + +export function be32(value: number): Bytes { + const result = new Uint8Array(4) + new DataView(result.buffer).setUint32(0, value >>> 0) + return result as Bytes +} + +export function be64(value: number | bigint): Bytes { + const result = new Uint8Array(8) + new DataView(result.buffer).setBigUint64(0, BigInt(value)) + return result as Bytes +} + +export function findBytes( + buffer: Uint8Array, + fourcc: string, + from = 0, + to = buffer.length - 4, +): number { + if (fourcc.length !== 4) throw new Error(`fourcc 必须是 4 个字符: ${fourcc}`) + const c0 = fourcc.charCodeAt(0) + const c1 = fourcc.charCodeAt(1) + const c2 = fourcc.charCodeAt(2) + const c3 = fourcc.charCodeAt(3) + for (let i = from; i <= to; i++) { + if ( + buffer[i] === c0 && + buffer[i + 1] === c1 && + buffer[i + 2] === c2 && + buffer[i + 3] === c3 + ) return i + } + return -1 +} + +export function strBytes(text: string): Bytes { + return new TextEncoder().encode(text) as Bytes +} + +export function readFourcc(buffer: Uint8Array, offset: number): string { + return String.fromCharCode( + buffer[offset] ?? 0, + buffer[offset + 1] ?? 0, + buffer[offset + 2] ?? 0, + buffer[offset + 3] ?? 0, + ) +} diff --git a/src/player/cenc.ts b/src/player/cenc.ts new file mode 100644 index 0000000..0945b24 --- /dev/null +++ b/src/player/cenc.ts @@ -0,0 +1,287 @@ +import { asBytes, type Bytes, hexToBytes, findBytes, readFourcc } from './bytes' +import { getSubtle } from '../crypto' + +export type CencCodec = 'opus' | 'aac' | 'flac' + +export interface CencMetadata { + codec: CencCodec + sampleCount: number + sizes: Uint32Array + /** 每个采样的 8/16 字节 CENC IV,按 sample 顺序拼接 */ + ivs: Bytes + durations: Uint32Array + timescale: number + channels: number + preSkip: number + /** mdat 的负载起点,相对于 head buffer */ + mdatStart: number + /** 每个 sample 在 mdat 负载中的累计偏移,长度为 sampleCount + 1 */ + offsets: Float64Array + totalTicks: number +} + +export interface CencKey { + key: CryptoKey + /** AES-CTR counter 的低位初值,CENC 常见为 0;某些流使用 1 */ + counterStart: 0 | 1 +} + +interface IsoBox { + type: string + offset: number + size: number + headerSize: number + end: number +} + +const CONTAINERS = new Set([ + 'moov', 'trak', 'mdia', 'minf', 'stbl', 'edts', 'dinf', 'udta', 'mvex', 'meta', +]) + +function readBox(buffer: Uint8Array, offset: number, limit: number): IsoBox | null { + if (offset + 8 > limit) return null + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + let headerSize = 8 + let size: number + + if (size32 === 1) { + if (offset + 16 > limit) return null + const extended = view.getBigUint64(offset + 8) + if (extended > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`MP4 box 过大: ${type}`) + } + size = Number(extended) + headerSize = 16 + } else if (size32 === 0) { + size = limit - offset + } else { + size = size32 + } + + if (size < headerSize || offset + size > limit) return null + return { type, offset, size, headerSize, end: offset + size } +} + +function walkBoxes( + buffer: Uint8Array, + start: number, + end: number, + visit: (box: IsoBox) => void, + depth = 0, +): void { + if (depth > 16) return + let offset = start + while (offset + 8 <= end) { + const box = readBox(buffer, offset, end) + if (!box) return + visit(box) + if (CONTAINERS.has(box.type)) { + // meta 是 FullBox,版本/flags 后才开始嵌套 box。 + const childStart = box.offset + box.headerSize + (box.type === 'meta' ? 4 : 0) + if (childStart < box.end) walkBoxes(buffer, childStart, box.end, visit, depth + 1) + } + offset = box.end + } +} + +function findBox(boxes: readonly IsoBox[], type: string): IsoBox | undefined { + return boxes.find(box => box.type === type) +} + +/** 读取顶层 mdat 的负载起点,即使 mdat 本体不在当前 head buffer 内也能定位。 */ +function findMdatStart(buffer: Uint8Array): number { + let offset = 0 + while (offset + 8 <= buffer.length) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + const headerSize = size32 === 1 ? 16 : 8 + if (type === 'mdat') return offset + headerSize + if (size32 === 0) return -1 + if (size32 === 1) { + if (offset + 16 > buffer.length) return -1 + const size = view.getBigUint64(offset + 8) + if (size > BigInt(Number.MAX_SAFE_INTEGER)) return -1 + offset += Number(size) + } else { + offset += size32 + } + } + return -1 +} + +function parseSampleSizes(box: IsoBox, view: DataView): Uint32Array { + if (box.offset + 20 > box.end) throw new Error('stsz box 不完整') + const sampleSize = view.getUint32(box.offset + 12) + const count = view.getUint32(box.offset + 16) + const sizes = new Uint32Array(count) + if (sampleSize !== 0) { + sizes.fill(sampleSize) + return sizes + } + if (box.offset + 20 + count * 4 > box.end) throw new Error('stsz 采样表不完整') + for (let i = 0; i < count; i++) sizes[i] = view.getUint32(box.offset + 20 + i * 4) + return sizes +} + +function parseDurations(box: IsoBox, sampleCount: number, view: DataView): Uint32Array { + if (box.offset + 16 > box.end) throw new Error('stts box 不完整') + const entryCount = view.getUint32(box.offset + 12) + const durations = new Uint32Array(sampleCount) + let index = 0 + for (let i = 0; i < entryCount; i++) { + const offset = box.offset + 16 + i * 8 + if (offset + 8 > box.end) throw new Error('stts 采样表不完整') + const count = view.getUint32(offset) + const duration = view.getUint32(offset + 4) + for (let j = 0; j < count && index < sampleCount; j++) durations[index++] = duration + } + if (index !== sampleCount) throw new Error(`stts 采样数不一致: ${index}/${sampleCount}`) + return durations +} + +function parseIvs(box: IsoBox, sampleCount: number, view: DataView): Bytes { + if (box.offset + 16 > box.end) throw new Error('senc box 不完整') + const flags = view.getUint32(box.offset + 8) & 0xffffff + if ((flags & 0x02) !== 0) throw new Error('暂不支持带 subsample encryption 的音频') + const count = view.getUint32(box.offset + 12) + if (count !== sampleCount) throw new Error(`senc 采样数不一致: ${count}/${sampleCount}`) + const dataStart = box.offset + 16 + const dataLength = box.end - dataStart + if (count === 0 || dataLength % count !== 0) throw new Error('senc IV 表不完整') + const ivLength = dataLength / count + if (ivLength !== 8 && ivLength !== 16) throw new Error(`不支持的 CENC IV 长度: ${ivLength}`) + return asBytes(new Uint8Array(view.buffer, view.byteOffset + dataStart, dataLength)) +} + +function parseTimescale(box: IsoBox, view: DataView): number { + if (box.offset + 24 > box.end) throw new Error('mdhd box 不完整') + const version = view.getUint8(box.offset + 8) + const offset = version === 1 ? box.offset + 28 : box.offset + 20 + if (offset + 4 > box.end) throw new Error('mdhd timescale 缺失') + const timescale = view.getUint32(offset) + if (timescale === 0) throw new Error('MP4 timescale 为 0') + return timescale +} + +function parseCodec(buffer: Uint8Array, mdatStart: number): Pick { + const end = Math.max(0, Math.min(mdatStart - 1, buffer.length - 4)) + const dOps = findBytes(buffer, 'dOps', 0, end) + if (dOps >= 0) { + const channels = buffer[dOps + 5] ?? 1 + const preSkip = ((buffer[dOps + 6] ?? 0) << 8) | (buffer[dOps + 7] ?? 0) + return { codec: 'opus', channels, preSkip } + } + + // AudioSampleEntry 的 channelcount 位于 type 后 20 字节;加密入口也保留这段布局。 + const encodedEntry = findBytes(buffer, 'enca', 0, end) + const plainEntry = findBytes(buffer, 'mp4a', 0, end) + const entryType = encodedEntry >= 0 ? encodedEntry : plainEntry + const channels = entryType >= 0 && entryType + 22 <= buffer.length + ? new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16(entryType + 20) + : 1 + if (findBytes(buffer, 'dfLa', 0, end) >= 0) { + return { codec: 'flac', channels: channels || 1, preSkip: 0 } + } + if (entryType >= 0) { + return { codec: 'aac', channels: channels || 1, preSkip: 0 } + } + throw new Error('未识别的音频编码(需要 Opus、AAC 或 FLAC)') +} + +/** 解析一份已包含 moov 元数据的 MP4 CENC 头。 */ +export function parseCencMetadata(head: Uint8Array): CencMetadata { + const mdatStart = findMdatStart(head) + if (mdatStart < 0) throw new Error('未找到 mdat,MP4 头可能尚未拉完整') + + const boxes: IsoBox[] = [] + walkBoxes(head, 0, Math.min(mdatStart - 8, head.length), box => boxes.push(box)) + const stsz = findBox(boxes, 'stsz') + const stts = findBox(boxes, 'stts') + const senc = findBox(boxes, 'senc') + const mdhd = findBox(boxes, 'mdhd') + if (!stsz || !stts || !senc || !mdhd) { + throw new Error('CENC MP4 头缺少 stsz/stts/senc/mdhd') + } + + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const sizes = parseSampleSizes(stsz, view) + const durations = parseDurations(stts, sizes.length, view) + const ivs = parseIvs(senc, sizes.length, view) + const timescale = parseTimescale(mdhd, view) + const codec = parseCodec(head, mdatStart) + const offsets = new Float64Array(sizes.length + 1) + for (let i = 0; i < sizes.length; i++) { + offsets[i + 1] = offsets[i]! + sizes[i]! + if (!Number.isSafeInteger(offsets[i + 1])) throw new Error('音频采样数据超过 JavaScript 安全整数范围') + } + + let totalTicks = 0 + for (const duration of durations) { + totalTicks += duration + if (!Number.isSafeInteger(totalTicks)) throw new Error('音频总时长超过 JavaScript 安全整数范围') + } + return { + ...codec, + sampleCount: sizes.length, + sizes, + ivs, + durations, + timescale, + mdatStart, + offsets, + totalTicks, + } +} + +export function cencIvSize(meta: CencMetadata): number { + return meta.ivs.length / meta.sampleCount +} + +export function normalizeCencKey(value: string | ArrayBuffer | Uint8Array): Bytes { + if (typeof value === 'string') { + const bytes = hexToBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 32 位 hex') + return bytes + } + const bytes = asBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 16 字节') + return bytes.slice() as Bytes +} + +export async function importCencKey(value: string | ArrayBuffer | Uint8Array): Promise { + return getSubtle().importKey( + 'raw', + normalizeCencKey(value), + { name: 'AES-CTR' }, + false, + ['decrypt'], + ) +} + +/** 按 CENC 的 8/16 字节 IV 构造 Web Crypto 的 16 字节 counter。 */ +export function makeCencCounter(iv: Uint8Array, counterStart: 0 | 1): Bytes { + if (iv.length !== 8 && iv.length !== 16) throw new Error(`无效的 CENC IV 长度: ${iv.length}`) + const counter = new Uint8Array(16) + counter.set(iv) + if (counterStart === 1) counter[15] = 1 + return counter as Bytes +} + +export async function decryptCencSample( + key: CryptoKey, + iv: Uint8Array, + ciphertext: Uint8Array, + counterStart: 0 | 1, +): Promise { + // TS 6 会把普通 Uint8Array 视为可能由 SharedArrayBuffer 支撑;Web Crypto 不接受它。 + const data = asBytes(ciphertext) + const plain = await getSubtle().decrypt( + { name: 'AES-CTR', counter: makeCencCounter(iv, counterStart), length: 64 }, + key, + data, + ) + return new Uint8Array(plain) as Bytes +} diff --git a/src/player/gmFetch.ts b/src/player/gmFetch.ts new file mode 100644 index 0000000..1c5c4a6 --- /dev/null +++ b/src/player/gmFetch.ts @@ -0,0 +1,191 @@ +// 音频 CDN 的取流通道。 +// +// 为什么不用页面 fetch:番茄网页站的 CSP(report-only)里 connect-src 没有任何 +// *vod.com 域名,音频 CDN 一个都不在白名单上。report-only 不会真拦请求,但每次 +// 都会往 mon.zijieapi.com/monitor_browser/collect/batch/security/ 上报一条违规, +// 等于把听书行为连着 URL 一起报给服务端。所以走 GM_xmlhttpRequest: +// 它在页面 CSP 之外,既不触发上报,也天然不带页面 Referer 和 Cookie。 +// +// 试过换 aid 让服务端返回白名单内的域名,行不通 —— CDN 域名跟着 aid 所属产品走: +// 1967 番茄小说 -> *.fqnovelvod.com 3040 番茄畅听 -> *.novelfmvod.com +// 13/35 头条 -> *.toutiaovod.com 6589 -> *.wkbrowser.com +// 1217/1128/8478/32/596084 -> PARAM_INVALID(不支持听书接口) +// 没有一个落在 connect-src 里,而且签名里的 aid 必须与 query 一致, +// 否则网关直接回 200 空响应体。 + +type AnyResponse = Tampermonkey.Response & { response?: unknown } + +/** + * 用 GM_xmlhttpRequest 实现的、够播放器用的 fetch 子集。 + * + * 只支持播放器实际用到的部分:GET + Range + AbortSignal + 流式 body。 + * + * 关于 responseType 的坑(踩过两次,记在这): + * - `'stream'`:TM 会把 ReadableStream 放进 res.response,但**不是**在 + * readyState 2 那一刻就绪。在 readyState 2 判断 instanceof 永远失败。 + * - `'arraybuffer'`:res.response 只在 readyState 4 才有值,onprogress + * 期间一直是 null,拿不到增量。 + * - `responseText`:绝对不能用来还原二进制。它按字符解码过,>0xFF 的码位 + * 已经丢了原始字节,喂进 MSE 会直接报 SourceBuffer 更新失败。 + * 所以这里两种都试:优先等 'stream',拿不到就退回整段 arraybuffer。 + */ +export function gmAudioFetch( + input: string | URL | Request, + init: RequestInit = {}, +): Promise { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const headers = normalizeHeaders(init.headers) + const signal = init.signal ?? null + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError()) + return + } + + let settled = false + let request: { abort: () => void } | undefined + + const onAbort = (): void => { + try { request?.abort() } catch { /* 尚未开始或已结束 */ } + if (settled) return + settled = true + reject(abortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + + const cleanup = (): void => { + signal?.removeEventListener('abort', onAbort) + } + + const succeed = (res: Tampermonkey.ResponseBase, body: BodyInit | null): void => { + if (settled) return + settled = true + cleanup() + resolve(makeResponse(res, body)) + } + + const fail = (error: Error): void => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + request = GM_xmlhttpRequest({ + method: 'GET', + url, + headers, + responseType: 'stream', + // GM 请求不带页面 Referer;anonymous 同时也不带 Cookie + anonymous: true, + onreadystatechange(res) { + // readyState 3/4 时 TM 才把 stream 挂上来。拿到就立刻交出去, + // 播放器那边就能边下边解密,不必等整段下完 + if (settled || res.readyState < 3) return + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) succeed(res, stream) + }, + onload(res) { + if (settled) return + // 没走成 stream:这时 readyState 已是 4,response 里是完整数据 + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) { + succeed(res, stream) + return + } + const bytes = toBytes(res) + if (!bytes) { + fail(new Error( + 'GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)', + )) + return + } + if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) { + fail(new Error('音频响应为空')) + return + } + succeed(res, bytes as unknown as BodyInit) + }, + onerror(res) { + fail(new Error(`音频 CDN 请求失败:${describe(res)}`)) + }, + ontimeout() { + fail(new Error('音频 CDN 请求超时')) + }, + }) as unknown as { abort: () => void } + }) +} + +function abortError(): DOMException { + return new DOMException('音频请求已取消', 'AbortError') +} + +/** 把 GM 的响应头文本解析成 Headers */ +function parseHeaders(raw: string | undefined): Headers { + const headers = new Headers() + if (!raw) return headers + for (const line of raw.split(/\r?\n/)) { + const colon = line.indexOf(':') + if (colon <= 0) continue + const name = line.slice(0, colon).trim() + const value = line.slice(colon + 1).trim() + if (!name) continue + try { + headers.append(name, value) + } catch { + // 个别响应头名字不合规范(比如带空格),跳过即可 + } + } + return headers +} + +/** + * 用 GM 的状态和响应头拼一个 Response。 + * + * 播放器只读 status / ok / body,不需要还原全部语义。 + * body 为空时给一条空流:206 是合法状态,但 `new Response(null, {status:206})` + * 会抛错(只有 204/304 允许空 body)。 + */ +function makeResponse(res: Tampermonkey.ResponseBase, body: BodyInit | null): Response { + return new Response(body ?? new Uint8Array(), { + status: res.status || 200, + statusText: res.statusText || '', + headers: parseHeaders(res.responseHeaders), + }) +} + +/** + * 从 GM 响应里取出完整字节。 + * + * 只认真正的二进制载荷 —— 见文件头关于 responseText 的说明, + * 拿不到就返回 null 让上层报错,比悄悄喂坏数据好。 + */ +function toBytes(res: Tampermonkey.Response): Uint8Array | null { + const raw = (res as AnyResponse).response + if (raw instanceof ArrayBuffer) return new Uint8Array(raw) + if (raw instanceof Uint8Array) return raw + return null +} + +function normalizeHeaders(init: HeadersInit | undefined): Record { + const out: Record = {} + if (!init) return out + if (init instanceof Headers) { + init.forEach((value, key) => { out[key] = value }) + return out + } + if (Array.isArray(init)) { + for (const [key, value] of init) { + if (key !== undefined && value !== undefined) out[key] = value + } + return out + } + return { ...init } +} + +function describe(res: Tampermonkey.ErrorResponse | Tampermonkey.ResponseBase): string { + if ('error' in res && res.error) return String(res.error) + const status = (res as Tampermonkey.ResponseBase).status + return status ? `HTTP ${status}` : '网络错误' +} diff --git a/src/player/index.ts b/src/player/index.ts new file mode 100644 index 0000000..01ff874 --- /dev/null +++ b/src/player/index.ts @@ -0,0 +1,24 @@ +/** + * CENC 听书播放器。 + * + * 最小用法: + * const player = new CencAudioPlayer(audioElement) + * await player.start({ url, key }) + * // 切章:再次 start;离开听书页:player.destroy() + */ +export { + CencAudioPlayer, + type CencAudioPlayerCallbacks, + type CencAudioPlayerOptions, + type CencAudioSource, + type CencMediaInfo, + type CencPlayerState, + type CencProgress, +} from './CencAudioPlayer' + +export { + normalizeCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' diff --git a/src/player/remux.ts b/src/player/remux.ts new file mode 100644 index 0000000..b419d33 --- /dev/null +++ b/src/player/remux.ts @@ -0,0 +1,260 @@ +import { ascii, be16, be32, be64, concatBytes, findBytes, hexToBytes, strBytes, uintBytes, type Bytes } from './bytes' +import type { CencCodec, CencMetadata } from './cenc' + +function ebmlVint(value: number, length: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0 || length < 1 || length > 8) { + throw new Error(`无效的 EBML VINT: ${value}`) + } + const result = new Uint8Array(length) + result[0] = 0x80 >> (length - 1) + let current = value + for (let i = length - 1; i > 0; i--) { + result[i] = current & 0xff + current = Math.floor(current / 256) + } + const max = Math.pow(2, 7 * length) + if (current >= max) throw new Error(`EBML VINT 溢出: ${value}`) + result[0] |= current + return result as Bytes +} + +/** + * 编码 EBML 元素长度。 + * + * 关键约束:全 1 的 VINT(1 字节的 0xFF、2 字节的 0x7FFF……)在 EBML 里是保留值, + * 表示「长度未知」。所以 2^(7n)-1 这些边界值必须多用一个字节来编, + * 否则解析器会把这个元素当成 unknown-size,从它开始一路吞掉后面的数据。 + * + * 踩过的坑:Opus 帧正好 127 字节时 SimpleBlock 的 payload 长度就是 127, + * 编成 0xFF 之后 Chromium 报 CHUNK_DEMUXER_ERROR_APPEND_FAILED, + * ffmpeg 报 "Unknown-sized element inside parent with finite size", + * 750 帧里只解出 8 帧。 + */ +function ebmlSize(value: number): Bytes { + for (let length = 1; length <= 8; length++) { + // < 2^(7n)-1:留出全 1 这个保留值,等于时进位到下一个长度 + if (value < Math.pow(2, 7 * length) - 1) return ebmlVint(value, length) + } + throw new Error(`EBML size 过大: ${value}`) +} + +function ebmlElement(id: string, payload: Uint8Array): Bytes { + return concatBytes(hexToBytes(id), ebmlSize(payload.length), payload) +} + +function mp4Box(type: string, payload: Uint8Array): Bytes { + return concatBytes(be32(8 + payload.length), ascii(type), payload) +} + +function matrixIdentity(): Bytes { + const matrix = new Uint8Array(36) + const view = new DataView(matrix.buffer) + view.setUint32(0, 0x10000) + view.setUint32(16, 0x10000) + view.setUint32(32, 0x40000000) + return matrix as Bytes +} + +function signedBe16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setInt16(0, value) + return result as Bytes +} + +/** 构造只包含 Opus 音轨的 WebM 初始化段。 */ +export function buildWebmInit(channels: number, preSkip: number, durationSeconds: number): Bytes { + const ebml = ebmlElement('1A45DFA3', concatBytes( + ebmlElement('4286', hexToBytes('01')), + ebmlElement('42F7', hexToBytes('01')), + ebmlElement('42F2', hexToBytes('04')), + ebmlElement('42F3', hexToBytes('08')), + ebmlElement('4282', strBytes('webm')), + ebmlElement('4287', hexToBytes('04')), + ebmlElement('4285', hexToBytes('02')), + )) + const codecDelayNs = Math.round(preSkip * 1e9 / 48000) + const duration = new Uint8Array(8) + new DataView(duration.buffer).setFloat64(0, durationSeconds, false) + const info = ebmlElement('1549A966', concatBytes( + ebmlElement('2AD7B1', uintBytes(1000000)), + ebmlElement('4489', duration as Bytes), + ebmlElement('4D80', strBytes('clearKeyStreamer')), + ebmlElement('5741', strBytes('clearKeyStreamer')), + )) + const sampleRate = new Uint8Array(4) + new DataView(sampleRate.buffer).setFloat32(0, 48000, false) + const audio = ebmlElement('E1', concatBytes( + ebmlElement('B5', sampleRate as Bytes), + ebmlElement('9F', uintBytes(channels)), + )) + const track = ebmlElement('AE', concatBytes( + ebmlElement('D7', hexToBytes('01')), + ebmlElement('73C5', uintBytes(1)), + ebmlElement('83', hexToBytes('02')), + ebmlElement('86', strBytes('A_OPUS')), + ebmlElement('63A2', buildOpusHead(channels, preSkip)), + ebmlElement('56AA', uintBytes(codecDelayNs)), + ebmlElement('56BB', uintBytes(80000000)), + audio, + )) + const tracks = ebmlElement('1654AE6B', track) + // Unknown-size Segment permits later clusters and seek-triggered re-buffering. + return concatBytes(ebml, hexToBytes('18538067'), hexToBytes('01FFFFFFFFFFFFFF'), info, tracks) +} + +function buildOpusHead(channels: number, preSkip: number): Bytes { + const head = new Uint8Array(19) + head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0) + const view = new DataView(head.buffer) + head[8] = 1 + head[9] = channels + view.setUint16(10, preSkip, true) + view.setUint32(12, 48000, true) + view.setInt16(16, 0, true) + head[18] = 0 + return head as Bytes +} + +/** 构造一个包含若干 Opus Block 的 WebM Cluster。 */ +export function buildWebmCluster(baseMs: number, relativeMs: readonly number[], packets: readonly Uint8Array[]): Bytes { + if (relativeMs.length !== packets.length) throw new Error('WebM 时间戳和采样数不一致') + const blocks: Bytes[] = [] + for (let i = 0; i < packets.length; i++) { + const relative = relativeMs[i] + if (relative === undefined || relative < -32768 || relative > 32767) { + throw new Error(`WebM 相对时间戳超出 Int16: ${relative}`) + } + const trackAndFlags = new Uint8Array([0x81, 0x80]) + blocks.push(ebmlElement('A3', concatBytes( + trackAndFlags.slice(0, 1), + signedBe16(relative), + trackAndFlags.slice(1), + packets[i]!, + ))) + } + return ebmlElement('1F43B675', concatBytes( + ebmlElement('E7', uintBytes(baseMs)), + concatBytes(...blocks), + )) +} + +function replaceFourcc(buffer: Bytes, offset: number, type: string): void { + buffer.set(ascii(type), offset) +} + +/** 从原始 MP4 头复制采样入口,并把加密入口改回浏览器可识别的明文入口。 */ +export function extractSampleEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + const plain = findBytes(head, 'mp4a', 0, mdatStart - 1) + const typeOffset = encoded >= 0 ? encoded : plain + if (typeOffset < 4) throw new Error('未找到 AAC 音频采样入口') + const entryOffset = typeOffset - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('AAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + const enca = findBytes(entry, 'enca', 0) + if (enca >= 0) replaceFourcc(entry, enca, 'mp4a') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 从原始 MP4 头复制 FLAC 采样入口,并去掉 CENC 标记。 */ +export function extractFlacEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + if (encoded < 4) throw new Error('未找到 FLAC 加密采样入口') + const entryOffset = encoded - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('FLAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + replaceFourcc(entry, encoded - entryOffset, 'fLaC') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 构造 AAC/FLAC 的 fragmented MP4 初始化段。 */ +export function buildMp4Init(sampleEntry: Uint8Array, timescale: number, totalTicks: number): Bytes { + const totalSeconds = totalTicks / timescale + const matrix = matrixIdentity() + const mvhd = mp4Box('mvhd', concatBytes( + be32(0), be32(0), be32(0), be32(1000), be32(Math.round(totalSeconds * 1000)), + be32(0x10000), be16(0x100), be16(0), new Uint8Array(8), + matrix, new Uint8Array(24), be32(2), + )) + const tkhd = mp4Box('tkhd', concatBytes( + be32(7), be32(0), be32(0), be32(1), be32(0), be32(0), new Uint8Array(8), + be16(0), be16(0), be16(0x100), be16(0), matrix, be32(0), be32(0), + )) + const mdhd = mp4Box('mdhd', concatBytes( + be32(0), be32(0), be32(0), be32(timescale), be32(totalTicks), be16(0x55c4), be16(0), + )) + const hdlr = mp4Box('hdlr', concatBytes( + be32(0), be32(0), ascii('soun'), new Uint8Array(12), + )) + const smhd = mp4Box('smhd', concatBytes(be32(0), be16(0), be16(0))) + const url = mp4Box('url ', be32(1)) + const dref = mp4Box('dref', concatBytes(be32(0), be32(1), url)) + const dinf = mp4Box('dinf', dref) + const stsd = mp4Box('stsd', concatBytes(be32(0), be32(1), sampleEntry)) + const stts = mp4Box('stts', concatBytes(be32(0), be32(0))) + const stsc = mp4Box('stsc', concatBytes(be32(0), be32(0))) + const stsz = mp4Box('stsz', concatBytes(be32(0), be32(0), be32(0))) + const stco = mp4Box('stco', concatBytes(be32(0), be32(0))) + const stbl = mp4Box('stbl', concatBytes(stsd, stts, stsc, stsz, stco)) + const minf = mp4Box('minf', concatBytes(smhd, dinf, stbl)) + const mdia = mp4Box('mdia', concatBytes(mdhd, hdlr, minf)) + const trak = mp4Box('trak', concatBytes(tkhd, mdia)) + const trex = mp4Box('trex', concatBytes(be32(0), be32(1), be32(1), be32(0), be32(0), be32(0))) + const mvex = mp4Box('mvex', trex) + const moov = mp4Box('moov', concatBytes(mvhd, trak, mvex)) + const ftyp = mp4Box('ftyp', concatBytes(ascii('isom'), be32(512), ascii('isomiso2mp41dash'))) + return concatBytes(ftyp, moov) +} + +export interface Mp4Sample { + data: Uint8Array + duration: number +} + +/** 构造一个 fragmented MP4 media segment。 */ +export function buildMp4Segment( + baseDecodeTick: number, + samples: readonly Mp4Sample[], + sequence: number, +): Bytes { + if (samples.length === 0) throw new Error('MP4 segment 不能没有采样') + const sampleTable = samples.flatMap(sample => [be32(sample.duration), be32(sample.data.length)]) + const mfhd = mp4Box('mfhd', concatBytes(be32(0), be32(sequence))) + const tfhd = mp4Box('tfhd', concatBytes(be32(0x020000), be32(1))) + const tfdt = mp4Box('tfdt', concatBytes(be32(0x01000000), be64(baseDecodeTick))) + const makeTrun = (offset: number) => mp4Box('trun', concatBytes( + be32(0x000301), be32(samples.length), be32(offset), concatBytes(...sampleTable), + )) + const makeMoof = (offset: number) => mp4Box('moof', concatBytes( + mfhd, mp4Box('traf', concatBytes(tfhd, tfdt, makeTrun(offset))), + )) + const mediaPayload = concatBytes(...samples.map(sample => sample.data)) + const dataOffset = makeMoof(0).length + 8 + return concatBytes(makeMoof(dataOffset), mp4Box('mdat', mediaPayload)) +} + +export function codecMime(codec: CencCodec): string { + if (codec === 'opus') return 'audio/webm;codecs="opus"' + if (codec === 'flac') return 'audio/mp4; codecs="fLaC"' + return 'audio/mp4; codecs="mp4a.40.2"' +} + +export function initSegment( + meta: CencMetadata, + sampleEntry?: Uint8Array, +): Bytes { + if (meta.codec === 'opus') { + const playableTicks = Math.max(0, meta.totalTicks - meta.preSkip) + return buildWebmInit(meta.channels, meta.preSkip, playableTicks / meta.timescale) + } + if (!sampleEntry) throw new Error('MP4 编码缺少 sample entry') + return buildMp4Init(sampleEntry, meta.timescale, meta.totalTicks) +} diff --git a/src/settings.ts b/src/settings.ts index bf3c5c4..05de70b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,9 +9,15 @@ export type ApiPreference = 'app' | 'redcandle' /** 下载格式 */ export type DownloadFormat = 'epub' | 'txt' +/** 书架里单击书本的去向 */ +export type BookshelfClickAction = 'read' | 'detail' + /** TXT 编码 */ export type DownloadCharset = 'utf-8' | 'gbk' +/** 听书读完一章之后做什么 */ +export type AudiobookChapterEnd = 'next' | 'stop' + export interface Settings { /* --- 常规 --- */ /** 解密网页端混淆字体 */ @@ -28,6 +34,11 @@ export interface Settings { customCssEnabled: boolean /** 自定义 CSS 内容。关闭开关时仍然保留,只是不应用 */ customCss: string + /** + * 书架里单击书本的默认去向。 + * 按住 Ctrl 单击取相反项;中键(或 Ctrl+中键)在新标签页打开对应目标。 + */ + bookshelfClickAction: BookshelfClickAction /* --- 搜索 --- */ /** 接管网页搜索界面 */ @@ -55,6 +66,12 @@ export interface Settings { /** EPUB:保留书籍自带的排版样式(css_map) */ downloadBookCss: boolean + /* --- 听书 --- */ + /** 读完一章之后:next=自动下一章,stop=停下 */ + audiobookChapterEnd: AudiobookChapterEnd + /** 高亮朗读中的段落,并跟随滚动 */ + audiobookFollow: boolean + /* --- 协议 --- */ apiPreference: ApiPreference /** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */ @@ -71,6 +88,8 @@ export const DEFAULT_SETTINGS: Settings = { readerFont: '', customCssEnabled: false, customCss: '', + // 原站书架点封面是继续阅读,保持一致 + bookshelfClickAction: 'read', enhanceSearch: true, // 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启 @@ -88,6 +107,9 @@ export const DEFAULT_SETTINGS: Settings = { downloadImages: true, downloadBookCss: true, + audiobookChapterEnd: 'next', + audiobookFollow: true, + apiPreference: 'app', deviceId: '', installId: '', @@ -116,6 +138,12 @@ function normalize(raw: unknown): Settings { if (s.downloadCharset !== 'utf-8' && s.downloadCharset !== 'gbk') { s.downloadCharset = DEFAULT_SETTINGS.downloadCharset } + if (s.bookshelfClickAction !== 'read' && s.bookshelfClickAction !== 'detail') { + s.bookshelfClickAction = DEFAULT_SETTINGS.bookshelfClickAction + } + if (s.audiobookChapterEnd !== 'next' && s.audiobookChapterEnd !== 'stop') { + s.audiobookChapterEnd = DEFAULT_SETTINGS.audiobookChapterEnd + } // 数字项来自输入框,可能是 NaN 或越界值 s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize) s.downloadInterval = clampInt(s.downloadInterval, 0, 10_000, DEFAULT_SETTINGS.downloadInterval) diff --git a/src/types.ts b/src/types.ts index b2c01d0..35b3934 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,3 +210,131 @@ export interface SearchLandingSection { words: SearchHotWord[] books: SearchSuggestBook[] } + +/* --------------------------------- 书评 --------------------------------- */ + +/** 评论作者。头衔(vip/作者等)接口下发的是内嵌 JSON 字符串,这里只留解析后的名字 */ +export interface CommentUser { + user_id: string + name: string + avatar: string + description: string + is_author: boolean + is_vip: boolean + /** 头衔名,如 vip、榜一大哥 */ + titles: string[] +} + +/** + * 正文里的富文本片段(书名号引用、话题等)。 + * + * s/e 是 UTF-16 码元下标(实测与 String.prototype.slice 一致, + * 不是 UTF-8 字节,也不是码点序号)。tp 目前只见过 4 = 搜索跳转。 + */ +export interface CommentTextExt { + start: number + end: number + text: string + type: number + /** dragon1967:// 开头的 APP 内部 schema,网页端点不了,仅保留原文 */ + uri?: string +} + +/** 评论与回复共有的部分 */ +export interface CommentBase { + id: string + text: string + /** 富文本片段,可能为空 */ + exts: CommentTextExt[] + /** 秒级时间戳 */ + create_time: number + user: CommentUser + digg_count: number + reply_count: number + /** 当前用户是否赞过。未登录(或读不到 sessionid)时恒为 false */ + user_digg: boolean + user_disagree: boolean +} + +/** 一楼书评 */ +export interface BookComment extends CommentBase { + /** 评分,1-10,缺失为 0 */ + score: number + /** 接口下发的展示文案,如「阅读19小时后点评」 */ + score_text: string + /** 作者置顶 */ + author_stick: boolean + /** 追评。同一用户在原评论之后补写的内容 */ + addition?: CommentBase + /** 接口下发的前几条回复,展开前先用它 */ + preview_reply_count: number +} + +/** 楼中楼回复。二级回复挂在 sub 里 */ +export interface CommentReply extends CommentBase { + /** 所属评论 id */ + to_comment_id: string + /** 二级回复时,父回复的 id */ + to_reply_id: string + /** 接口预览下发的子回复 */ + sub: CommentReply[] +} + +/** 书评区筛选标签,如「长评」「文笔很好」 */ +export interface CommentFilterTag { + tag_id: string + tag_name: string + count: number +} + +/** 评论排序。1=最热,3=最新(其余值接口报参数错误) */ +export type CommentSort = 1 | 3 + +export interface CommentListResult { + comments: BookComment[] + /** 服务端游标,原样回传即可翻页 */ + cursor: string + has_more: boolean + /** 该书评论总数(受 tag 过滤影响) */ + total: number + tags: CommentFilterTag[] + /** 点评人数展示文案,如「1.1万人点评」 */ + score_text: string + score_count: number + /** + * 当前登录用户自己写的那条书评 / 打分。 + * 服务端把它单独放在 extra.user_comment,不混在列表里; + * 没登录或还没点评过时为 null。打过分后即可用它回显星级。 + */ + user_comment: BookComment | null +} + +export interface ReplyListResult { + replies: CommentReply[] + cursor: string + has_more: boolean + total: number +} + +export interface CencAudioContext { + urls: string[] + vid: string + key: string // empty for a not encrypted audio + item_id: string +} + +export interface Tone { + id: number + name: string + icon: string // 空字符串是没有图标 + gender: number // 1=男声 2=女声 + description: string +} + +export interface ParagraphTimeTag { + is_title: boolean // idx=10000 就是标题 + startidx: number + endidx: number + startms: number + endms: number +} \ No newline at end of file diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts new file mode 100644 index 0000000..1d9e20c --- /dev/null +++ b/src/utils/cookie.ts @@ -0,0 +1,127 @@ +// Cookie 读取。 +// +// 网页站的 sessionid 是 HttpOnly,document.cookie 读不到,而书评接口挂在 +// reading.snssdk.com 根路径(网页站只把 /reading 反代到了 APP 网关), +// 拿不到同源那份 Cookie,只能自己把 sessionid 塞进请求头。 +// +// GM_cookie 能读 HttpOnly,但 Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, +// 需要用户手动放开: +// 设置 → 通用 → 配置模式 → 高级 +// 设置 → 高级 → 安全 → 允许脚本访问 Cookie → All +// 见 https://github.com/Tampermonkey/tampermonkey/issues/465#issuecomment-2479061865 +// +// 没有任何接口能告诉我们「是不是被这个开关挡住了」,所以只能反推: +// 用户明明是登录状态(checkLogin 通过),却读不到 sessionid —— 那就是被挡了。 + +/** 放开 Cookie 访问的操作路径,取不到 sessionid 时提示用户 */ +export const COOKIE_PERMISSION_HINT = + '读不到登录态(sessionid)。若确认已登录,请在 Tampermonkey 里放开 Cookie 访问:' + + '设置 → 通用 → 配置模式 改为「高级」,再到 设置 → 高级 → 安全 →' + + '「允许脚本访问 Cookie」改为 All。' + +/** GM_cookie.list 迟迟不回调时的等待上限 */ +const LIST_TIMEOUT = 5_000 + +export type CookieFailure = + /** 脚本管理器不支持,或没授权 GM_cookie */ + | 'no-api' + /** GM_cookie.list 报错或超时 */ + | 'api-error' + /** 接口正常但没有这条 Cookie(未登录,或被 HttpOnly 开关挡住) */ + | 'not-found' + +export interface CookieLookup { + value: string | null + reason?: CookieFailure + /** api-error 时的原始错误信息 */ + error?: string +} + +function hasGMCookie(): boolean { + // @grant GM_cookie 缺失时这个标识符压根不存在,typeof 才不会抛 + return typeof GM_cookie !== 'undefined' && typeof GM_cookie?.list === 'function' +} + +/** 把回调式的 GM_cookie.list 包成 Promise,并防它不回调 */ +function listCookies(details: Tampermonkey.ListCookiesDetails): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (fn: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + fn() + } + const timer = setTimeout( + () => finish(() => reject(new Error('GM_cookie.list 无响应'))), + LIST_TIMEOUT, + ) + try { + GM_cookie.list(details, (cookies, error) => { + finish(() => (error ? reject(new Error(error)) : resolve(cookies ?? []))) + }) + } catch (err) { + finish(() => reject(err instanceof Error ? err : new Error(String(err)))) + } + }) +} + +/** document.cookie 兜底,只能读到非 HttpOnly 的 */ +function readFromDocument(name: string): string | null { + for (const part of document.cookie.split(';')) { + const raw = part.trim() + if (raw.startsWith(name + '=')) return raw.slice(name.length + 1) + } + return null +} + +/** + * 读一条 Cookie。先试 GM_cookie(能读 HttpOnly),再退到 document.cookie。 + * + * @param name Cookie 名 + * @param url 归属页面,默认当前文档 + */ +export async function readCookie(name: string, url?: string): Promise { + if (hasGMCookie()) { + try { + const cookies = await listCookies(url ? { url, name } : { name }) + // 指定了 name 仍可能匹配到多条(不同 domain/path),取第一条有值的 + const hit = cookies.find(c => c.name === name && c.value) + if (hit) return { value: hit.value } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn('[fqa:cookie] GM_cookie.list 失败,退到 document.cookie:', message) + const fallback = readFromDocument(name) + if (fallback) return { value: fallback } + return { value: null, reason: 'api-error', error: message } + } + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'not-found' } + } + + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'no-api' } +} + +const SESSION_COOKIE = 'sessionid' + +/** 成功读到的 sessionid。同一页面内复用,避免每次点赞都问一遍脚本管理器 */ +let cached: string | null = null + +/** + * 读网页站的 sessionid。 + * + * 只在需要写操作(点赞等)或需要「我是否赞过」状态时调用; + * 拿到的值仅随请求发往字节官方的 reading.snssdk.com,不经任何第三方。 + */ +export async function getSessionId(): Promise { + if (cached) return { value: cached } + const found = await readCookie(SESSION_COOKIE) + if (found.value) cached = found.value + return found +} + +/** 登录状态变化后调用,丢掉旧的 sessionid */ +export function clearSessionIdCache(): void { + cached = null +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 67ffdeb..4055234 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -78,4 +78,9 @@ export function concatArrayBuffers(...buffers: ArrayBuffer[]): ArrayBuffer { offset += buf.byteLength } return result.buffer +} + +export function clearNodeEventListeners(element: T): void { + element.replaceWith(cloneElement(element)) + return } \ No newline at end of file diff --git a/src/utils/navigate.ts b/src/utils/navigate.ts new file mode 100644 index 0000000..c098738 --- /dev/null +++ b/src/utils/navigate.ts @@ -0,0 +1,58 @@ +// 书籍卡片的跳转辅助。 +// +// 交互约定: +// 单击 -> 设置里选的默认去向(继续阅读 / 查看详情) +// Ctrl+单击 -> 另一个去向 +// 中键 -> 默认去向,新标签页 +// Ctrl+中键 -> 另一个去向,新标签页 +// +// macOS 上 Ctrl+单击被系统当成右键(会触发 contextmenu),所以 Cmd 也算「反向」, +// 否则 Mac 用户没有可用的反向键。 + +/** 鼠标中键 */ +const MIDDLE_BUTTON = 1 + +export interface OpenModifiers { + /** 取与默认相反的去向 */ + flip: boolean + /** 在新标签页打开 */ + newTab: boolean +} + +/** 从鼠标/键盘事件里读出修饰意图。键盘事件没有 button,只可能 flip */ +export function modifiersOf(event: MouseEvent | KeyboardEvent): OpenModifiers { + return { + flip: event.ctrlKey || event.metaKey, + newTab: 'button' in event && event.button === MIDDLE_BUTTON, + } +} + +/** 中键按下时浏览器会进入自动滚动模式,光标变成罗盘,需要拦掉 */ +export function isMiddleButton(event: MouseEvent): boolean { + return event.button === MIDDLE_BUTTON +} + +export function readerUrl(chapterId: string): string { + return `https://fanqienovel.com/reader/${chapterId}` +} + +export function bookPageUrl(bookId: string): string { + return `https://fanqienovel.com/page/${bookId}` +} + +/** + * 跳转。newTab 为真时开新标签页。 + * + * 不传 'noopener':按规范带上它 window.open 一律返回 null,就没法判断是否被拦了。 + * 目标是同源页面,opener 可访问本身无害。 + */ +export function openUrl(url: string, newTab = false): void { + if (!newTab) { + unsafeWindow.location.href = url + return + } + // 中键点击是用户手势,正常不会被拦;真被拦了也不要偷偷改成当前页跳转 + if (!unsafeWindow.open(url, '_blank')) { + console.warn('[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:', url) + } +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 46288ea..3e3799a 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -6,6 +6,30 @@ export type ApiResponse = Tampermonkey.Response & { json(): T; }; +/** + * 响应没有任何正文。 + * + * 番茄网关对「不认识的设备」就是这个回应:HTTP 200、`content-length: 0`、 + * 一个字节都没有。裸 JSON.parse 会抛 `SyntaxError: "undefined" is not valid JSON` + * (Tampermonkey 在无正文时不给 responseText),完全看不出真正的原因, + * 所以这里换成一个能认出来的错误类型。 + */ +export class EmptyResponseError extends Error { + readonly status: number; + + constructor(status: number) { + super(`服务端返回了空响应体(HTTP ${status})`); + this.name = "EmptyResponseError"; + this.status = status; + } +} + +/** 响应体是否为空。Tampermonkey 可能给 undefined,也可能给空串 */ +export function isEmptyResponse(res: Pick): boolean { + const text = res.responseText; + return typeof text !== "string" || text === ""; +} + const supportedMethods = new Set([ "GET", "HEAD", @@ -59,6 +83,9 @@ export default function apiFetch( cleanup(); resolve(Object.assign(response, { json(this: Tampermonkey.Response): T { + if (isEmptyResponse(this)) { + throw new EmptyResponseError(this.status); + } return JSON.parse(this.responseText) as T; }, })); diff --git a/src/views/AudioBar.vue b/src/views/AudioBar.vue new file mode 100644 index 0000000..788010a --- /dev/null +++ b/src/views/AudioBar.vue @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + 听 + + + + + + + {{ state.title }} + + {{ state.error || (state.loading ? '缓冲中…' : state.toneName) }} + + + + + + + + + + + + + × + + + + + + diff --git a/src/views/BookCard.vue b/src/views/BookCard.vue index 6a47823..2b57f59 100644 --- a/src/views/BookCard.vue +++ b/src/views/BookCard.vue @@ -1,13 +1,14 @@ + + + + + {{ props.current === null ? '选择音色' : '切换音色' }} + 切换后会从当前段落开头继续 + + + + + {{ tone.name.slice(0, 1) }} + + + {{ tone.name }} + + {{ genderLabel(tone.gender) }} + + + {{ tone.description }} + + + + + + 取消 + + + + diff --git a/vite.config.ts b/vite.config.ts index a3eced1..a6beb61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,7 +30,10 @@ export default defineConfig({ author: 'naiyQAQ', 'run-at': 'document-start', match: ['*://*.fanqienovel.com/*'], - grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'unsafeWindow'], + // GM_cookie 用于读 HttpOnly 的 sessionid(书评点赞需要登录态)。 + // 注意:Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, + // 用户需在 设置→通用→配置模式→高级,再 设置→高级→安全→允许脚本访问 Cookie→All + grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'GM_cookie', 'unsafeWindow'], connect: [ 'fanqienovel.com', // 主站 'jxbhmy.com', // 红烛小说 API @@ -38,6 +41,11 @@ export default defineConfig({ 'byteimg.com', // 图床 'fqnovelpic.com', // 图床 'bytecdn.cn', // 图床 + // 听书音频 CDN。页面 CSP 的 connect-src 不含这些域名, + // 走页面 fetch 会被 report-only 策略上报,所以改用 GM_xmlhttpRequest + 'fqnovelvod.com', // 番茄小说 TTS + 'novelfmvod.com', // 番茄畅听 + 'volcautovod.com', // 火山引擎回源 ], }, build: {
(点到 blk 或行内标签上也能命中), + * 再拿 idx 去时间点表里查覆盖它的那一段。 + */ + tagIndexOfNode(node: Node | null): number { + const paragraph = nearestParagraph(node) + if (paragraph) { + const idx = Number(paragraph.getAttribute('idx')) + if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx) + } + // 点在标题上 + if (node instanceof Node && titleTarget(this.tags)?.contains(node)) { + return this.tags.findIndex(t => t.is_title) + } + return -1 + } + + /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ + tagIndexOfIdx(idx: number): number { + return this.tags.findIndex(t => !t.is_title && idx >= t.startidx && idx <= t.endidx) + } + + /** 第 index 个时间点的起始秒数,供 seek 用 */ + startSecondsOf(index: number): number | null { + const tag = this.tags[index] + return tag ? tag.startms / 1000 : null + } +} + +/** 从任意节点往上找最近的带 idx 的段落 */ +function nearestParagraph(node: Node | null): HTMLElement | null { + let current: Node | null = node + while (current) { + if (current instanceof HTMLElement && current.matches('p[idx]')) return current + // 到正文容器就停,别一路找到 body + if (current instanceof HTMLElement && current.id === 'fqa-reader-content') return null + current = current.parentNode + } + return null +} diff --git a/src/crypto/spade.ts b/src/crypto/spade.ts new file mode 100644 index 0000000..81def53 --- /dev/null +++ b/src/crypto/spade.ts @@ -0,0 +1,53 @@ +import { b64decode } from '.' + +function decodeBase36(c: number): number { + if (c >= 48 && c <= 57) return c - 48; // '0'-'9' + if (c >= 97 && c <= 122) return c - 97 + 10; // 'a'-'z' + return 0xFF; +} + +function bitCount(n: number): number { + n = n - ((n >> 1) & 0x55555555); + n = (n & 0x33333333) + ((n >> 2) & 0x33333333); + return ((n + (n >> 4) & 0xF0F0F0F) * 0x1010101) >> 24; +} + +function decryptSpadeInner(spadeKey: Uint8Array): Uint8Array { + const result = new Uint8Array(spadeKey); + const buff = new Uint8Array(2 + spadeKey.length); + buff.set([0xFA, 0x55], 0); + buff.set(spadeKey, 2); + + for (let i = 0; i < result.length; i++) { + let v = (spadeKey[i]! ^ buff[i]!) - bitCount(i) - 21; + while (v < 0) { v += 0xFF; } + result[i] = v; + } + return result; +} + +function decryptSpade(spadeKeyBytes: Uint8Array): string { + const spadeKeyLen = spadeKeyBytes.length; + if (spadeKeyLen < 3) return ""; + const paddingLen = (spadeKeyBytes[0]! ^ spadeKeyBytes[1]! ^ spadeKeyBytes[2]!) - 48; + if (spadeKeyLen < paddingLen + 2) return ""; + const innerInput = spadeKeyBytes.slice(1, spadeKeyLen - paddingLen); + const tmpBuff = decryptSpadeInner(innerInput); + if (tmpBuff.length === 0) return ""; + const skipBytes = decodeBase36(tmpBuff[0]!); + const decodedMessageLen = spadeKeyLen - paddingLen - 2; + const endIndex = 1 + decodedMessageLen - skipBytes; + if (endIndex > tmpBuff.length) return ""; + const finalBytes = tmpBuff.slice(1, endIndex); + return new TextDecoder("utf-8").decode(finalBytes); +} + +export function decryptSpadeA(spadeAStr: string): string { + try { + const bytes = new Uint8Array(b64decode(spadeAStr)); + return decryptSpade(bytes); + } catch (e) { + console.error("Spade parsing error", e); + return ""; + } +} \ No newline at end of file diff --git a/src/hooks/readerHook.ts b/src/hooks/readerHook.ts index 446dd8b..19363d1 100644 --- a/src/hooks/readerHook.ts +++ b/src/hooks/readerHook.ts @@ -9,6 +9,14 @@ import { cloneElement } from '../utils'; import { fetchArrayBuffer } from '../utils/request'; import { type Book } from '../types' import { decryptComicImage } from '../crypto/content'; +import { initAudioPanel } from '../audioPanel' +import { + closeAudiobook, + openAudiobook, + refreshParagraphs, + state as audioState, + switchChapter, +} from '../audiobook/controller' // import moment from 'moment' let currentBook: Book | null = null @@ -303,6 +311,36 @@ async function insertContent() { } } } + + // 正文换了:段落节点全是新的,正在听书就把音频也切到这一章 + if (audioState.open) { + await switchChapter(itemId, { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) + } else { + refreshParagraphs() + } +} + +/** + * 工具栏「听书」按钮:停止 / 开始听书。 + * + * 已经在听的时候再点就是停止。切音色走左下角悬浮栏,不从这里进。 + */ +async function startAudioPlay(): Promise { + if (audioState.open) { + closeAudiobook() + return + } + const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || '' + if (!itemId) return + initAudioPanel() + // 封面和书名从已取到的书籍信息来;正文还没加载完时退化成空封面 + await openAudiobook(itemId, currentBook?.book_id ?? '', { + cover: currentBook?.cover_url ?? '', + title: currentBook?.title ?? document.title, + }) } // let currentBook: Book | null = null @@ -322,6 +360,28 @@ async function onLoad(): Promise { document.head.appendChild(style) } */ + // 切换日夜间的按钮,不管怎么样它的状态都是完全一样的。适合作为模板。 + const toolbar = document.querySelector("div.reader-toolbar > div") + const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)") + if (toolbarButton && toolbar) { + const c = cloneElement(toolbarButton) + c.id = 'fqa-toggle-audiobook' + const listenIcon = document.createElement('span') + listenIcon.textContent = '听' + listenIcon.style.width = '24px' + listenIcon.style.height = '24px' + listenIcon.style.fontSize = '24px' + listenIcon.style.lineHeight = '24px' + listenIcon.classList.add('muyeicon-icon') + listenIcon.classList.add('reader-toolbar-item-icon') + c.firstChild?.replaceWith(listenIcon) + const l = c.lastChild as HTMLDivElement + if (l) { + l.textContent = '听书' + } + c.addEventListener('click', () => void startAudioPlay()) + toolbar.appendChild(c) + } const btns = document.querySelector("div.muye-reader-btns") // single div if (btns) { // TODO: 直接覆写按钮行为,切章节由脚本完成 diff --git a/src/player/CencAudioPlayer.ts b/src/player/CencAudioPlayer.ts new file mode 100644 index 0000000..ca97b05 --- /dev/null +++ b/src/player/CencAudioPlayer.ts @@ -0,0 +1,1083 @@ +import { fetch as pageFetch } from '../config' +import type { Bytes } from './bytes' +import { + cencIvSize, + decryptCencSample, + importCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' +import { + buildMp4Segment, + buildWebmCluster, + codecMime, + extractFlacEntry, + extractSampleEntry, + initSegment, +} from './remux' + +export type CencPlayerState = 'idle' | 'loading' | 'ready' | 'playing' | 'paused' | 'ended' | 'error' | 'destroyed' + +export interface CencAudioSource { + /** 加密音频 MP4 URL。Range 请求不会发送 Referer。 */ + url: string + /** 16 字节 AES-128 key,或 32 位 hex key。 */ + key: string | ArrayBuffer | Uint8Array +} + +export interface CencMediaInfo { + codec: CencCodec + sampleCount: number + durationSeconds: number + timescale: number + channels: number + encryptedBytes: number +} + +export interface CencProgress { + processedSamples: number + totalSamples: number + percent: number + bufferedAhead: number + durationSeconds: number +} + +export interface CencAudioPlayerCallbacks { + onStateChange?: (state: CencPlayerState) => void + onProgress?: (progress: CencProgress) => void + onMessage?: (message: string, level: 'info' | 'warn' | 'error') => void +} + +export interface CencAudioPlayerOptions extends CencAudioPlayerCallbacks { + /** 初次读取的 MP4 头大小,默认 512 KiB。 */ + headBytes?: number + /** MP4 头解析失败时逐次扩大读取,默认最多 8 MiB。 */ + maxHeadBytes?: number + /** + * 每个 Range/MSE media segment 包含的目标秒数,默认 15 秒。 + * Opus/WebM 因时间戳范围限制最高按 30 秒处理。 + */ + segmentSeconds?: number + /** 播放位置前方达到此秒数后暂停续流,默认 40 秒。 */ + maxBufferAheadSeconds?: number + /** 清理播放位置后方的缓冲,只保留此秒数,默认 10 秒。 */ + keepBehindSeconds?: number + /** 并发 AES-CTR 解密数,默认 24。 */ + decryptConcurrency?: number + /** 仅用于测试或替换网络实现;默认使用页面原始 fetch。 */ + fetch?: typeof fetch +} + +interface StreamContext { + url: string + head: Bytes + meta: CencMetadata + key: CryptoKey + counterStart: 0 | 1 + ticks: Float64Array + samplesPerSecond: number + samplesPerSegment: number +} + +const DEFAULT_HEAD_BYTES = 512 * 1024 +const DEFAULT_MAX_HEAD_BYTES = 8 * 1024 * 1024 +const DEFAULT_SEGMENT_SECONDS = 15 +const MAX_WEBM_SEGMENT_SECONDS = 30 +const DEFAULT_MAX_BUFFER_AHEAD = 40 +const DEFAULT_KEEP_BEHIND = 10 +const DEFAULT_DECRYPT_CONCURRENCY = 24 + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(): DOMException { + return new DOMException('播放器会话已取消', 'AbortError') +} + +function isAbort(value: unknown): boolean { + return value instanceof DOMException && value.name === 'AbortError' +} + +/** + * 等 SourceBuffer 处理完手头的 appendBuffer/remove。 + * + * abort 会让上一次操作的 Promise 提前兑现,但浏览器那边还在跑, + * 此时再调 appendBuffer 就会抛 "still processing"。updating 转 false 时 + * 一定会有一次 updateend/abort/error,所以监听事件即可,不用轮询。 + */ +function waitForSourceBufferIdle(sourceBuffer: SourceBuffer, signal: AbortSignal): Promise { + if (!sourceBuffer.updating) return Promise.resolve() + return new Promise(resolve => { + const done = (): void => { + sourceBuffer.removeEventListener('updateend', done) + sourceBuffer.removeEventListener('abort', done) + sourceBuffer.removeEventListener('error', done) + signal.removeEventListener('abort', done) + resolve() + } + sourceBuffer.addEventListener('updateend', done) + sourceBuffer.addEventListener('abort', done) + sourceBuffer.addEventListener('error', done) + // 会话被取消也别一直挂着;调用方随后会自己检查 signal + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * 把 的错误码翻成可读文本,附在 SourceBuffer 报错后面。 + * + * MEDIA_ERR_DECODE 基本等于「喂进去的字节不是合法的音频」—— + * 数据在传输环节被破坏时就是这个。 + */ +function describeMediaError(audio: HTMLAudioElement): string { + const error = audio.error + if (!error) return '' + const names: Record = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE(数据不是合法音频,多半在传输中被破坏)', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED', + } + const name = names[error.code] ?? `code ${error.code}` + return `:${name}${error.message ? ` - ${error.message}` : ''}` +} + +function validatePositive(value: number | undefined, fallback: number, name: string): number { + const result = value ?? fallback + if (!Number.isFinite(result) || result <= 0) throw new Error(`${name} 必须是正数`) + return result +} + +async function mapLimit( + items: readonly T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + const results = new Array(items.length) + let next = 0 + async function consume(): Promise { + while (next < items.length) { + const index = next++ + results[index] = await worker(items[index]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => consume())) + return results +} + +type ByteReader = ReadableStreamDefaultReader + +/** + * 把 fetch 的任意网络 chunk 重新切成播放器要求的 sample 段。 + * 不主动预读:调用方停止 readExactly 后,ReadableStream 的背压会限制继续下载。 + */ +class SequentialByteStream { + private readonly chunks: Bytes[] = [] + private chunkOffset = 0 + private available = 0 + private reader: ByteReader | null + private readonly openReader: (() => Promise) | null + private eof = false + + constructor( + initial: Uint8Array = new Uint8Array(), + reader: ByteReader | null = null, + openReader: (() => Promise) | null = null, + ) { + if (initial.length > 0) { + const copy = new Uint8Array(initial) as Bytes + this.chunks.push(copy) + this.available = copy.length + } + this.reader = reader + this.openReader = openReader + } + + /** 至少缓存 length 字节;流先结束时返回 false。 */ + async bufferAtLeast(length: number): Promise { + while (this.available < length && !this.eof) await this.pull() + return this.available >= length + } + + /** 复制当前全部未消费数据,不移动读取位置。 */ + snapshot(): Bytes { + const result = new Uint8Array(this.available) + let output = 0 + for (let i = 0; i < this.chunks.length; i++) { + const chunk = this.chunks[i]! + const start = i === 0 ? this.chunkOffset : 0 + result.set(chunk.subarray(start), output) + output += chunk.length - start + } + return result as Bytes + } + + async skip(length: number): Promise { + await this.readExactly(length) + } + + async readExactly(length: number): Promise { + if (!Number.isSafeInteger(length) || length < 0) { + throw new Error(`无效的流读取长度: ${length}`) + } + if (length === 0) return new Uint8Array() as Bytes + if (!await this.bufferAtLeast(length)) { + throw new Error(`音频流提前结束(还需 ${length - this.available} 字节)`) + } + + const result = new Uint8Array(length) + let output = 0 + while (output < length) { + const chunk = this.chunks[0]! + const take = Math.min(length - output, chunk.length - this.chunkOffset) + result.set(chunk.subarray(this.chunkOffset, this.chunkOffset + take), output) + output += take + this.chunkOffset += take + this.available -= take + if (this.chunkOffset === chunk.length) { + this.chunks.shift() + this.chunkOffset = 0 + } + } + return result as Bytes + } + + async cancel(): Promise { + this.eof = true + try { + await this.reader?.cancel() + } catch { + // AbortController 通常已经让 reader 进入 errored,取消失败无需再处理。 + } + this.reader = null + } + + private async pull(): Promise { + if (!this.reader && this.openReader) this.reader = await this.openReader() + if (!this.reader) { + this.eof = true + return + } + const { value, done } = await this.reader.read() + if (done) { + this.eof = true + return + } + if (value?.length) { + const copy = new Uint8Array(value) as Bytes + this.chunks.push(copy) + this.available += copy.length + } + } +} + +/** + * CENC 音频流播放器。 + * + * 负责 MP4 分段读取、AES-CTR 解密、WebM/fMP4 重封装和 MSE 缓冲;不创建任何 + * 页面控件。调用方传入一个已有的 HTMLAudioElement,再把它接到自己的界面即可。 + * + * 音频 CDN 明确拒绝 Referer,因此网络层固定使用: + * - 页面原始 fetch(不走被页面脚本改写的 fetch) + * - credentials: omit + * - referrerPolicy: no-referrer + * 不使用 GM_xmlhttpRequest 回退,也不手动添加 Referer。 + */ +export class CencAudioPlayer { + private readonly audio: HTMLAudioElement + private readonly options: Required< + Pick< + CencAudioPlayerOptions, + | 'headBytes' + | 'maxHeadBytes' + | 'segmentSeconds' + | 'maxBufferAheadSeconds' + | 'keepBehindSeconds' + | 'decryptConcurrency' + > + > & CencAudioPlayerCallbacks & { fetch: typeof fetch } + + private state: CencPlayerState = 'idle' + private session = 0 + private streamToken = 0 + private seekToken = 0 + private sessionController: AbortController | null = null + private streamController: AbortController | null = null + private sourceBuffer: SourceBuffer | null = null + private mediaSource: MediaSource | null = null + private objectUrl: string | null = null + private sourceBufferChain: Promise = Promise.resolve() + private context: StreamContext | null = null + private info: CencMediaInfo | null = null + private autoplay = false + private segmentSequence = 0 + + private readonly onSeeking = (): void => { + const context = this.context + if (!context || this.state === 'destroyed' || this.session === 0) return + // 已经进入错误态时,任何 appendBuffer 都会被拒 + // ("The HTMLMediaElement.error attribute is not null.")。 + // 这时重启流只会把同一个错误刷满控制台,直接放弃 + if (this.audio.error) return + const target = this.audio.currentTime + if (!Number.isFinite(target) || this.isTimeBuffered(target)) return + const wasPlaying = !this.audio.paused + void this.restartAt(target, wasPlaying) + } + + private readonly onPlay = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('playing') + } + + private readonly onPause = (): void => { + if (this.context && this.state === 'playing' && !this.audio.ended) this.emitState('paused') + } + + private readonly onEnded = (): void => { + if (this.context && this.state !== 'destroyed') this.emitState('ended') + } + + constructor(audio: HTMLAudioElement, options: CencAudioPlayerOptions = {}) { + this.audio = audio + // Window.fetch 不能作为普通对象的方法调用,否则 Edge 会报 Illegal invocation。 + const fetcher = (options.fetch ?? pageFetch).bind(unsafeWindow) + this.options = { + headBytes: validatePositive(options.headBytes, DEFAULT_HEAD_BYTES, 'headBytes'), + maxHeadBytes: validatePositive(options.maxHeadBytes, DEFAULT_MAX_HEAD_BYTES, 'maxHeadBytes'), + segmentSeconds: validatePositive(options.segmentSeconds, DEFAULT_SEGMENT_SECONDS, 'segmentSeconds'), + maxBufferAheadSeconds: validatePositive(options.maxBufferAheadSeconds, DEFAULT_MAX_BUFFER_AHEAD, 'maxBufferAheadSeconds'), + keepBehindSeconds: validatePositive(options.keepBehindSeconds, DEFAULT_KEEP_BEHIND, 'keepBehindSeconds'), + decryptConcurrency: Math.max(1, Math.floor(validatePositive(options.decryptConcurrency, DEFAULT_DECRYPT_CONCURRENCY, 'decryptConcurrency'))), + onStateChange: options.onStateChange, + onProgress: options.onProgress, + onMessage: options.onMessage, + // config.fetch 保存的是页面 fetch 的原始引用,避免被字节 SDK 改写。 + fetch: fetcher, + } + this.audio.addEventListener('seeking', this.onSeeking) + this.audio.addEventListener('play', this.onPlay) + this.audio.addEventListener('pause', this.onPause) + this.audio.addEventListener('ended', this.onEnded) + } + + get currentState(): CencPlayerState { + return this.state + } + + get mediaInfo(): CencMediaInfo | null { + return this.info + } + + get element(): HTMLAudioElement { + return this.audio + } + + /** 加载并开始后台缓冲;autoplay 只表示首次缓冲后尝试调用 audio.play。 */ + async load(source: CencAudioSource, autoplay = false): Promise { + this.ensureAlive() + const session = this.beginSession() + this.autoplay = autoplay + this.emitState('loading') + let initialStream: SequentialByteStream | null = null + + try { + const url = this.validateUrl(source.url) + this.streamController = new AbortController() + initialStream = this.createNetworkStream(url, 0, this.streamController.signal) + let { head, meta } = await this.parseHeadFromStream(initialStream, session) + this.assertSession(session) + const key = await importCencKey(source.key) + this.assertSession(session) + + let counterStart: 0 | 1 = 0 + if (meta.codec === 'opus') { + // counter 探测仍消费同一条 fetch;若前六帧超出初始头,就继续多读一点。 + const probeEnd = meta.mdatStart + meta.offsets[Math.min(6, meta.sampleCount)]! + if (head.length < probeEnd) { + await initialStream.bufferAtLeast(probeEnd) + head = initialStream.snapshot() + } + counterStart = await this.detectCounterStart(meta, head, key) + } + this.assertSession(session) + + const sampleEntry = meta.codec === 'aac' + ? extractSampleEntry(head, meta.mdatStart) + : meta.codec === 'flac' + ? extractFlacEntry(head, meta.mdatStart) + : undefined + const mime = codecMime(meta.codec) + const ctor = this.mediaSourceConstructor() + if (!ctor.isTypeSupported(mime)) throw new Error(`浏览器不支持 MSE 音频格式: ${mime}`) + + const mediaSource = new ctor() + this.mediaSource = mediaSource + this.objectUrl = unsafeWindow.URL.createObjectURL(mediaSource) + const opened = this.waitForSourceOpen(mediaSource, session) + this.audio.src = this.objectUrl + await opened + this.assertSession(session) + this.sourceBuffer = mediaSource.addSourceBuffer(mime) + this.segmentSequence = 0 + const init = initSegment(meta, sampleEntry) + this.emitMessage(`追加 init 段 ${init.byteLength} 字节 (${mime})`, 'info') + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(init)) + }) + this.assertSession(session) + + const ticks = new Float64Array(meta.sampleCount + 1) + for (let i = 0; i < meta.sampleCount; i++) { + ticks[i + 1] = ticks[i]! + meta.durations[i]! + } + const durationSeconds = meta.totalTicks / meta.timescale + const samplesPerSecond = meta.sampleCount / durationSeconds + // WebM SimpleBlock 的相对时间戳是 Int16,Cluster 最长必须小于约 32.7 秒。 + const segmentSeconds = meta.codec === 'opus' + ? Math.min(this.options.segmentSeconds, MAX_WEBM_SEGMENT_SECONDS) + : this.options.segmentSeconds + this.context = { + url, + head, + meta, + key, + counterStart, + ticks, + samplesPerSecond, + samplesPerSegment: Math.max(1, Math.round(samplesPerSecond * segmentSeconds)), + } + this.info = { + codec: meta.codec, + sampleCount: meta.sampleCount, + durationSeconds, + timescale: meta.timescale, + channels: meta.channels, + encryptedBytes: meta.offsets[meta.sampleCount]!, + } + // MP4 头和音频正文来自同一个 fetch;丢掉头部后继续消费现有 reader。 + await initialStream.skip(meta.mdatStart) + this.assertSession(session) + this.emitState('ready') + this.startStream(0, initialStream) + initialStream = null // reader 的所有权已转交给 runStream + return this.info + } catch (error) { + void initialStream?.cancel() + if (this.isCurrent(session) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + throw error + } + } + + /** load(..., true) 的便捷形式。 */ + start(source: CencAudioSource): Promise { + return this.load(source, true) + } + + play(): Promise { + this.ensureAlive() + return this.audio.play().then(() => undefined) + } + + pause(): void { + this.audio.pause() + } + + /** 停止当前流并释放 MSE URL,但保留播放器实例以便再次 load。 */ + stop(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.emitState('idle') + } + + destroy(): void { + if (this.state === 'destroyed') return + this.beginSession() + this.audio.removeEventListener('seeking', this.onSeeking) + this.audio.removeEventListener('play', this.onPlay) + this.audio.removeEventListener('pause', this.onPause) + this.audio.removeEventListener('ended', this.onEnded) + this.emitState('destroyed') + } + + private ensureAlive(): void { + if (this.state === 'destroyed') throw new Error('播放器已销毁') + } + + private beginSession(): number { + this.session++ + this.streamToken++ + this.seekToken++ + this.sessionController?.abort() + this.streamController?.abort() + this.sessionController = new AbortController() + this.streamController = null + this.context = null + this.info = null + this.autoplay = false + this.sourceBuffer = null + this.mediaSource = null + this.sourceBufferChain = Promise.resolve() + this.audio.pause() + if (this.audio.src) { + this.audio.removeAttribute('src') + this.audio.load() + } + if (this.objectUrl) { + unsafeWindow.URL.revokeObjectURL(this.objectUrl) + this.objectUrl = null + } + return this.session + } + + private validateUrl(raw: string): string { + const url = raw.trim() + const parsed = new URL(url) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('音频 URL 必须是 HTTP(S) 地址') + } + return parsed.toString() + } + + private mediaSourceConstructor(): typeof MediaSource { + const win = unsafeWindow as Window & { MediaSource?: typeof MediaSource } + const ctor = win.MediaSource ?? globalThis.MediaSource + if (!ctor) throw new Error('当前浏览器没有 MediaSource 支持') + return ctor + } + + /** + * 把字节搬到页面 realm,再交给 appendBuffer。 + * + * MediaSource 取自 unsafeWindow(页面 realm),而我们构造的 Uint8Array + * 属于用户脚本沙箱 realm。跨 realm 的 ArrayBuffer 传进 appendBuffer 会被拒, + * 表现是 SourceBuffer 立刻抛 error 事件、而 .error 仍是 null + * —— 只看报错完全看不出是 realm 问题。 + * + * 页面 realm 没暴露 Uint8Array 时(少见)就原样返回,交给浏览器自己判断。 + */ + private toPageBuffer(bytes: Uint8Array): BufferSource { + const win = unsafeWindow as Window & { Uint8Array?: Uint8ArrayConstructor } + const PageUint8Array = win.Uint8Array + if (!PageUint8Array || PageUint8Array === Uint8Array) return bytes as Bytes + const copy = new PageUint8Array(bytes.byteLength) as Bytes + copy.set(bytes) + return copy + } + + private async parseHeadFromStream( + stream: SequentialByteStream, + session: number, + ): Promise<{ head: Bytes; meta: CencMetadata }> { + let size = Math.min(this.options.headBytes, this.options.maxHeadBytes) + let lastError: unknown = null + while (size <= this.options.maxHeadBytes) { + const complete = await stream.bufferAtLeast(size) + this.assertSession(session) + const head = stream.snapshot() + try { + return { head, meta: parseCencMetadata(head) } + } catch (error) { + lastError = error + const message = asError(error).message + const incomplete = /未找到|不完整|缺少/.test(message) + if (!incomplete || !complete || size === this.options.maxHeadBytes) throw error + size = Math.min(this.options.maxHeadBytes, size * 2) + } + } + throw lastError instanceof Error ? lastError : new Error('无法解析音频 MP4 头') + } + + private createNetworkStream( + url: string, + start: number, + signal: AbortSignal, + initial: Uint8Array = new Uint8Array(), + ): SequentialByteStream { + return new SequentialByteStream( + initial, + null, + () => this.openAudioReader(url, start, signal), + ) + } + + /** + * 打开一条从指定字节一直到文件末尾的流。 + * 所有 CDN 请求都明确禁止 Referer、Cookie;顺序播放期间只会打开一次。 + */ + private async openAudioReader(url: string, start: number, signal: AbortSignal): Promise { + this.emitMessage(`打开音频流:bytes=${start}-`, 'info') + const response = await this.options.fetch(url, { + method: 'GET', + headers: { Range: `bytes=${start}-` }, + credentials: 'omit', + // 两项都设:policy 禁止浏览器生成 Referer,空 referrer 防止调用方 Request 继承。 + referrer: '', + referrerPolicy: 'no-referrer', + signal, + }) + if (!response.ok && response.status !== 206) { + throw new Error(`音频 CDN 请求失败(HTTP ${response.status})`) + } + if (start > 0 && response.status !== 206) { + throw new Error('音频 CDN 忽略了 Range,无法从拖动位置续流') + } + if (response.body) return response.body.getReader() + + // 极少数 WebView 不暴露 response.body,退回一次性读取,接口语义仍保持一致。 + const bytes = await response.arrayBuffer() + const fallback = new Response(bytes).body + if (!fallback) throw new Error('浏览器不支持流式读取 Response') + return fallback.getReader() + } + + private sessionSignal(session: number): AbortSignal { + this.assertSession(session) + return this.sessionController!.signal + } + + private assertSession(session: number): void { + if (!this.isCurrent(session)) throw abortError() + } + + private isCurrent(session: number): boolean { + return this.session === session && !this.sessionController?.signal.aborted + } + + private async waitForSourceOpen(mediaSource: MediaSource, session: number): Promise { + const signal = this.sessionSignal(session) + await new Promise((resolve, reject) => { + if (mediaSource.readyState === 'open') { + resolve() + return + } + const onOpen = (): void => { + cleanup() + resolve() + } + const onError = (): void => { + cleanup() + reject(new Error('MediaSource 打开失败')) + } + const onAbort = (): void => { + cleanup() + reject(abortError()) + } + const cleanup = (): void => { + mediaSource.removeEventListener('sourceopen', onOpen) + mediaSource.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + } + mediaSource.addEventListener('sourceopen', onOpen, { once: true }) + mediaSource.addEventListener('error', onError, { once: true }) + signal.addEventListener('abort', onAbort, { once: true }) + }) + } + + private enqueueSourceBuffer( + session: number, + operation: (sourceBuffer: SourceBuffer) => void, + ): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer) return Promise.reject(new Error('SourceBuffer 尚未创建')) + const signal = this.sessionSignal(session) + const run = async (): Promise => { + if (signal.aborted) throw abortError() + // 关键:上一次操作可能因为 abort/error 提前兑现了 Promise, + // 但浏览器仍在处理那次 appendBuffer/remove。这时直接下一个操作会抛 + // "This SourceBuffer is still processing an 'appendBuffer' or 'remove' + // operation.",所以必须等它真的空闲,不能只靠 Promise 链的顺序。 + await waitForSourceBufferIdle(sourceBuffer, signal) + if (signal.aborted) throw abortError() + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) return + settled = true + sourceBuffer.removeEventListener('updateend', onUpdateEnd) + sourceBuffer.removeEventListener('error', onError) + signal.removeEventListener('abort', onAbort) + if (error) reject(error) + else resolve() + } + const onUpdateEnd = (): void => finish() + // SourceBuffer 的 error 事件不带原因。注意 MSE 规范里这个事件 + // 先于媒体元素的错误传播,所以此刻 audio.error 常常还是 null, + // 不能据此断定「不是解码问题」。真实错误码要等一轮事件循环, + // 由下面 catch 里的 reportAppendFailure 补报 + const onError = (): void => finish(new Error('SourceBuffer 更新失败')) + const onAbort = (): void => finish(abortError()) + sourceBuffer.addEventListener('updateend', onUpdateEnd) + sourceBuffer.addEventListener('error', onError) + signal.addEventListener('abort', onAbort, { once: true }) + try { + operation(sourceBuffer) + } catch (error) { + finish(asError(error)) + } + }) + } + const next = this.sourceBufferChain.catch(() => undefined).then(run) + this.sourceBufferChain = next + return next + } + + private startStream(startSample: number, existingStream?: SequentialByteStream): void { + const context = this.context + if (!context || this.state === 'destroyed') return + if (!existingStream) { + this.streamController?.abort() + this.streamController = new AbortController() + } + const controller = this.streamController + if (!controller) return + const token = ++this.streamToken + void this.runStream( + context, + this.session, + token, + startSample, + controller.signal, + existingStream, + ) + } + + private async runStream( + context: StreamContext, + session: number, + token: number, + startSample: number, + signal: AbortSignal, + existingStream?: SequentialByteStream, + ): Promise { + let sample = startSample + let first = true + let stream: SequentialByteStream | null = existingStream ?? null + try { + stream ??= this.createSampleStream(context, startSample, signal) + while (this.isStreamCurrent(session, token, signal) && sample < context.meta.sampleCount) { + await this.waitForRoom(session, token, signal) + if (!this.isStreamCurrent(session, token, signal)) return + const endSample = Math.min(context.meta.sampleCount, sample + context.samplesPerSegment) + const byteLength = context.meta.offsets[endSample]! - context.meta.offsets[sample]! + const encrypted = await stream.readExactly(byteLength) + if (!this.isStreamCurrent(session, token, signal)) return + const plain = await this.decryptRange(context, encrypted, sample, endSample) + if (!this.isStreamCurrent(session, token, signal)) return + + const segment = context.meta.codec === 'opus' + ? buildWebmCluster( + Math.round(context.ticks[sample]! * 1000 / context.meta.timescale), + plain.map((_, index) => Math.round( + context.ticks[sample + index]! * 1000 / context.meta.timescale, + ) - Math.round(context.ticks[sample]! * 1000 / context.meta.timescale)), + plain, + ) + : buildMp4Segment( + context.ticks[sample]!, + plain.map((data, index) => ({ + data, + duration: context.meta.durations[sample + index]!, + })), + ++this.segmentSequence, + ) + await this.enqueueSourceBuffer(session, sourceBuffer => { + sourceBuffer.appendBuffer(this.toPageBuffer(segment)) + }) + if (first) { + // 首段最容易暴露数据问题:把解密后的特征打出来, + // 便于判断是取流坏了还是封装坏了 + const toc = plain[0]?.[0] + this.emitMessage( + `首段 samples=${sample}..${endSample} 密文${encrypted.byteLength}B ` + + `段长${segment.byteLength}B TOC=0x${(toc ?? 0).toString(16)}`, + 'info', + ) + } + if (!this.isStreamCurrent(session, token, signal)) return + await this.evictBehind(session) + + sample = endSample + this.emitProgress({ + processedSamples: sample, + totalSamples: context.meta.sampleCount, + percent: Math.round(sample / context.meta.sampleCount * 100), + bufferedAhead: this.bufferedAhead(), + durationSeconds: context.meta.totalTicks / context.meta.timescale, + }) + if (first && this.autoplay) { + first = false + void this.audio.play().catch(() => { + this.emitMessage('自动播放被浏览器拦截,请点击音频控件播放', 'warn') + }) + } else { + first = false + } + } + if (this.isStreamCurrent(session, token, signal)) { + // 缓冲到结尾必须通知 MediaSource 收口:不调 endOfStream 的话 + // duration 一直是 Infinity, 永远到不了结尾, + // ended 事件不触发(表现为播到最后还显示播放中但没声音, + // 自动切章也就不会发生)。真正的「播完」仍由 ended 事件上报。 + await this.finishStream(session, sample) + this.emitMessage('音频已缓冲到结尾', 'info') + } + } catch (error) { + if (this.isStreamCurrent(session, token, signal) && !isAbort(error)) { + this.emitMessage(asError(error).message, 'error') + void this.reportAppendFailure() + this.emitState('error') + } + } finally { + void stream?.cancel() + } + } + + /** + * 补报媒体元素的错误码。 + * + * SourceBuffer 的 error 事件先于媒体元素错误传播,同步读 audio.error + * 大概率是 null。让出一轮事件循环再读,才能拿到真正的 MediaError。 + */ + private async reportAppendFailure(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) + const detail = describeMediaError(this.audio) + if (detail) this.emitMessage(`媒体元素错误${detail}`, 'error') + } + + private isStreamCurrent(session: number, token: number, signal: AbortSignal): boolean { + return this.isCurrent(session) && this.streamToken === token && !signal.aborted + } + + /** + * 全部样本都送进 SourceBuffer 后收口 MediaSource。 + * + * 只有真的跑到最后一个样本才收口。seek 之后的流也会走到循环末尾, + * 但那时前面还有没缓冲的区间,提前 endOfStream 会把 duration 定在错的位置。 + */ + private async finishStream(session: number, lastSample: number): Promise { + const context = this.context + const mediaSource = this.mediaSource + if (!context || !mediaSource) return + if (lastSample < context.meta.sampleCount) return + if (mediaSource.readyState !== 'open') return + // 等 append 队列排空:还在 updating 时调 endOfStream 会抛 InvalidStateError。 + // 不能用 enqueueSourceBuffer 塞个空操作 —— 它靠 updateend 兑现, + // 空操作不产生事件,会永远挂住 + await this.sourceBufferChain.catch(() => undefined) + if (!this.isCurrent(session)) return + if (this.sourceBuffer) { + await waitForSourceBufferIdle(this.sourceBuffer, this.sessionSignal(session)) + } + if (!this.isCurrent(session) || mediaSource.readyState !== 'open') return + try { + mediaSource.endOfStream() + } catch (error) { + // 收口失败不影响已经缓冲好的部分,照常播,只是没有 ended + this.emitMessage(`标记音频结尾失败: ${asError(error).message}`, 'warn') + } + } + + private async waitForRoom( + session: number, + token: number, + signal: AbortSignal, + ): Promise { + while ( + this.isStreamCurrent(session, token, signal) && + this.bufferedAhead() >= this.options.maxBufferAheadSeconds + ) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + if (!this.isStreamCurrent(session, token, signal)) throw abortError() + } + + /** + * seek 后的新流。目标仍在已保存的 MP4 头内时先复用内存字节,读完再从 + * head.length 开一条网络流;否则直接从目标 sample 的绝对偏移续传。 + */ + private createSampleStream( + context: StreamContext, + startSample: number, + signal: AbortSignal, + ): SequentialByteStream { + const start = context.meta.mdatStart + context.meta.offsets[startSample]! + if (start < context.head.length) { + return this.createNetworkStream( + context.url, + context.head.length, + signal, + context.head.slice(start), + ) + } + return this.createNetworkStream(context.url, start, signal) + } + + private async decryptRange( + context: StreamContext, + encrypted: Uint8Array, + startSample: number, + endSample: number, + ): Promise { + const ivSize = cencIvSize(context.meta) + const jobs = [] as Array<{ iv: Uint8Array; ciphertext: Uint8Array }> + for (let i = startSample; i < endSample; i++) { + const relative = context.meta.offsets[i]! - context.meta.offsets[startSample]! + const size = context.meta.sizes[i]! + jobs.push({ + iv: context.meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext: encrypted.slice(relative, relative + size), + }) + } + return mapLimit(jobs, this.options.decryptConcurrency, job => decryptCencSample( + context.key, + job.iv, + job.ciphertext, + context.counterStart, + )) + } + + private async detectCounterStart( + meta: CencMetadata, + head: Bytes, + key: CryptoKey, + ): Promise<0 | 1> { + const count = Math.min(6, meta.sampleCount) + if (count === 0) return 0 + const start = meta.mdatStart + meta.offsets[0]! + const end = meta.mdatStart + meta.offsets[count]! - 1 + if (end >= head.length) throw new Error('Opus counter 探测数据不完整') + const encrypted = head.slice(start, end + 1) + const ivSize = cencIvSize(meta) + for (const candidate of [0, 1] as const) { + const firstBytes = new Set() + for (let i = 0; i < count; i++) { + const relative = meta.offsets[i]! + const ciphertext = encrypted.slice(relative, relative + meta.sizes[i]!) + const plain = await decryptCencSample( + key, + meta.ivs.slice(i * ivSize, (i + 1) * ivSize), + ciphertext, + candidate, + ) + if (plain.length > 0) firstBytes.add(plain[0]!) + } + if (firstBytes.size === 1) { + this.emitMessage(`CENC counter 低位起始=${candidate}`, 'info') + return candidate + } + } + this.emitMessage('CENC counter 未收敛,按 0 继续', 'warn') + return 0 + } + + private async evictBehind(session: number): Promise { + const sourceBuffer = this.sourceBuffer + if (!sourceBuffer || !sourceBuffer.buffered.length) return + const current = this.audio.currentTime + if (current <= this.options.keepBehindSeconds + 5) return + const start = sourceBuffer.buffered.start(0) + const end = current - this.options.keepBehindSeconds + if (start < end) { + try { + await this.enqueueSourceBuffer(session, buffer => buffer.remove(start, end)) + } catch { + // 清理失败不影响继续播放,下一段仍可能触发浏览器自己的回收。 + } + } + } + + private async restartAt(target: number, wasPlaying: boolean): Promise { + const context = this.context + if (!context) return + if (this.audio.error) { + this.emitMessage('播放元素已进入错误态,无法跳转,请重新开始听书', 'warn') + return + } + const session = this.session + const seek = ++this.seekToken + this.streamToken++ + this.streamController?.abort() + + let low = 0 + let high = context.meta.sampleCount - 1 + let index = 0 + const targetTicks = target * context.meta.timescale + while (low <= high) { + const middle = (low + high) >> 1 + if (context.ticks[middle]! <= targetTicks) { + index = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + const preSamples = Math.max(1, Math.round(context.samplesPerSecond * 2)) + const startSample = Math.max(0, index - preSamples) + this.emitMessage(`跳转到 ${target.toFixed(1)} 秒`, 'info') + + // 先让上一条流排队中的 append 走完,再读 buffered。 + // 否则 remove 的范围是旧的,而且紧接着的 append 会撞上还在处理的操作 + await this.sourceBufferChain.catch(() => undefined) + if (seek !== this.seekToken || !this.isCurrent(session)) return + + const bufferedEnd = this.sourceBuffer?.buffered.length + ? this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) + : 0 + if (bufferedEnd > 0) { + try { + await this.enqueueSourceBuffer(session, sourceBuffer => sourceBuffer.remove(0, bufferedEnd + 0.5)) + } catch { + // 后续 append 会给出更具体的错误;这里不让一次清理失败阻断 seek。 + } + } + if (seek !== this.seekToken || !this.isCurrent(session)) return + // 已经 endOfStream 过时 readyState 是 'ended',append 会让它自动回到 'open'; + // 只有 'closed'(SourceBuffer 已被 detach)才真的没救 + if (this.mediaSource && this.mediaSource.readyState === 'closed') { + this.emitMessage('媒体源已关闭,无法跳转', 'warn') + return + } + this.startStream(startSample) + if (wasPlaying) void this.audio.play().catch(() => undefined) + } + + private isTimeBuffered(time: number): boolean { + const ranges = this.sourceBuffer?.buffered + if (!ranges) return false + for (let i = 0; i < ranges.length; i++) { + if (time >= ranges.start(i) - 0.05 && time <= ranges.end(i) + 0.05) return true + } + return false + } + + private bufferedAhead(): number { + const ranges = this.sourceBuffer?.buffered + if (!ranges || !ranges.length) return 0 + const current = this.audio.currentTime + for (let i = 0; i < ranges.length; i++) { + if (current >= ranges.start(i) - 0.05 && current <= ranges.end(i) + 0.05) { + return Math.max(0, ranges.end(i) - current) + } + } + return Math.max(0, ranges.end(ranges.length - 1) - current) + } + + private emitState(state: CencPlayerState): void { + this.state = state + this.options.onStateChange?.(state) + } + + private emitProgress(progress: CencProgress): void { + this.options.onProgress?.(progress) + } + + private emitMessage(message: string, level: 'info' | 'warn' | 'error'): void { + this.options.onMessage?.(message, level) + } +} diff --git a/src/player/bytes.ts b/src/player/bytes.ts new file mode 100644 index 0000000..219f5ae --- /dev/null +++ b/src/player/bytes.ts @@ -0,0 +1,109 @@ +/** 二进制播放器使用的基础字节工具。 */ + +export type Bytes = Uint8Array + +export function asBytes(value: ArrayBuffer | Uint8Array): Bytes { + if (value instanceof Uint8Array) { + return new Uint8Array(value) as Bytes + } + return new Uint8Array(value) as Bytes +} + +export function concatBytes(...arrays: readonly Uint8Array[]): Bytes { + const length = arrays.reduce((sum, array) => sum + array.byteLength, 0) + const result = new Uint8Array(length) + let offset = 0 + for (const array of arrays) { + result.set(array, offset) + offset += array.byteLength + } + return result as Bytes +} + +export function ascii(text: string): Bytes { + const result = new Uint8Array(text.length) + for (let i = 0; i < text.length; i++) { + result[i] = text.charCodeAt(i) & 0xff + } + return result as Bytes +} + +export function hexToBytes(text: string): Bytes { + const hex = text.replace(/\s+/g, '') + if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) { + throw new Error('无效的十六进制数据') + } + const result = new Uint8Array(hex.length / 2) + for (let i = 0; i < result.length; i++) { + result[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return result as Bytes +} + +export function uintBytes(value: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`无效的无符号整数: ${value}`) + } + if (value === 0) return new Uint8Array([0]) as Bytes + + const result: number[] = [] + let current = value + while (current > 0) { + result.unshift(current & 0xff) + current = Math.floor(current / 256) + } + return new Uint8Array(result) as Bytes +} + +export function be16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setUint16(0, value) + return result as Bytes +} + +export function be32(value: number): Bytes { + const result = new Uint8Array(4) + new DataView(result.buffer).setUint32(0, value >>> 0) + return result as Bytes +} + +export function be64(value: number | bigint): Bytes { + const result = new Uint8Array(8) + new DataView(result.buffer).setBigUint64(0, BigInt(value)) + return result as Bytes +} + +export function findBytes( + buffer: Uint8Array, + fourcc: string, + from = 0, + to = buffer.length - 4, +): number { + if (fourcc.length !== 4) throw new Error(`fourcc 必须是 4 个字符: ${fourcc}`) + const c0 = fourcc.charCodeAt(0) + const c1 = fourcc.charCodeAt(1) + const c2 = fourcc.charCodeAt(2) + const c3 = fourcc.charCodeAt(3) + for (let i = from; i <= to; i++) { + if ( + buffer[i] === c0 && + buffer[i + 1] === c1 && + buffer[i + 2] === c2 && + buffer[i + 3] === c3 + ) return i + } + return -1 +} + +export function strBytes(text: string): Bytes { + return new TextEncoder().encode(text) as Bytes +} + +export function readFourcc(buffer: Uint8Array, offset: number): string { + return String.fromCharCode( + buffer[offset] ?? 0, + buffer[offset + 1] ?? 0, + buffer[offset + 2] ?? 0, + buffer[offset + 3] ?? 0, + ) +} diff --git a/src/player/cenc.ts b/src/player/cenc.ts new file mode 100644 index 0000000..0945b24 --- /dev/null +++ b/src/player/cenc.ts @@ -0,0 +1,287 @@ +import { asBytes, type Bytes, hexToBytes, findBytes, readFourcc } from './bytes' +import { getSubtle } from '../crypto' + +export type CencCodec = 'opus' | 'aac' | 'flac' + +export interface CencMetadata { + codec: CencCodec + sampleCount: number + sizes: Uint32Array + /** 每个采样的 8/16 字节 CENC IV,按 sample 顺序拼接 */ + ivs: Bytes + durations: Uint32Array + timescale: number + channels: number + preSkip: number + /** mdat 的负载起点,相对于 head buffer */ + mdatStart: number + /** 每个 sample 在 mdat 负载中的累计偏移,长度为 sampleCount + 1 */ + offsets: Float64Array + totalTicks: number +} + +export interface CencKey { + key: CryptoKey + /** AES-CTR counter 的低位初值,CENC 常见为 0;某些流使用 1 */ + counterStart: 0 | 1 +} + +interface IsoBox { + type: string + offset: number + size: number + headerSize: number + end: number +} + +const CONTAINERS = new Set([ + 'moov', 'trak', 'mdia', 'minf', 'stbl', 'edts', 'dinf', 'udta', 'mvex', 'meta', +]) + +function readBox(buffer: Uint8Array, offset: number, limit: number): IsoBox | null { + if (offset + 8 > limit) return null + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + let headerSize = 8 + let size: number + + if (size32 === 1) { + if (offset + 16 > limit) return null + const extended = view.getBigUint64(offset + 8) + if (extended > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`MP4 box 过大: ${type}`) + } + size = Number(extended) + headerSize = 16 + } else if (size32 === 0) { + size = limit - offset + } else { + size = size32 + } + + if (size < headerSize || offset + size > limit) return null + return { type, offset, size, headerSize, end: offset + size } +} + +function walkBoxes( + buffer: Uint8Array, + start: number, + end: number, + visit: (box: IsoBox) => void, + depth = 0, +): void { + if (depth > 16) return + let offset = start + while (offset + 8 <= end) { + const box = readBox(buffer, offset, end) + if (!box) return + visit(box) + if (CONTAINERS.has(box.type)) { + // meta 是 FullBox,版本/flags 后才开始嵌套 box。 + const childStart = box.offset + box.headerSize + (box.type === 'meta' ? 4 : 0) + if (childStart < box.end) walkBoxes(buffer, childStart, box.end, visit, depth + 1) + } + offset = box.end + } +} + +function findBox(boxes: readonly IsoBox[], type: string): IsoBox | undefined { + return boxes.find(box => box.type === type) +} + +/** 读取顶层 mdat 的负载起点,即使 mdat 本体不在当前 head buffer 内也能定位。 */ +function findMdatStart(buffer: Uint8Array): number { + let offset = 0 + while (offset + 8 <= buffer.length) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const size32 = view.getUint32(offset) + const type = readFourcc(buffer, offset + 4) + const headerSize = size32 === 1 ? 16 : 8 + if (type === 'mdat') return offset + headerSize + if (size32 === 0) return -1 + if (size32 === 1) { + if (offset + 16 > buffer.length) return -1 + const size = view.getBigUint64(offset + 8) + if (size > BigInt(Number.MAX_SAFE_INTEGER)) return -1 + offset += Number(size) + } else { + offset += size32 + } + } + return -1 +} + +function parseSampleSizes(box: IsoBox, view: DataView): Uint32Array { + if (box.offset + 20 > box.end) throw new Error('stsz box 不完整') + const sampleSize = view.getUint32(box.offset + 12) + const count = view.getUint32(box.offset + 16) + const sizes = new Uint32Array(count) + if (sampleSize !== 0) { + sizes.fill(sampleSize) + return sizes + } + if (box.offset + 20 + count * 4 > box.end) throw new Error('stsz 采样表不完整') + for (let i = 0; i < count; i++) sizes[i] = view.getUint32(box.offset + 20 + i * 4) + return sizes +} + +function parseDurations(box: IsoBox, sampleCount: number, view: DataView): Uint32Array { + if (box.offset + 16 > box.end) throw new Error('stts box 不完整') + const entryCount = view.getUint32(box.offset + 12) + const durations = new Uint32Array(sampleCount) + let index = 0 + for (let i = 0; i < entryCount; i++) { + const offset = box.offset + 16 + i * 8 + if (offset + 8 > box.end) throw new Error('stts 采样表不完整') + const count = view.getUint32(offset) + const duration = view.getUint32(offset + 4) + for (let j = 0; j < count && index < sampleCount; j++) durations[index++] = duration + } + if (index !== sampleCount) throw new Error(`stts 采样数不一致: ${index}/${sampleCount}`) + return durations +} + +function parseIvs(box: IsoBox, sampleCount: number, view: DataView): Bytes { + if (box.offset + 16 > box.end) throw new Error('senc box 不完整') + const flags = view.getUint32(box.offset + 8) & 0xffffff + if ((flags & 0x02) !== 0) throw new Error('暂不支持带 subsample encryption 的音频') + const count = view.getUint32(box.offset + 12) + if (count !== sampleCount) throw new Error(`senc 采样数不一致: ${count}/${sampleCount}`) + const dataStart = box.offset + 16 + const dataLength = box.end - dataStart + if (count === 0 || dataLength % count !== 0) throw new Error('senc IV 表不完整') + const ivLength = dataLength / count + if (ivLength !== 8 && ivLength !== 16) throw new Error(`不支持的 CENC IV 长度: ${ivLength}`) + return asBytes(new Uint8Array(view.buffer, view.byteOffset + dataStart, dataLength)) +} + +function parseTimescale(box: IsoBox, view: DataView): number { + if (box.offset + 24 > box.end) throw new Error('mdhd box 不完整') + const version = view.getUint8(box.offset + 8) + const offset = version === 1 ? box.offset + 28 : box.offset + 20 + if (offset + 4 > box.end) throw new Error('mdhd timescale 缺失') + const timescale = view.getUint32(offset) + if (timescale === 0) throw new Error('MP4 timescale 为 0') + return timescale +} + +function parseCodec(buffer: Uint8Array, mdatStart: number): Pick { + const end = Math.max(0, Math.min(mdatStart - 1, buffer.length - 4)) + const dOps = findBytes(buffer, 'dOps', 0, end) + if (dOps >= 0) { + const channels = buffer[dOps + 5] ?? 1 + const preSkip = ((buffer[dOps + 6] ?? 0) << 8) | (buffer[dOps + 7] ?? 0) + return { codec: 'opus', channels, preSkip } + } + + // AudioSampleEntry 的 channelcount 位于 type 后 20 字节;加密入口也保留这段布局。 + const encodedEntry = findBytes(buffer, 'enca', 0, end) + const plainEntry = findBytes(buffer, 'mp4a', 0, end) + const entryType = encodedEntry >= 0 ? encodedEntry : plainEntry + const channels = entryType >= 0 && entryType + 22 <= buffer.length + ? new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16(entryType + 20) + : 1 + if (findBytes(buffer, 'dfLa', 0, end) >= 0) { + return { codec: 'flac', channels: channels || 1, preSkip: 0 } + } + if (entryType >= 0) { + return { codec: 'aac', channels: channels || 1, preSkip: 0 } + } + throw new Error('未识别的音频编码(需要 Opus、AAC 或 FLAC)') +} + +/** 解析一份已包含 moov 元数据的 MP4 CENC 头。 */ +export function parseCencMetadata(head: Uint8Array): CencMetadata { + const mdatStart = findMdatStart(head) + if (mdatStart < 0) throw new Error('未找到 mdat,MP4 头可能尚未拉完整') + + const boxes: IsoBox[] = [] + walkBoxes(head, 0, Math.min(mdatStart - 8, head.length), box => boxes.push(box)) + const stsz = findBox(boxes, 'stsz') + const stts = findBox(boxes, 'stts') + const senc = findBox(boxes, 'senc') + const mdhd = findBox(boxes, 'mdhd') + if (!stsz || !stts || !senc || !mdhd) { + throw new Error('CENC MP4 头缺少 stsz/stts/senc/mdhd') + } + + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const sizes = parseSampleSizes(stsz, view) + const durations = parseDurations(stts, sizes.length, view) + const ivs = parseIvs(senc, sizes.length, view) + const timescale = parseTimescale(mdhd, view) + const codec = parseCodec(head, mdatStart) + const offsets = new Float64Array(sizes.length + 1) + for (let i = 0; i < sizes.length; i++) { + offsets[i + 1] = offsets[i]! + sizes[i]! + if (!Number.isSafeInteger(offsets[i + 1])) throw new Error('音频采样数据超过 JavaScript 安全整数范围') + } + + let totalTicks = 0 + for (const duration of durations) { + totalTicks += duration + if (!Number.isSafeInteger(totalTicks)) throw new Error('音频总时长超过 JavaScript 安全整数范围') + } + return { + ...codec, + sampleCount: sizes.length, + sizes, + ivs, + durations, + timescale, + mdatStart, + offsets, + totalTicks, + } +} + +export function cencIvSize(meta: CencMetadata): number { + return meta.ivs.length / meta.sampleCount +} + +export function normalizeCencKey(value: string | ArrayBuffer | Uint8Array): Bytes { + if (typeof value === 'string') { + const bytes = hexToBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 32 位 hex') + return bytes + } + const bytes = asBytes(value) + if (bytes.length !== 16) throw new Error('AES-128 key 必须是 16 字节') + return bytes.slice() as Bytes +} + +export async function importCencKey(value: string | ArrayBuffer | Uint8Array): Promise { + return getSubtle().importKey( + 'raw', + normalizeCencKey(value), + { name: 'AES-CTR' }, + false, + ['decrypt'], + ) +} + +/** 按 CENC 的 8/16 字节 IV 构造 Web Crypto 的 16 字节 counter。 */ +export function makeCencCounter(iv: Uint8Array, counterStart: 0 | 1): Bytes { + if (iv.length !== 8 && iv.length !== 16) throw new Error(`无效的 CENC IV 长度: ${iv.length}`) + const counter = new Uint8Array(16) + counter.set(iv) + if (counterStart === 1) counter[15] = 1 + return counter as Bytes +} + +export async function decryptCencSample( + key: CryptoKey, + iv: Uint8Array, + ciphertext: Uint8Array, + counterStart: 0 | 1, +): Promise { + // TS 6 会把普通 Uint8Array 视为可能由 SharedArrayBuffer 支撑;Web Crypto 不接受它。 + const data = asBytes(ciphertext) + const plain = await getSubtle().decrypt( + { name: 'AES-CTR', counter: makeCencCounter(iv, counterStart), length: 64 }, + key, + data, + ) + return new Uint8Array(plain) as Bytes +} diff --git a/src/player/gmFetch.ts b/src/player/gmFetch.ts new file mode 100644 index 0000000..1c5c4a6 --- /dev/null +++ b/src/player/gmFetch.ts @@ -0,0 +1,191 @@ +// 音频 CDN 的取流通道。 +// +// 为什么不用页面 fetch:番茄网页站的 CSP(report-only)里 connect-src 没有任何 +// *vod.com 域名,音频 CDN 一个都不在白名单上。report-only 不会真拦请求,但每次 +// 都会往 mon.zijieapi.com/monitor_browser/collect/batch/security/ 上报一条违规, +// 等于把听书行为连着 URL 一起报给服务端。所以走 GM_xmlhttpRequest: +// 它在页面 CSP 之外,既不触发上报,也天然不带页面 Referer 和 Cookie。 +// +// 试过换 aid 让服务端返回白名单内的域名,行不通 —— CDN 域名跟着 aid 所属产品走: +// 1967 番茄小说 -> *.fqnovelvod.com 3040 番茄畅听 -> *.novelfmvod.com +// 13/35 头条 -> *.toutiaovod.com 6589 -> *.wkbrowser.com +// 1217/1128/8478/32/596084 -> PARAM_INVALID(不支持听书接口) +// 没有一个落在 connect-src 里,而且签名里的 aid 必须与 query 一致, +// 否则网关直接回 200 空响应体。 + +type AnyResponse = Tampermonkey.Response & { response?: unknown } + +/** + * 用 GM_xmlhttpRequest 实现的、够播放器用的 fetch 子集。 + * + * 只支持播放器实际用到的部分:GET + Range + AbortSignal + 流式 body。 + * + * 关于 responseType 的坑(踩过两次,记在这): + * - `'stream'`:TM 会把 ReadableStream 放进 res.response,但**不是**在 + * readyState 2 那一刻就绪。在 readyState 2 判断 instanceof 永远失败。 + * - `'arraybuffer'`:res.response 只在 readyState 4 才有值,onprogress + * 期间一直是 null,拿不到增量。 + * - `responseText`:绝对不能用来还原二进制。它按字符解码过,>0xFF 的码位 + * 已经丢了原始字节,喂进 MSE 会直接报 SourceBuffer 更新失败。 + * 所以这里两种都试:优先等 'stream',拿不到就退回整段 arraybuffer。 + */ +export function gmAudioFetch( + input: string | URL | Request, + init: RequestInit = {}, +): Promise { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const headers = normalizeHeaders(init.headers) + const signal = init.signal ?? null + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError()) + return + } + + let settled = false + let request: { abort: () => void } | undefined + + const onAbort = (): void => { + try { request?.abort() } catch { /* 尚未开始或已结束 */ } + if (settled) return + settled = true + reject(abortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + + const cleanup = (): void => { + signal?.removeEventListener('abort', onAbort) + } + + const succeed = (res: Tampermonkey.ResponseBase, body: BodyInit | null): void => { + if (settled) return + settled = true + cleanup() + resolve(makeResponse(res, body)) + } + + const fail = (error: Error): void => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + request = GM_xmlhttpRequest({ + method: 'GET', + url, + headers, + responseType: 'stream', + // GM 请求不带页面 Referer;anonymous 同时也不带 Cookie + anonymous: true, + onreadystatechange(res) { + // readyState 3/4 时 TM 才把 stream 挂上来。拿到就立刻交出去, + // 播放器那边就能边下边解密,不必等整段下完 + if (settled || res.readyState < 3) return + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) succeed(res, stream) + }, + onload(res) { + if (settled) return + // 没走成 stream:这时 readyState 已是 4,response 里是完整数据 + const stream = (res as AnyResponse).response + if (stream instanceof ReadableStream) { + succeed(res, stream) + return + } + const bytes = toBytes(res) + if (!bytes) { + fail(new Error( + 'GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)', + )) + return + } + if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) { + fail(new Error('音频响应为空')) + return + } + succeed(res, bytes as unknown as BodyInit) + }, + onerror(res) { + fail(new Error(`音频 CDN 请求失败:${describe(res)}`)) + }, + ontimeout() { + fail(new Error('音频 CDN 请求超时')) + }, + }) as unknown as { abort: () => void } + }) +} + +function abortError(): DOMException { + return new DOMException('音频请求已取消', 'AbortError') +} + +/** 把 GM 的响应头文本解析成 Headers */ +function parseHeaders(raw: string | undefined): Headers { + const headers = new Headers() + if (!raw) return headers + for (const line of raw.split(/\r?\n/)) { + const colon = line.indexOf(':') + if (colon <= 0) continue + const name = line.slice(0, colon).trim() + const value = line.slice(colon + 1).trim() + if (!name) continue + try { + headers.append(name, value) + } catch { + // 个别响应头名字不合规范(比如带空格),跳过即可 + } + } + return headers +} + +/** + * 用 GM 的状态和响应头拼一个 Response。 + * + * 播放器只读 status / ok / body,不需要还原全部语义。 + * body 为空时给一条空流:206 是合法状态,但 `new Response(null, {status:206})` + * 会抛错(只有 204/304 允许空 body)。 + */ +function makeResponse(res: Tampermonkey.ResponseBase, body: BodyInit | null): Response { + return new Response(body ?? new Uint8Array(), { + status: res.status || 200, + statusText: res.statusText || '', + headers: parseHeaders(res.responseHeaders), + }) +} + +/** + * 从 GM 响应里取出完整字节。 + * + * 只认真正的二进制载荷 —— 见文件头关于 responseText 的说明, + * 拿不到就返回 null 让上层报错,比悄悄喂坏数据好。 + */ +function toBytes(res: Tampermonkey.Response): Uint8Array | null { + const raw = (res as AnyResponse).response + if (raw instanceof ArrayBuffer) return new Uint8Array(raw) + if (raw instanceof Uint8Array) return raw + return null +} + +function normalizeHeaders(init: HeadersInit | undefined): Record { + const out: Record = {} + if (!init) return out + if (init instanceof Headers) { + init.forEach((value, key) => { out[key] = value }) + return out + } + if (Array.isArray(init)) { + for (const [key, value] of init) { + if (key !== undefined && value !== undefined) out[key] = value + } + return out + } + return { ...init } +} + +function describe(res: Tampermonkey.ErrorResponse | Tampermonkey.ResponseBase): string { + if ('error' in res && res.error) return String(res.error) + const status = (res as Tampermonkey.ResponseBase).status + return status ? `HTTP ${status}` : '网络错误' +} diff --git a/src/player/index.ts b/src/player/index.ts new file mode 100644 index 0000000..01ff874 --- /dev/null +++ b/src/player/index.ts @@ -0,0 +1,24 @@ +/** + * CENC 听书播放器。 + * + * 最小用法: + * const player = new CencAudioPlayer(audioElement) + * await player.start({ url, key }) + * // 切章:再次 start;离开听书页:player.destroy() + */ +export { + CencAudioPlayer, + type CencAudioPlayerCallbacks, + type CencAudioPlayerOptions, + type CencAudioSource, + type CencMediaInfo, + type CencPlayerState, + type CencProgress, +} from './CencAudioPlayer' + +export { + normalizeCencKey, + parseCencMetadata, + type CencCodec, + type CencMetadata, +} from './cenc' diff --git a/src/player/remux.ts b/src/player/remux.ts new file mode 100644 index 0000000..b419d33 --- /dev/null +++ b/src/player/remux.ts @@ -0,0 +1,260 @@ +import { ascii, be16, be32, be64, concatBytes, findBytes, hexToBytes, strBytes, uintBytes, type Bytes } from './bytes' +import type { CencCodec, CencMetadata } from './cenc' + +function ebmlVint(value: number, length: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0 || length < 1 || length > 8) { + throw new Error(`无效的 EBML VINT: ${value}`) + } + const result = new Uint8Array(length) + result[0] = 0x80 >> (length - 1) + let current = value + for (let i = length - 1; i > 0; i--) { + result[i] = current & 0xff + current = Math.floor(current / 256) + } + const max = Math.pow(2, 7 * length) + if (current >= max) throw new Error(`EBML VINT 溢出: ${value}`) + result[0] |= current + return result as Bytes +} + +/** + * 编码 EBML 元素长度。 + * + * 关键约束:全 1 的 VINT(1 字节的 0xFF、2 字节的 0x7FFF……)在 EBML 里是保留值, + * 表示「长度未知」。所以 2^(7n)-1 这些边界值必须多用一个字节来编, + * 否则解析器会把这个元素当成 unknown-size,从它开始一路吞掉后面的数据。 + * + * 踩过的坑:Opus 帧正好 127 字节时 SimpleBlock 的 payload 长度就是 127, + * 编成 0xFF 之后 Chromium 报 CHUNK_DEMUXER_ERROR_APPEND_FAILED, + * ffmpeg 报 "Unknown-sized element inside parent with finite size", + * 750 帧里只解出 8 帧。 + */ +function ebmlSize(value: number): Bytes { + for (let length = 1; length <= 8; length++) { + // < 2^(7n)-1:留出全 1 这个保留值,等于时进位到下一个长度 + if (value < Math.pow(2, 7 * length) - 1) return ebmlVint(value, length) + } + throw new Error(`EBML size 过大: ${value}`) +} + +function ebmlElement(id: string, payload: Uint8Array): Bytes { + return concatBytes(hexToBytes(id), ebmlSize(payload.length), payload) +} + +function mp4Box(type: string, payload: Uint8Array): Bytes { + return concatBytes(be32(8 + payload.length), ascii(type), payload) +} + +function matrixIdentity(): Bytes { + const matrix = new Uint8Array(36) + const view = new DataView(matrix.buffer) + view.setUint32(0, 0x10000) + view.setUint32(16, 0x10000) + view.setUint32(32, 0x40000000) + return matrix as Bytes +} + +function signedBe16(value: number): Bytes { + const result = new Uint8Array(2) + new DataView(result.buffer).setInt16(0, value) + return result as Bytes +} + +/** 构造只包含 Opus 音轨的 WebM 初始化段。 */ +export function buildWebmInit(channels: number, preSkip: number, durationSeconds: number): Bytes { + const ebml = ebmlElement('1A45DFA3', concatBytes( + ebmlElement('4286', hexToBytes('01')), + ebmlElement('42F7', hexToBytes('01')), + ebmlElement('42F2', hexToBytes('04')), + ebmlElement('42F3', hexToBytes('08')), + ebmlElement('4282', strBytes('webm')), + ebmlElement('4287', hexToBytes('04')), + ebmlElement('4285', hexToBytes('02')), + )) + const codecDelayNs = Math.round(preSkip * 1e9 / 48000) + const duration = new Uint8Array(8) + new DataView(duration.buffer).setFloat64(0, durationSeconds, false) + const info = ebmlElement('1549A966', concatBytes( + ebmlElement('2AD7B1', uintBytes(1000000)), + ebmlElement('4489', duration as Bytes), + ebmlElement('4D80', strBytes('clearKeyStreamer')), + ebmlElement('5741', strBytes('clearKeyStreamer')), + )) + const sampleRate = new Uint8Array(4) + new DataView(sampleRate.buffer).setFloat32(0, 48000, false) + const audio = ebmlElement('E1', concatBytes( + ebmlElement('B5', sampleRate as Bytes), + ebmlElement('9F', uintBytes(channels)), + )) + const track = ebmlElement('AE', concatBytes( + ebmlElement('D7', hexToBytes('01')), + ebmlElement('73C5', uintBytes(1)), + ebmlElement('83', hexToBytes('02')), + ebmlElement('86', strBytes('A_OPUS')), + ebmlElement('63A2', buildOpusHead(channels, preSkip)), + ebmlElement('56AA', uintBytes(codecDelayNs)), + ebmlElement('56BB', uintBytes(80000000)), + audio, + )) + const tracks = ebmlElement('1654AE6B', track) + // Unknown-size Segment permits later clusters and seek-triggered re-buffering. + return concatBytes(ebml, hexToBytes('18538067'), hexToBytes('01FFFFFFFFFFFFFF'), info, tracks) +} + +function buildOpusHead(channels: number, preSkip: number): Bytes { + const head = new Uint8Array(19) + head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0) + const view = new DataView(head.buffer) + head[8] = 1 + head[9] = channels + view.setUint16(10, preSkip, true) + view.setUint32(12, 48000, true) + view.setInt16(16, 0, true) + head[18] = 0 + return head as Bytes +} + +/** 构造一个包含若干 Opus Block 的 WebM Cluster。 */ +export function buildWebmCluster(baseMs: number, relativeMs: readonly number[], packets: readonly Uint8Array[]): Bytes { + if (relativeMs.length !== packets.length) throw new Error('WebM 时间戳和采样数不一致') + const blocks: Bytes[] = [] + for (let i = 0; i < packets.length; i++) { + const relative = relativeMs[i] + if (relative === undefined || relative < -32768 || relative > 32767) { + throw new Error(`WebM 相对时间戳超出 Int16: ${relative}`) + } + const trackAndFlags = new Uint8Array([0x81, 0x80]) + blocks.push(ebmlElement('A3', concatBytes( + trackAndFlags.slice(0, 1), + signedBe16(relative), + trackAndFlags.slice(1), + packets[i]!, + ))) + } + return ebmlElement('1F43B675', concatBytes( + ebmlElement('E7', uintBytes(baseMs)), + concatBytes(...blocks), + )) +} + +function replaceFourcc(buffer: Bytes, offset: number, type: string): void { + buffer.set(ascii(type), offset) +} + +/** 从原始 MP4 头复制采样入口,并把加密入口改回浏览器可识别的明文入口。 */ +export function extractSampleEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + const plain = findBytes(head, 'mp4a', 0, mdatStart - 1) + const typeOffset = encoded >= 0 ? encoded : plain + if (typeOffset < 4) throw new Error('未找到 AAC 音频采样入口') + const entryOffset = typeOffset - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('AAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + const enca = findBytes(entry, 'enca', 0) + if (enca >= 0) replaceFourcc(entry, enca, 'mp4a') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 从原始 MP4 头复制 FLAC 采样入口,并去掉 CENC 标记。 */ +export function extractFlacEntry(head: Uint8Array, mdatStart: number): Bytes { + const encoded = findBytes(head, 'enca', 0, mdatStart - 1) + if (encoded < 4) throw new Error('未找到 FLAC 加密采样入口') + const entryOffset = encoded - 4 + const view = new DataView(head.buffer, head.byteOffset, head.byteLength) + const size = view.getUint32(entryOffset) + if (size < 8 || entryOffset + size > head.length) throw new Error('FLAC 采样入口不完整') + const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes + replaceFourcc(entry, encoded - entryOffset, 'fLaC') + const sinf = findBytes(entry, 'sinf', 8) + if (sinf >= 0) replaceFourcc(entry, sinf, 'free') + return entry +} + +/** 构造 AAC/FLAC 的 fragmented MP4 初始化段。 */ +export function buildMp4Init(sampleEntry: Uint8Array, timescale: number, totalTicks: number): Bytes { + const totalSeconds = totalTicks / timescale + const matrix = matrixIdentity() + const mvhd = mp4Box('mvhd', concatBytes( + be32(0), be32(0), be32(0), be32(1000), be32(Math.round(totalSeconds * 1000)), + be32(0x10000), be16(0x100), be16(0), new Uint8Array(8), + matrix, new Uint8Array(24), be32(2), + )) + const tkhd = mp4Box('tkhd', concatBytes( + be32(7), be32(0), be32(0), be32(1), be32(0), be32(0), new Uint8Array(8), + be16(0), be16(0), be16(0x100), be16(0), matrix, be32(0), be32(0), + )) + const mdhd = mp4Box('mdhd', concatBytes( + be32(0), be32(0), be32(0), be32(timescale), be32(totalTicks), be16(0x55c4), be16(0), + )) + const hdlr = mp4Box('hdlr', concatBytes( + be32(0), be32(0), ascii('soun'), new Uint8Array(12), + )) + const smhd = mp4Box('smhd', concatBytes(be32(0), be16(0), be16(0))) + const url = mp4Box('url ', be32(1)) + const dref = mp4Box('dref', concatBytes(be32(0), be32(1), url)) + const dinf = mp4Box('dinf', dref) + const stsd = mp4Box('stsd', concatBytes(be32(0), be32(1), sampleEntry)) + const stts = mp4Box('stts', concatBytes(be32(0), be32(0))) + const stsc = mp4Box('stsc', concatBytes(be32(0), be32(0))) + const stsz = mp4Box('stsz', concatBytes(be32(0), be32(0), be32(0))) + const stco = mp4Box('stco', concatBytes(be32(0), be32(0))) + const stbl = mp4Box('stbl', concatBytes(stsd, stts, stsc, stsz, stco)) + const minf = mp4Box('minf', concatBytes(smhd, dinf, stbl)) + const mdia = mp4Box('mdia', concatBytes(mdhd, hdlr, minf)) + const trak = mp4Box('trak', concatBytes(tkhd, mdia)) + const trex = mp4Box('trex', concatBytes(be32(0), be32(1), be32(1), be32(0), be32(0), be32(0))) + const mvex = mp4Box('mvex', trex) + const moov = mp4Box('moov', concatBytes(mvhd, trak, mvex)) + const ftyp = mp4Box('ftyp', concatBytes(ascii('isom'), be32(512), ascii('isomiso2mp41dash'))) + return concatBytes(ftyp, moov) +} + +export interface Mp4Sample { + data: Uint8Array + duration: number +} + +/** 构造一个 fragmented MP4 media segment。 */ +export function buildMp4Segment( + baseDecodeTick: number, + samples: readonly Mp4Sample[], + sequence: number, +): Bytes { + if (samples.length === 0) throw new Error('MP4 segment 不能没有采样') + const sampleTable = samples.flatMap(sample => [be32(sample.duration), be32(sample.data.length)]) + const mfhd = mp4Box('mfhd', concatBytes(be32(0), be32(sequence))) + const tfhd = mp4Box('tfhd', concatBytes(be32(0x020000), be32(1))) + const tfdt = mp4Box('tfdt', concatBytes(be32(0x01000000), be64(baseDecodeTick))) + const makeTrun = (offset: number) => mp4Box('trun', concatBytes( + be32(0x000301), be32(samples.length), be32(offset), concatBytes(...sampleTable), + )) + const makeMoof = (offset: number) => mp4Box('moof', concatBytes( + mfhd, mp4Box('traf', concatBytes(tfhd, tfdt, makeTrun(offset))), + )) + const mediaPayload = concatBytes(...samples.map(sample => sample.data)) + const dataOffset = makeMoof(0).length + 8 + return concatBytes(makeMoof(dataOffset), mp4Box('mdat', mediaPayload)) +} + +export function codecMime(codec: CencCodec): string { + if (codec === 'opus') return 'audio/webm;codecs="opus"' + if (codec === 'flac') return 'audio/mp4; codecs="fLaC"' + return 'audio/mp4; codecs="mp4a.40.2"' +} + +export function initSegment( + meta: CencMetadata, + sampleEntry?: Uint8Array, +): Bytes { + if (meta.codec === 'opus') { + const playableTicks = Math.max(0, meta.totalTicks - meta.preSkip) + return buildWebmInit(meta.channels, meta.preSkip, playableTicks / meta.timescale) + } + if (!sampleEntry) throw new Error('MP4 编码缺少 sample entry') + return buildMp4Init(sampleEntry, meta.timescale, meta.totalTicks) +} diff --git a/src/settings.ts b/src/settings.ts index bf3c5c4..05de70b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,9 +9,15 @@ export type ApiPreference = 'app' | 'redcandle' /** 下载格式 */ export type DownloadFormat = 'epub' | 'txt' +/** 书架里单击书本的去向 */ +export type BookshelfClickAction = 'read' | 'detail' + /** TXT 编码 */ export type DownloadCharset = 'utf-8' | 'gbk' +/** 听书读完一章之后做什么 */ +export type AudiobookChapterEnd = 'next' | 'stop' + export interface Settings { /* --- 常规 --- */ /** 解密网页端混淆字体 */ @@ -28,6 +34,11 @@ export interface Settings { customCssEnabled: boolean /** 自定义 CSS 内容。关闭开关时仍然保留,只是不应用 */ customCss: string + /** + * 书架里单击书本的默认去向。 + * 按住 Ctrl 单击取相反项;中键(或 Ctrl+中键)在新标签页打开对应目标。 + */ + bookshelfClickAction: BookshelfClickAction /* --- 搜索 --- */ /** 接管网页搜索界面 */ @@ -55,6 +66,12 @@ export interface Settings { /** EPUB:保留书籍自带的排版样式(css_map) */ downloadBookCss: boolean + /* --- 听书 --- */ + /** 读完一章之后:next=自动下一章,stop=停下 */ + audiobookChapterEnd: AudiobookChapterEnd + /** 高亮朗读中的段落,并跟随滚动 */ + audiobookFollow: boolean + /* --- 协议 --- */ apiPreference: ApiPreference /** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */ @@ -71,6 +88,8 @@ export const DEFAULT_SETTINGS: Settings = { readerFont: '', customCssEnabled: false, customCss: '', + // 原站书架点封面是继续阅读,保持一致 + bookshelfClickAction: 'read', enhanceSearch: true, // 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启 @@ -88,6 +107,9 @@ export const DEFAULT_SETTINGS: Settings = { downloadImages: true, downloadBookCss: true, + audiobookChapterEnd: 'next', + audiobookFollow: true, + apiPreference: 'app', deviceId: '', installId: '', @@ -116,6 +138,12 @@ function normalize(raw: unknown): Settings { if (s.downloadCharset !== 'utf-8' && s.downloadCharset !== 'gbk') { s.downloadCharset = DEFAULT_SETTINGS.downloadCharset } + if (s.bookshelfClickAction !== 'read' && s.bookshelfClickAction !== 'detail') { + s.bookshelfClickAction = DEFAULT_SETTINGS.bookshelfClickAction + } + if (s.audiobookChapterEnd !== 'next' && s.audiobookChapterEnd !== 'stop') { + s.audiobookChapterEnd = DEFAULT_SETTINGS.audiobookChapterEnd + } // 数字项来自输入框,可能是 NaN 或越界值 s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize) s.downloadInterval = clampInt(s.downloadInterval, 0, 10_000, DEFAULT_SETTINGS.downloadInterval) diff --git a/src/types.ts b/src/types.ts index b2c01d0..35b3934 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,3 +210,131 @@ export interface SearchLandingSection { words: SearchHotWord[] books: SearchSuggestBook[] } + +/* --------------------------------- 书评 --------------------------------- */ + +/** 评论作者。头衔(vip/作者等)接口下发的是内嵌 JSON 字符串,这里只留解析后的名字 */ +export interface CommentUser { + user_id: string + name: string + avatar: string + description: string + is_author: boolean + is_vip: boolean + /** 头衔名,如 vip、榜一大哥 */ + titles: string[] +} + +/** + * 正文里的富文本片段(书名号引用、话题等)。 + * + * s/e 是 UTF-16 码元下标(实测与 String.prototype.slice 一致, + * 不是 UTF-8 字节,也不是码点序号)。tp 目前只见过 4 = 搜索跳转。 + */ +export interface CommentTextExt { + start: number + end: number + text: string + type: number + /** dragon1967:// 开头的 APP 内部 schema,网页端点不了,仅保留原文 */ + uri?: string +} + +/** 评论与回复共有的部分 */ +export interface CommentBase { + id: string + text: string + /** 富文本片段,可能为空 */ + exts: CommentTextExt[] + /** 秒级时间戳 */ + create_time: number + user: CommentUser + digg_count: number + reply_count: number + /** 当前用户是否赞过。未登录(或读不到 sessionid)时恒为 false */ + user_digg: boolean + user_disagree: boolean +} + +/** 一楼书评 */ +export interface BookComment extends CommentBase { + /** 评分,1-10,缺失为 0 */ + score: number + /** 接口下发的展示文案,如「阅读19小时后点评」 */ + score_text: string + /** 作者置顶 */ + author_stick: boolean + /** 追评。同一用户在原评论之后补写的内容 */ + addition?: CommentBase + /** 接口下发的前几条回复,展开前先用它 */ + preview_reply_count: number +} + +/** 楼中楼回复。二级回复挂在 sub 里 */ +export interface CommentReply extends CommentBase { + /** 所属评论 id */ + to_comment_id: string + /** 二级回复时,父回复的 id */ + to_reply_id: string + /** 接口预览下发的子回复 */ + sub: CommentReply[] +} + +/** 书评区筛选标签,如「长评」「文笔很好」 */ +export interface CommentFilterTag { + tag_id: string + tag_name: string + count: number +} + +/** 评论排序。1=最热,3=最新(其余值接口报参数错误) */ +export type CommentSort = 1 | 3 + +export interface CommentListResult { + comments: BookComment[] + /** 服务端游标,原样回传即可翻页 */ + cursor: string + has_more: boolean + /** 该书评论总数(受 tag 过滤影响) */ + total: number + tags: CommentFilterTag[] + /** 点评人数展示文案,如「1.1万人点评」 */ + score_text: string + score_count: number + /** + * 当前登录用户自己写的那条书评 / 打分。 + * 服务端把它单独放在 extra.user_comment,不混在列表里; + * 没登录或还没点评过时为 null。打过分后即可用它回显星级。 + */ + user_comment: BookComment | null +} + +export interface ReplyListResult { + replies: CommentReply[] + cursor: string + has_more: boolean + total: number +} + +export interface CencAudioContext { + urls: string[] + vid: string + key: string // empty for a not encrypted audio + item_id: string +} + +export interface Tone { + id: number + name: string + icon: string // 空字符串是没有图标 + gender: number // 1=男声 2=女声 + description: string +} + +export interface ParagraphTimeTag { + is_title: boolean // idx=10000 就是标题 + startidx: number + endidx: number + startms: number + endms: number +} \ No newline at end of file diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts new file mode 100644 index 0000000..1d9e20c --- /dev/null +++ b/src/utils/cookie.ts @@ -0,0 +1,127 @@ +// Cookie 读取。 +// +// 网页站的 sessionid 是 HttpOnly,document.cookie 读不到,而书评接口挂在 +// reading.snssdk.com 根路径(网页站只把 /reading 反代到了 APP 网关), +// 拿不到同源那份 Cookie,只能自己把 sessionid 塞进请求头。 +// +// GM_cookie 能读 HttpOnly,但 Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, +// 需要用户手动放开: +// 设置 → 通用 → 配置模式 → 高级 +// 设置 → 高级 → 安全 → 允许脚本访问 Cookie → All +// 见 https://github.com/Tampermonkey/tampermonkey/issues/465#issuecomment-2479061865 +// +// 没有任何接口能告诉我们「是不是被这个开关挡住了」,所以只能反推: +// 用户明明是登录状态(checkLogin 通过),却读不到 sessionid —— 那就是被挡了。 + +/** 放开 Cookie 访问的操作路径,取不到 sessionid 时提示用户 */ +export const COOKIE_PERMISSION_HINT = + '读不到登录态(sessionid)。若确认已登录,请在 Tampermonkey 里放开 Cookie 访问:' + + '设置 → 通用 → 配置模式 改为「高级」,再到 设置 → 高级 → 安全 →' + + '「允许脚本访问 Cookie」改为 All。' + +/** GM_cookie.list 迟迟不回调时的等待上限 */ +const LIST_TIMEOUT = 5_000 + +export type CookieFailure = + /** 脚本管理器不支持,或没授权 GM_cookie */ + | 'no-api' + /** GM_cookie.list 报错或超时 */ + | 'api-error' + /** 接口正常但没有这条 Cookie(未登录,或被 HttpOnly 开关挡住) */ + | 'not-found' + +export interface CookieLookup { + value: string | null + reason?: CookieFailure + /** api-error 时的原始错误信息 */ + error?: string +} + +function hasGMCookie(): boolean { + // @grant GM_cookie 缺失时这个标识符压根不存在,typeof 才不会抛 + return typeof GM_cookie !== 'undefined' && typeof GM_cookie?.list === 'function' +} + +/** 把回调式的 GM_cookie.list 包成 Promise,并防它不回调 */ +function listCookies(details: Tampermonkey.ListCookiesDetails): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (fn: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + fn() + } + const timer = setTimeout( + () => finish(() => reject(new Error('GM_cookie.list 无响应'))), + LIST_TIMEOUT, + ) + try { + GM_cookie.list(details, (cookies, error) => { + finish(() => (error ? reject(new Error(error)) : resolve(cookies ?? []))) + }) + } catch (err) { + finish(() => reject(err instanceof Error ? err : new Error(String(err)))) + } + }) +} + +/** document.cookie 兜底,只能读到非 HttpOnly 的 */ +function readFromDocument(name: string): string | null { + for (const part of document.cookie.split(';')) { + const raw = part.trim() + if (raw.startsWith(name + '=')) return raw.slice(name.length + 1) + } + return null +} + +/** + * 读一条 Cookie。先试 GM_cookie(能读 HttpOnly),再退到 document.cookie。 + * + * @param name Cookie 名 + * @param url 归属页面,默认当前文档 + */ +export async function readCookie(name: string, url?: string): Promise { + if (hasGMCookie()) { + try { + const cookies = await listCookies(url ? { url, name } : { name }) + // 指定了 name 仍可能匹配到多条(不同 domain/path),取第一条有值的 + const hit = cookies.find(c => c.name === name && c.value) + if (hit) return { value: hit.value } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn('[fqa:cookie] GM_cookie.list 失败,退到 document.cookie:', message) + const fallback = readFromDocument(name) + if (fallback) return { value: fallback } + return { value: null, reason: 'api-error', error: message } + } + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'not-found' } + } + + const fallback = readFromDocument(name) + return fallback ? { value: fallback } : { value: null, reason: 'no-api' } +} + +const SESSION_COOKIE = 'sessionid' + +/** 成功读到的 sessionid。同一页面内复用,避免每次点赞都问一遍脚本管理器 */ +let cached: string | null = null + +/** + * 读网页站的 sessionid。 + * + * 只在需要写操作(点赞等)或需要「我是否赞过」状态时调用; + * 拿到的值仅随请求发往字节官方的 reading.snssdk.com,不经任何第三方。 + */ +export async function getSessionId(): Promise { + if (cached) return { value: cached } + const found = await readCookie(SESSION_COOKIE) + if (found.value) cached = found.value + return found +} + +/** 登录状态变化后调用,丢掉旧的 sessionid */ +export function clearSessionIdCache(): void { + cached = null +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 67ffdeb..4055234 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -78,4 +78,9 @@ export function concatArrayBuffers(...buffers: ArrayBuffer[]): ArrayBuffer { offset += buf.byteLength } return result.buffer +} + +export function clearNodeEventListeners(element: T): void { + element.replaceWith(cloneElement(element)) + return } \ No newline at end of file diff --git a/src/utils/navigate.ts b/src/utils/navigate.ts new file mode 100644 index 0000000..c098738 --- /dev/null +++ b/src/utils/navigate.ts @@ -0,0 +1,58 @@ +// 书籍卡片的跳转辅助。 +// +// 交互约定: +// 单击 -> 设置里选的默认去向(继续阅读 / 查看详情) +// Ctrl+单击 -> 另一个去向 +// 中键 -> 默认去向,新标签页 +// Ctrl+中键 -> 另一个去向,新标签页 +// +// macOS 上 Ctrl+单击被系统当成右键(会触发 contextmenu),所以 Cmd 也算「反向」, +// 否则 Mac 用户没有可用的反向键。 + +/** 鼠标中键 */ +const MIDDLE_BUTTON = 1 + +export interface OpenModifiers { + /** 取与默认相反的去向 */ + flip: boolean + /** 在新标签页打开 */ + newTab: boolean +} + +/** 从鼠标/键盘事件里读出修饰意图。键盘事件没有 button,只可能 flip */ +export function modifiersOf(event: MouseEvent | KeyboardEvent): OpenModifiers { + return { + flip: event.ctrlKey || event.metaKey, + newTab: 'button' in event && event.button === MIDDLE_BUTTON, + } +} + +/** 中键按下时浏览器会进入自动滚动模式,光标变成罗盘,需要拦掉 */ +export function isMiddleButton(event: MouseEvent): boolean { + return event.button === MIDDLE_BUTTON +} + +export function readerUrl(chapterId: string): string { + return `https://fanqienovel.com/reader/${chapterId}` +} + +export function bookPageUrl(bookId: string): string { + return `https://fanqienovel.com/page/${bookId}` +} + +/** + * 跳转。newTab 为真时开新标签页。 + * + * 不传 'noopener':按规范带上它 window.open 一律返回 null,就没法判断是否被拦了。 + * 目标是同源页面,opener 可访问本身无害。 + */ +export function openUrl(url: string, newTab = false): void { + if (!newTab) { + unsafeWindow.location.href = url + return + } + // 中键点击是用户手势,正常不会被拦;真被拦了也不要偷偷改成当前页跳转 + if (!unsafeWindow.open(url, '_blank')) { + console.warn('[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:', url) + } +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 46288ea..3e3799a 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -6,6 +6,30 @@ export type ApiResponse = Tampermonkey.Response & { json(): T; }; +/** + * 响应没有任何正文。 + * + * 番茄网关对「不认识的设备」就是这个回应:HTTP 200、`content-length: 0`、 + * 一个字节都没有。裸 JSON.parse 会抛 `SyntaxError: "undefined" is not valid JSON` + * (Tampermonkey 在无正文时不给 responseText),完全看不出真正的原因, + * 所以这里换成一个能认出来的错误类型。 + */ +export class EmptyResponseError extends Error { + readonly status: number; + + constructor(status: number) { + super(`服务端返回了空响应体(HTTP ${status})`); + this.name = "EmptyResponseError"; + this.status = status; + } +} + +/** 响应体是否为空。Tampermonkey 可能给 undefined,也可能给空串 */ +export function isEmptyResponse(res: Pick): boolean { + const text = res.responseText; + return typeof text !== "string" || text === ""; +} + const supportedMethods = new Set([ "GET", "HEAD", @@ -59,6 +83,9 @@ export default function apiFetch( cleanup(); resolve(Object.assign(response, { json(this: Tampermonkey.Response): T { + if (isEmptyResponse(this)) { + throw new EmptyResponseError(this.status); + } return JSON.parse(this.responseText) as T; }, })); diff --git a/src/views/AudioBar.vue b/src/views/AudioBar.vue new file mode 100644 index 0000000..788010a --- /dev/null +++ b/src/views/AudioBar.vue @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + 听 + + + + + + + {{ state.title }} + + {{ state.error || (state.loading ? '缓冲中…' : state.toneName) }} + + + + + + + + + + + + + × + + + + + + diff --git a/src/views/BookCard.vue b/src/views/BookCard.vue index 6a47823..2b57f59 100644 --- a/src/views/BookCard.vue +++ b/src/views/BookCard.vue @@ -1,13 +1,14 @@ + + + + + {{ props.current === null ? '选择音色' : '切换音色' }} + 切换后会从当前段落开头继续 + + + + + {{ tone.name.slice(0, 1) }} + + + {{ tone.name }} + + {{ genderLabel(tone.gender) }} + + + {{ tone.description }} + + + + + + 取消 + + + + diff --git a/vite.config.ts b/vite.config.ts index a3eced1..a6beb61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,7 +30,10 @@ export default defineConfig({ author: 'naiyQAQ', 'run-at': 'document-start', match: ['*://*.fanqienovel.com/*'], - grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'unsafeWindow'], + // GM_cookie 用于读 HttpOnly 的 sessionid(书评点赞需要登录态)。 + // 注意:Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie, + // 用户需在 设置→通用→配置模式→高级,再 设置→高级→安全→允许脚本访问 Cookie→All + grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'GM_cookie', 'unsafeWindow'], connect: [ 'fanqienovel.com', // 主站 'jxbhmy.com', // 红烛小说 API @@ -38,6 +41,11 @@ export default defineConfig({ 'byteimg.com', // 图床 'fqnovelpic.com', // 图床 'bytecdn.cn', // 图床 + // 听书音频 CDN。页面 CSP 的 connect-src 不含这些域名, + // 走页面 fetch 会被 report-only 策略上报,所以改用 GM_xmlhttpRequest + 'fqnovelvod.com', // 番茄小说 TTS + 'novelfmvod.com', // 番茄畅听 + 'volcautovod.com', // 火山引擎回源 ], }, build: {
切换后会从当前段落开头继续