From 20e7a6fc79589ed6412b662e6d8ed55ebd25cb51 Mon Sep 17 00:00:00 2001 From: naiy Date: Mon, 7 Sep 2026 23:57:12 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9A=82=E5=AD=98=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 58 +- dist/fanqie-assistant.user.js | 5525 +++++++++++++++++++++++++-------- src/api/app.ts | 66 +- src/api/audiobook.ts | 68 + src/api/comment.ts | 478 +++ src/api/content.ts | 4 + src/api/provision.ts | 104 +- src/assets/audiobook.css | 360 +++ src/assets/left.svg | 1 + src/assets/paused.svg | 6 + src/assets/playing.svg | 74 + src/assets/script.css | 4 + src/assets/star.svg | 15 + src/assets/star_fill.svg | 18 + src/audioPanel.ts | 72 + src/audiobook/controller.ts | 400 +++ src/audiobook/index.ts | 20 + src/audiobook/paragraph.ts | 193 ++ src/crypto/spade.ts | 53 + src/hooks/readerHook.ts | 60 + src/player/CencAudioPlayer.ts | 1083 +++++++ src/player/bytes.ts | 109 + src/player/cenc.ts | 287 ++ src/player/gmFetch.ts | 191 ++ src/player/index.ts | 24 + src/player/remux.ts | 260 ++ src/settings.ts | 28 + src/types.ts | 128 + src/utils/cookie.ts | 127 + src/utils/index.ts | 5 + src/utils/navigate.ts | 58 + src/utils/request.ts | 27 + src/views/AudioBar.vue | 105 + src/views/BookCard.vue | 29 +- src/views/BookshelfView.vue | 32 +- src/views/SettingsView.vue | 51 +- src/views/TonePicker.vue | 56 + vite.config.ts | 10 +- 38 files changed, 8807 insertions(+), 1382 deletions(-) create mode 100644 src/api/audiobook.ts create mode 100644 src/api/comment.ts create mode 100644 src/assets/audiobook.css create mode 100644 src/assets/left.svg create mode 100644 src/assets/paused.svg create mode 100644 src/assets/playing.svg create mode 100644 src/assets/star.svg create mode 100644 src/assets/star_fill.svg create mode 100644 src/audioPanel.ts create mode 100644 src/audiobook/controller.ts create mode 100644 src/audiobook/index.ts create mode 100644 src/audiobook/paragraph.ts create mode 100644 src/crypto/spade.ts create mode 100644 src/player/CencAudioPlayer.ts create mode 100644 src/player/bytes.ts create mode 100644 src/player/cenc.ts create mode 100644 src/player/gmFetch.ts create mode 100644 src/player/index.ts create mode 100644 src/player/remux.ts create mode 100644 src/utils/cookie.ts create mode 100644 src/utils/navigate.ts create mode 100644 src/views/AudioBar.vue create mode 100644 src/views/TonePicker.vue 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 事件、而