chore: 暂存更改

This commit is contained in:
naiy committed 2026-09-07 23:57:12 +08:00
1 parent 75678ac0a4
commit 20e7a6fc79
38 files changed
+8807 -1382

No files matched your search

+49 -9
View File
@@ -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`:那是番茄旗下红烛小说的接口,与番茄数据基本通用、风控更低,属于第一方而非第三方。脚本不会把您的凭据发往番茄及其关联平台以外的任何地方。
代码全部开源,欢迎审查。
+4181 -1344
View File
File diff suppressed because it is too large. Load diff
+63 -3
View File
@@ -1,10 +1,16 @@
import apiFetch from '../utils/request'
import apiFetch, { isEmptyResponse } from '../utils/request'
import { signRequest } from '../crypto/sign'
import config, { fetch as pageFetch } from '../config'
import { settings } from '../settings'
import { replaceDevice, markDeviceHealthy } from './provision'
export const appBaseUrl = 'https://reading.snssdk.com/reading'
/**
* APP 网关的根地址。绝大多数接口挂在 /reading 下(见 appBaseUrl),
* 但书评那套在根路径 /novel/commentapi/*,套上 /reading 会 404。
*/
export const appRootUrl = 'https://reading.snssdk.com'
export const redcandleBaseUrl = 'https://api5-sinfonlinec.jxbhmy.com/reading'
/**
* 番茄网页站同源挂载的 APP 接口。走页面 fetch 时浏览器会自动带上 Cookie,
@@ -33,6 +39,7 @@ export function buildAppQuery(extra?: Record<string, string>): URLSearchParams {
device_brand: c.device_brand || 'realme',
update_version_code: '70132',
manifest_version_code: '70132',
pv_player: '70132',
...extra,
})
}
@@ -66,6 +73,7 @@ function isUsable(res: any): boolean {
const j = res.json()
return !j || j.code === undefined || j.code === 0
} catch {
// 包括空响应体(设备被作废),一律当作不可用,交给下一个通道
return false
}
}
@@ -90,6 +98,35 @@ async function requestRedcandle(path: string, query?: Record<string, string>, he
}
/**
* 打番茄接口,遇到「设备被作废」自动换设备重试一次。
*
* 服务端作废设备的表现是 HTTP 200 + 完全空的响应体,没有错误码可判断
* (详见 provision.replaceDevice)。这里是所有 /reading/* GET 的唯一出口,
* 集中处理比每个调用点各写一遍可靠。
*/
async function requestAppWithRecovery(
path: string,
query?: Record<string, string>,
headers?: Record<string, string>,
) {
const res = await requestApp(path, query, headers)
if (!isEmptyResponse(res)) {
// 有响应就说明网关认这台设备,解除替换锁
markDeviceHealthy()
return res
}
console.warn(`[fqa:api] ${path} 返回空响应体,判定当前设备已失效`)
// 换设备失败就把空响应交回去,让调用方按原有逻辑报错
if (!(await replaceDevice())) return res
const retry = await requestApp(path, query, headers)
if (!isEmptyResponse(retry)) markDeviceHealthy()
return retry
}
export async function appGet(
path: string,
query?: Record<string, string>,
@@ -104,7 +141,7 @@ export async function appGet(
console.warn(`[fqa:api] 红烛接口请求失败,回落到番茄 APP: ${path}`, e)
}
}
return requestApp(path, query, headers)
return requestAppWithRecovery(path, query, headers)
}
@@ -114,7 +151,30 @@ export async function appPost(
query?: Record<string, string>,
headers?: Record<string, string>,
): Promise<any> {
const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}`
return postSigned(appBaseUrl + path, body, query, headers)
}
/**
* 打 APP 网关根路径(不带 /reading 前缀)的 POST 接口,目前只有书评在用。
*/
export async function appRootPost(
path: string,
body: string,
query?: Record<string, string>,
headers?: Record<string, string>,
): Promise<any> {
return postSigned(appRootUrl + path, body, query, headers)
}
async function postSigned(
base: string,
body: string,
query?: Record<string, string>,
headers?: Record<string, string>,
): Promise<any> {
const url = `${base}?${buildAppQuery(query).toString()}`
const signed = await signRequest(url, body)
console.log('---start--- APP POST ', url)
const res = await apiFetch(url, {
+68
View File
@@ -0,0 +1,68 @@
import { appGet } from './app'
import { decryptSpadeA } from '../crypto/spade'
import type { CencAudioContext, Tone, ParagraphTimeTag } from '../types'
export async function getPlayInfo(item_id: string[] | string, tone_id: number): Promise<CencAudioContext[]> {
const item_ids = Array.isArray(item_id) ? item_id.join(',') : item_id
const resp = await appGet('/reader/audio/playinfo/', {
item_ids,
tone_id: tone_id.toString()
})
const j = resp.json()
const results: CencAudioContext[] = []
if (Array.isArray(j.data)) {
for (let i of j.data) {
results.push({
urls: [i.main_url, i.backup_url].filter(Boolean),
vid: i.vid,
key: i.is_encrypt ? decryptSpadeA(i.encryption_key) : '',
item_id: i.item_id
})
}
}
return results
}
export async function getBookAvailableTones(book_id: string): Promise<Tone[]> {
const resp = await appGet('/bookapi/audio/toneinfo/', { book_id })
const j = resp.json()
const results: Tone[] = []
if (Array.isArray(j?.data?.tts_tones)) {
for (let i of j.data.tts_tones) {
results.push({
id: i.id,
name: i.title,
gender: i.tone_gender,
icon: i.icon_url,
description: i.description
})
}
}
return results
}
export async function getChapterParagraphTimeTag(item_id: string, tone_id: number): Promise<ParagraphTimeTag[]> {
// req_type=1 必须带:不带的话响应变成 para_match_data.text_para_match_data
// (按 book_id 分组、只有 start_time 没有 end_time);带上才是 data.time_points。
// 另外响应里的 is_title 实测恒为 false,不能用来判断标题,只能看 start_para
const resp = await appGet('/reader/audio/timepoint/', {
item_id,
tone_id: tone_id.toString(),
req_type: '1'
})
const j = resp.json()
const results: ParagraphTimeTag[] = []
if (Array.isArray(j?.data?.time_points)) {
for (let i of j.data.time_points) {
results.push({
startms: i.start_time,
endms: i.end_time,
startidx: i.start_para,
endidx: i.end_para,
is_title: i.start_para === 10000
})
}
}
return results
}
+478
View File
@@ -0,0 +1,478 @@
// 书评(评论 / 回复 / 点赞)。
//
// 接口挂在 APP 网关的根路径 /novel/commentapi/*,不在 /reading 下面。
// 网页站只把 /reading 反代到了 APP 网关,所以这里没有同源可用,
// 必须走 GM_xmlhttpRequest 打 reading.snssdk.com,并自己带 Cookie。
//
// 实测要点(temp/probe 逐项验证):
// - 三个接口都要签名(x-argus/x-ladon/x-khronos + 有 body 时 X-SS-STUB),
// 不签名一律 code=110001「未知异常」。仓库现成的 signRequest 直接可用,
// query 用精简版(7.0.1.32)也照过,不必照搬抓包里那一长串。
// - 抓包里的 comment-source / server-channel / lc / sdk-version 这些请求头可省,
// 真正起作用的是 body 里的同名字段。
// - 读列表不需要登录态;但 user_digg(我赞过没)只有带 sessionid 才准。
// 带登录态时,接口会把「自己打的分 / 写的书评」从 data_list 里抽出来,
// 单独放进 extra.user_comment(所以列表看着少一条,不是分页 bug)。
// - do_action / comment/add 这类写操作必须带 sessionid,否则 code=103006「用户未登录」。
// x-tt-token 单独给不管用(那是 APP 的登录票据,与网页 sessionid 不同源)。
//
// 请求体的必填字段比看上去多,缺一个就 code=103001「参数错误」:
// comment/list:business_param.book_id + fold_type、comment_source、comment_type、
// group_id、group_type、server_channel、sort —— 一个都不能少
// reply/list:business_param.book_id、comment_id、comment_source、comment_type、group_type
// do_action:action_type、comment_type、object_id、object_type(去掉 comment_type 会 502)
// comment/add:business_param.book_id + score、group_id、group_type、comment_type、
// data_type、commit_source、text、rich_text。抓包里的 shark_param /
// text_feature 是埋点,对照 do_action 的先例按可省处理,只保留结构字段。
import { appRootPost } from './app'
import { getSessionId, COOKIE_PERMISSION_HINT } from '../utils/cookie'
import type {
BookComment,
CommentBase,
CommentFilterTag,
CommentListResult,
CommentReply,
CommentSort,
CommentTextExt,
CommentUser,
ReplyListResult,
} from '../types'
/** comment_type=2 是书评。段评/章评是别的值,这里只做书评 */
const COMMENT_TYPE = 2
/**
* 写书评(comment/add)时 comment_type 用的是 0,而不是读列表的 2。
* 服务端就是这么不对称的,照抓包填 0,填 2 会归错类。
*/
const ADD_COMMENT_TYPE = 0
/** group_type=1 表示 group_id 是书 id */
const GROUP_TYPE = 1
/** 书评区(comment/list 只认这个值,501/1002 会报参数错误) */
const SOURCE_BOOK = 1
/** 评论详情页,reply/list 打一级评论时用 */
const SOURCE_COMMENT_REPLY = 501
/** 回复详情页,reply/list 打二级回复时用,且必须带 ref_reply_id */
const SOURCE_REPLY_REPLY = 1002
/** comment/list 单页上限。101 起服务端只回 1 条,不报错 */
export const MAX_COMMENT_COUNT = 100
/** do_action 的动作码 */
export const CommentAction = {
Like: 8,
Unlike: 9,
Dislike: 10,
Undislike: 11,
} as const
export type CommentAction = (typeof CommentAction)[keyof typeof CommentAction]
/** 点赞等写操作缺少登录态 */
export class NotLoggedInError extends Error {
constructor(message = COOKIE_PERMISSION_HINT) {
super(message)
this.name = 'NotLoggedInError'
}
}
/* ------------------------------- 响应解析 ------------------------------- */
function str(v: unknown): string {
return typeof v === 'string' ? v : v == null ? '' : String(v)
}
function int(v: unknown): number {
const n = Number(v)
return Number.isFinite(n) ? Math.trunc(n) : 0
}
/**
* 头衔名。接口把它塞成内嵌 JSON 字符串:
* intro: '{"title_name":"vip","title_id":"vip","icon_type":1}'
* 解析失败就退回 zh_title。
*/
function titleName(raw: any): string {
if (typeof raw?.intro === 'string' && raw.intro) {
try {
const parsed = JSON.parse(raw.intro)
const name = str(parsed?.title_name)
if (name) return name
} catch {
// 服务端改了格式,用下面的兜底
}
}
return str(raw?.zh_title || raw?.en_title)
}
function parseUser(raw: any): CommentUser {
const base = raw?.base_info ?? {}
const tag = raw?.user_tag ?? {}
return {
user_id: str(base.user_id || raw?.user_id),
name: str(base.user_name),
avatar: str(base.user_avatar),
description: str(base.description),
is_author: Boolean(tag.is_author),
// 蓝 V 也算会员标记,展示上不区分
is_vip: Boolean(tag.is_vip || tag.is_blue_vip),
titles: Array.isArray(tag.user_title_info)
? tag.user_title_info.map(titleName).filter(Boolean)
: [],
}
}
function parseExts(raw: any): CommentTextExt[] {
if (!Array.isArray(raw)) return []
const out: CommentTextExt[] = []
for (const e of raw) {
const text = str(e?.text)
if (!text) continue
out.push({
start: int(e?.s),
end: int(e?.e),
text,
type: int(e?.tp),
uri: str(e?.uri) || undefined,
})
}
// 服务端未保证有序,渲染时要按位置切片
return out.sort((a, b) => a.start - b.start)
}
/**
* 评论与回复的公共部分。
*
* 注意大小写:一级评论用 `common`,回复用 `Common`(服务端就是这么不一致的),
* 两边都要试。
*/
function parseBase(raw: any, id: string): CommentBase {
const common = raw?.common ?? raw?.Common ?? {}
const content = common.content ?? {}
const stat = raw?.stat ?? {}
const action = raw?.user_action ?? {}
return {
id,
text: str(content.text),
exts: parseExts(content.text_exts),
create_time: int(common.create_timestamp),
user: parseUser(common.user_info),
digg_count: int(stat.digg_count),
reply_count: int(stat.reply_count),
user_digg: Boolean(action.user_digg),
user_disagree: Boolean(action.user_disagree),
}
}
function parseComment(raw: any): BookComment | null {
const id = str(raw?.comment_id)
if (!id) return null
const expand = raw?.expand ?? {}
const action = raw?.user_action ?? {}
const addition = raw?.addition_comment
const additionId = str(addition?.comment_id)
return {
...parseBase(raw, id),
score: int(expand.score),
score_text: str(expand.score_suffix_text),
author_stick: Boolean(action.is_author_stick),
addition: additionId ? parseBase(addition, additionId) : undefined,
preview_reply_count: int(expand.reply_show_count),
}
}
function parseReply(raw: any): CommentReply | null {
const id = str(raw?.reply_id)
if (!id) return null
return {
...parseBase(raw, id),
to_comment_id: str(raw?.reply_to_comment_id),
to_reply_id: str(raw?.reply_to_reply_id),
sub: Array.isArray(raw?.sub_reply)
? raw.sub_reply.map(parseReply).filter((r: CommentReply | null): r is CommentReply => !!r)
: [],
}
}
function parseTags(raw: any): CommentFilterTag[] {
if (!Array.isArray(raw)) return []
return raw
.map(t => ({
tag_id: str(t?.tag_id),
tag_name: str(t?.tag_name),
count: int(t?.count),
}))
.filter(t => t.tag_id && t.tag_name)
}
/* ------------------------------- 请求封装 ------------------------------- */
/** 读接口。带上 sessionid 才能拿到 user_digg,读不到就匿名请求 */
async function commentPost(path: string, body: unknown, requireLogin = false): Promise<any> {
const headers: Record<string, string> = {}
const session = await getSessionId()
if (session.value) {
headers['Cookie'] = `sessionid=${session.value}`
} else if (requireLogin) {
throw new NotLoggedInError()
}
const res = await appRootPost(path, JSON.stringify(body), undefined, headers)
let j: any
try {
j = res.json()
} catch {
throw new Error(`书评接口返回了非 JSON(HTTP ${res.status})`)
}
// 103006 只可能出现在写操作上,读接口不校验登录
if (j?.code === 103006) throw new NotLoggedInError()
if (j?.code !== 0) {
throw new Error(`书评接口失败(code=${j?.code}): ${j?.message ?? '未知错误'}`)
}
return j.data
}
export interface CommentListOptions {
/** 1=最热(默认),3=最新 */
sort?: CommentSort
/** 单页条数,上限 100 */
count?: number
/** 上一页返回的 cursor,首页不传 */
cursor?: string
/** 按标签过滤,取 tags 里的 tag_id */
tagId?: string
}
/**
* 书评列表。
*
* @param bookId 书 id
*/
export async function getComments(
bookId: string,
{ sort = 1, count = 20, cursor, tagId }: CommentListOptions = {},
): Promise<CommentListResult> {
const data = await commentPost(`/novel/commentapi/comment/list/${bookId}/v1`, {
business_param: {
book_id: bookId,
need_count: true,
// fold_type 必填,且只有 1 有效(0 会报参数错误)
fold_type: 1,
...(tagId ? { tag_id: tagId } : {}),
},
comment_source: SOURCE_BOOK,
comment_type: COMMENT_TYPE,
count: Math.min(count, MAX_COMMENT_COUNT),
group_id: bookId,
group_type: GROUP_TYPE,
server_channel: 5,
sort,
...(cursor ? { cursor } : {}),
})
const info = data?.common_list_info ?? {}
const list = Array.isArray(data?.data_list) ? data.data_list : []
const extra = data?.extra ?? {}
return {
comments: list
.map((cell: any) => parseComment(cell?.comment))
.filter((c: BookComment | null): c is BookComment => !!c),
cursor: str(info.cursor),
has_more: Boolean(info.has_more),
total: int(info.total),
tags: parseTags(extra.filter_tag),
score_text: str(extra.score_text),
score_count: int(extra.score_cnt),
// 登录后自己那条被单独放在 extra.user_comment,形状和列表项里的 comment 一致
user_comment: parseComment(extra.user_comment),
}
}
/**
* 打分接口只认这几档:2/4/6/8/10 对应 1~5 星。
* 实测服务端也收 0(隐藏档,已经有人打过零分),所以 UI 要能显示零星,
* 但主动提交时把星级换算成分请走 starToScore,别自己乘。
*/
export const REVIEW_SCORE_STEP = 2
export const MIN_REVIEW_STARS = 1
export const MAX_REVIEW_STARS = 5
/** 星级(1-5)→ 接口分值(2-10)。越界会被夹到合法范围 */
export function starToScore(stars: number): number {
const s = Math.round(stars)
const clamped = Math.min(MAX_REVIEW_STARS, Math.max(MIN_REVIEW_STARS, s))
return clamped * REVIEW_SCORE_STEP
}
/** 接口分值(0-10)→ 星级(0-5)。0 分保留为 0 星,用于回显别人打过的零分 */
export function scoreToStars(score: number): number {
return Math.round(int(score) / REVIEW_SCORE_STEP)
}
export interface SubmitReviewOptions {
/**
* 评分。直接传接口分值(0/2/4/6/8/10)。
* 想用星级请先经 starToScore 换算。默认不改分(沿用当前分)。
*/
score?: number
/** 评论正文。空串表示只打分不写评 */
text?: string
}
/**
* 打分 / 写书评。text 为空只打分,带 text 就是评论。
*
* 需要登录态(sessionid),否则抛 NotLoggedInError。
* 成功后 comment/list 会把这条放进 extra.user_comment,可据此回显。
*
* @param bookId 书 id
* @returns 服务端回存的这条评论
*/
export async function submitReview(
bookId: string,
{ score = 0, text = '' }: SubmitReviewOptions = {},
): Promise<BookComment | null> {
const data = await commentPost(
`/novel/commentapi/comment/add/v1/`,
{
business_param: {
book_id: bookId,
score,
has_aigc_content: false,
// 下面这些是 APP 的催更 / 二次确认开关,网页端一律走默认
from_famous_comment_id: 0,
ignore_urge_rule: false,
is_confirm_request: false,
offset: 0,
read_item_cnt: 0,
video_is_muted: 0,
},
comment_type: ADD_COMMENT_TYPE,
// 抓包固定值:网页提交场景
commit_source: 1,
data_type: 2,
group_id: bookId,
group_type: GROUP_TYPE,
// 富文本(@、书名号引用等)网页端暂不支持,只发纯文本
rich_text: [],
text,
},
true,
)
return parseComment(data?.comment_info)
}
export interface ReplyListOptions {
count?: number
/** 上一页返回的 cursor(回复接口的 cursor 是十进制偏移的字符串) */
cursor?: string
}
/**
* 某条评论下的回复。
*
* @param bookId 书 id,接口要求
* @param commentId 一级评论 id
*/
export async function getReplies(
bookId: string,
commentId: string,
{ count = 20, cursor }: ReplyListOptions = {},
): Promise<ReplyListResult> {
const data = await commentPost(`/novel/commentapi/reply/list/${commentId}/v1`, {
business_param: { book_id: bookId, need_count: true },
comment_id: commentId,
comment_source: SOURCE_COMMENT_REPLY,
comment_type: COMMENT_TYPE,
count,
group_id: bookId,
group_type: GROUP_TYPE,
// cursor 必须是字符串,传数字直接 HTTP 400
...(cursor ? { cursor: String(cursor) } : {}),
})
return toReplyResult(data)
}
/**
* 某条回复下的二级回复。
*
* 服务端要求 ref_reply_id(任取一条已知子回复的 id),
* 缺了或填 '0' 都会报参数错误。若手上没有子回复 id,
* 可以退回用 getReplies 打这条回复的 id —— source 501 同样能列出子回复,
* 只是父级会以 `comment` 而不是 `reply` 的形状返回。
*
* @param refReplyId 已知的一条子回复 id
*/
export async function getSubReplies(
bookId: string,
replyId: string,
refReplyId: string,
{ count = 20, cursor }: ReplyListOptions = {},
): Promise<ReplyListResult> {
const data = await commentPost(`/novel/commentapi/reply/list/${replyId}/v1`, {
business_param: { book_id: bookId, need_count: true, ref_reply_id: refReplyId },
comment_id: replyId,
comment_source: SOURCE_REPLY_REPLY,
comment_type: COMMENT_TYPE,
count,
group_id: bookId,
group_type: GROUP_TYPE,
...(cursor ? { cursor: String(cursor) } : {}),
})
return toReplyResult(data)
}
function toReplyResult(data: any): ReplyListResult {
const info = data?.comment_list_info ?? {}
const list = Array.isArray(data?.reply_list) ? data.reply_list : []
return {
replies: list
.map(parseReply)
.filter((r: CommentReply | null): r is CommentReply => !!r),
cursor: str(info.cursor),
has_more: Boolean(info.has_more),
total: int(info.total),
}
}
/**
* 点赞 / 取消 / 点踩 / 取消踩。评论和回复都用这一个接口,object_type 固定 2。
*
* 需要登录态。读不到 sessionid 时抛 NotLoggedInError,
* 调用方应把 COOKIE_PERMISSION_HINT 提示给用户。
*
* @param objectId 评论 id 或回复 id
*/
export async function doCommentAction(objectId: string, action: CommentAction): Promise<void> {
await commentPost(
'/novel/commentapi/comment/do_action/v1/',
{
action_type: action,
// 抓包里塞了一堆埋点字段(shark_param 等),实测可以整个留空
business_param: {},
comment_type: COMMENT_TYPE,
object_id: objectId,
object_type: 2,
},
true,
)
}
/** 点赞开关。传入当前状态,返回操作后的状态 */
export async function toggleLike(objectId: string, liked: boolean): Promise<boolean> {
await doCommentAction(objectId, liked ? CommentAction.Unlike : CommentAction.Like)
return !liked
}
/** 点踩开关 */
export async function toggleDislike(objectId: string, disliked: boolean): Promise<boolean> {
await doCommentAction(objectId, disliked ? CommentAction.Undislike : CommentAction.Dislike)
return !disliked
}
+4
View File
@@ -80,6 +80,8 @@ export async function getChapter(itemId: string, _retry?: number): Promise<any>
await ensureKeyinfo()
}
const res = await appGet('/reader/full/v', { item_id: itemId, req_type: '1' })
// appGet 已经处理过「设备被作废」(空响应体会自动换设备重试)。
// 走到这里还是空的,说明连换设备都没救回来,如实抛错而不是继续重试
const j = res.json()?.data
if (!j) {
console.warn('Failed to get chapter: ', itemId, ', response: ', res.responseText)
@@ -129,6 +131,8 @@ export async function getChapters(
req_type: '1',
})
// appGet 已处理过「设备被作废」(空响应体会自动换设备重试);
// 这里还解析失败就是真的坏响应,直接抛给调用方,别退化成「章节全部失败」
const raw = res.json()?.data
const entries: Array<[string, any]> = raw && typeof raw === 'object'
? (Array.isArray(raw)
+96 -8
View File
@@ -2,8 +2,8 @@
// 首次使用时注册新设备并激活会员,之后从本地缓存复用。
import config, { defaultConfig, type DeviceConfig } from '../config'
import { b64decode, b64encode } from '../crypto'
import { read, write } from '../localStorage'
import { b64encode } from '../crypto'
import { del, read, write } from '../localStorage'
import { registerDevice, activatePremium, registerKey } from './device'
import { settings } from '../settings'
@@ -15,19 +15,31 @@ interface StoredDevice {
device_type?: string;
device_brand?: string;
vip_expire_time?: string;
key?: string; // base64
/** @deprecated 密钥现在只持久化在 `keyinfo` 里,读取时迁移过去后即删除 */
key?: string;
keyver?: number;
}
function load(): DeviceConfig | null {
const s = read(STORE_KEY) as StoredDevice | null
if (!s?.device_id || !s?.install_id) return null
// 旧版本把密钥嵌在 device 里,refreshKey 之后两个副本就失同步了。
// keyinfo 已存在时它是较新的权威副本(refreshKey 只更新它),
// 不能被 device 里过期的旧密钥覆盖回去;不存在时迁移过去。
// 迁移完删掉 device.key,之后密钥只有一个持久化副本
if (s.key) {
if (!read('keyinfo')) {
write('keyinfo', { key: s.key, keyver: s.keyver })
}
delete s.key
delete s.keyver
write(STORE_KEY, s)
}
return {
device_id: s.device_id,
install_id: s.install_id,
device_type: s.device_type,
device_brand: s.device_brand,
key_info: s.key ? { key: b64decode(s.key), keyver: s.keyver } : undefined,
}
}
@@ -39,10 +51,6 @@ function save(c: DeviceConfig, vipExpireTime?: string): void {
device_brand: c.device_brand,
vip_expire_time: vipExpireTime,
}
if (c.key_info?.key) {
s.key = b64encode(c.key_info.key)
s.keyver = c.key_info.keyver
}
write(STORE_KEY, s)
}
@@ -63,6 +71,11 @@ export async function provisionDevice(): Promise<DeviceConfig> {
}
config.currentConfig = c
save(c, vipExpireTime)
// content.ts 的 ensureKeyinfo 读的是这个键,不同步会拿到上一台设备的密钥
write('keyinfo', {
key: b64encode(keyInfo.key!),
keyver: keyInfo.keyver,
})
return c
}
@@ -101,3 +114,78 @@ export async function ensureDevice(): Promise<DeviceConfig> {
return defaultConfig
}
}
/**
* 正在进行的替换。并发请求共享同一次,
* 否则一屏书架十几个请求同时收到空响应,会一口气注册十几台设备。
*/
let replaceInflight: Promise<DeviceConfig | null> | null = null
/**
* 换完之后还没有任何请求成功过。
*
* 刚换的设备立刻又收到空响应,说明问题不在设备本身(网络被拦、接口整体故障、
* 出口 IP 被限),继续换只是白注册。等到有请求真的成功再解锁。
*/
let unverified = false
/**
* 记录「设备是工作的」。
*
* 只要网关给出了非空响应就算 —— 哪怕业务码是错的(书不存在之类),
* 也证明它认这台设备。由 api/app 在每次拿到非空响应时调用。
*/
export function markDeviceHealthy(): void {
unverified = false
}
/**
* 换一台设备。
*
* 设备被服务端作废后,所有 /reading/* 接口都返回 HTTP 200 + 空响应体
* (`content-length: 0`,一个字节都没有),没有错误码可判断。实测重新注册密钥
* 救不回来(registerkey 对被拒设备照样回 code:0 和一个 key,但正文仍是空),
* 必须整台重注册,注册后立刻就能正常取正文。
*
* 作废并不是按闲置时间来的:实测自动注册的设备和内置那台(4-5 月注册)
* 都还活着。所以这里不限次数,只要求两次替换之间至少有一个请求成功过 ——
* 真出问题就换,问题不在设备就不白折腾。
*
* 手填了设备的用户不动,那是他们自己指定的,替换只会让人困惑。
*
* @returns 换成功返回新设备,放弃时返回 null
*/
export function replaceDevice(): Promise<DeviceConfig | null> {
if (replaceInflight) return replaceInflight
replaceInflight = doReplace().finally(() => {
replaceInflight = null
})
return replaceInflight
}
async function doReplace(): Promise<DeviceConfig | null> {
if (unverified) {
console.warn(
'[fqa:device] 刚换过设备仍然收到空响应,问题多半不在设备(检查网络代理是否拦了 reading.snssdk.com)',
)
return null
}
if (settings.deviceId.trim() && settings.installId.trim()) {
console.warn('[fqa:device] 当前是手填设备,不自动替换。若接口持续失败,请到设置里清空或改用自动注册')
return null
}
// 注册失败时也保持上锁:否则每个失败请求都会再打一次注册接口
unverified = true
console.warn('[fqa:device] 当前设备已被服务端作废,正在注册新设备…')
try {
// 旧设备的密钥跟着旧 device_id 绑定,留着会让 ensureKeyinfo 命中错的缓存
del('keyinfo')
config.currentConfig.key_info = undefined
const c = await provisionDevice()
console.log('[fqa:device] 已换到新设备:', c.device_id)
return c
} catch (e) {
console.error('[fqa:device] 注册新设备失败:', e)
return null
}
}
+360
View File
@@ -0,0 +1,360 @@
/* 听书悬浮栏与音色弹窗。
* 暗色跟随页面的 div.muye-reader-dark(与 userHook 的判定一致)。 */
.fqa-audio-root {
--fqa-audio-bg: #fff;
--fqa-audio-text: #1f2329;
--fqa-audio-sub: #8f959e;
--fqa-audio-hover: rgba(31, 35, 41, 0.06);
--fqa-audio-border: rgba(31, 35, 41, 0.1);
--fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
position: fixed;
left: 20px;
bottom: 20px;
z-index: 2147483000;
font-size: 14px;
color: var(--fqa-audio-text);
}
/* 页面切到夜间模式时整体反色 */
.muye-reader-dark .fqa-audio-root,
.fqa-audio-root.fqa-audio-dark {
--fqa-audio-bg: #2b2b2b;
--fqa-audio-text: #b3b3b3;
--fqa-audio-sub: #7a7a7a;
--fqa-audio-hover: rgba(255, 255, 255, 0.08);
--fqa-audio-border: rgba(255, 255, 255, 0.12);
--fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
.fqa-audio-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 8px 8px;
border-radius: 999px;
background: var(--fqa-audio-bg);
box-shadow: var(--fqa-audio-shadow);
}
/* ------------------------------- 旋转封面 ------------------------------- */
.fqa-audio-cover {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--fqa-audio-hover);
cursor: pointer;
}
/*
* 只有这一层转。状态图标是 .fqa-audio-state,放在旋转层外面,
* 否则会跟着封面一起转,反而更看不清。
* 播放/缓冲时转,暂停时停在当前角度(animation-play-state 比移除动画更平滑)
*/
.fqa-audio-disc {
position: absolute;
inset: 0;
border-radius: 50%;
overflow: hidden;
animation: fqa-audio-rotate 8s linear infinite;
animation-play-state: paused;
}
.fqa-audio-disc.fqa-audio-spin {
animation-play-state: running;
}
.fqa-audio-disc img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fqa-audio-cover-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--fqa-audio-sub);
font-size: 18px;
}
/* 播放/暂停状态。压一层遮罩,浅色封面上也看得清 */
.fqa-audio-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.38);
color: #fff;
transition: background 0.2s ease;
}
.fqa-audio-cover:hover .fqa-audio-state {
background: rgba(0, 0, 0, 0.55);
}
/*
* 只给尺寸,不写 fill:paused.svg 里那块透明点击区是 fill="none",
* CSS 的 fill 优先级高于表现属性,会把它填成一个白方块盖住图标。
* 两个 svg 自身已经用 currentColor,跟着上面的 color 走。
*/
.fqa-audio-state svg {
width: 18px;
height: 18px;
}
@keyframes fqa-audio-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.fqa-audio-disc { animation: none; }
}
/* -------------------------------- 文字区 -------------------------------- */
.fqa-audio-meta {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
max-width: 180px;
line-height: 1.35;
}
.fqa-audio-title,
.fqa-audio-sub {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fqa-audio-sub {
color: var(--fqa-audio-sub);
font-size: 12px;
}
/* -------------------------------- 按钮 -------------------------------- */
.fqa-audio-btn,
.fqa-audio-expand {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--fqa-audio-text);
cursor: pointer;
}
.fqa-audio-btn:hover,
.fqa-audio-expand:hover {
background: var(--fqa-audio-hover);
}
.fqa-audio-close {
font-size: 20px;
line-height: 1;
}
/* svg 用 currentColor,跟着按钮的 color 走 */
.fqa-audio-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.fqa-audio-icon svg {
width: 100%;
height: 100%;
fill: currentColor;
}
/* 收起后箭头水平翻转朝右 */
.fqa-audio-icon-flip {
transform: scaleX(-1);
}
.fqa-audio-expand {
background: var(--fqa-audio-bg);
box-shadow: var(--fqa-audio-shadow);
width: 36px;
height: 36px;
}
/* 三个点 */
.fqa-audio-dots,
.fqa-audio-dots::before,
.fqa-audio-dots::after {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
}
.fqa-audio-dots {
position: relative;
}
.fqa-audio-dots::before,
.fqa-audio-dots::after {
content: '';
position: absolute;
top: 0;
}
.fqa-audio-dots::before { left: -7px; }
.fqa-audio-dots::after { left: 7px; }
/* ----------------------------- 段落高亮 ----------------------------- */
.fqa-audio-active {
background: var(--web-brand_light, rgba(241, 70, 70, 0.12));
border-radius: 4px;
transition: background 0.2s ease;
}
/* ----------------------------- 音色弹窗 ----------------------------- */
.fqa-tone-mask {
position: fixed;
inset: 0;
z-index: 2147483002;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.fqa-tone-box {
width: min(420px, calc(100vw - 32px));
max-height: min(560px, calc(100vh - 64px));
display: flex;
flex-direction: column;
padding: 20px;
border-radius: 12px;
background: var(--fqa-audio-bg, #fff);
color: var(--fqa-audio-text, #1f2329);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.24);
}
.fqa-tone-title {
margin: 0 0 4px;
font-size: 17px;
font-weight: 600;
}
.fqa-tone-sub {
margin: 0 0 14px;
color: var(--fqa-audio-sub, #8f959e);
font-size: 12px;
}
.fqa-tone-list {
flex: 1 1 auto;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.fqa-tone-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.fqa-tone-item:hover {
background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06));
}
.fqa-tone-item-active {
border-color: var(--web-brand, #f14646);
}
.fqa-tone-icon {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.fqa-tone-icon-empty {
display: flex;
align-items: center;
justify-content: center;
background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06));
color: var(--fqa-audio-sub, #8f959e);
}
.fqa-tone-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.fqa-tone-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.fqa-tone-gender {
color: var(--fqa-audio-sub, #8f959e);
font-size: 11px;
}
.fqa-tone-desc {
overflow: hidden;
color: var(--fqa-audio-sub, #8f959e);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.fqa-tone-actions {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
.fqa-tone-btn {
padding: 6px 16px;
border: 1px solid var(--fqa-audio-border, rgba(31, 35, 41, 0.1));
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
}
.fqa-tone-btn:hover {
background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06));
}
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path fill="currentColor" d="M353,450a15,15,0,0,1-10.61-4.39L157.5,260.71a15,15,0,0,1,0-21.21L342.39,54.6a15,15,0,1,1,21.22,21.21L189.32,250.1,363.61,424.39A15,15,0,0,1,353,450Z"/></g></svg>

After

Width:  |  Height:  |  Size: 307 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M16.394 12L10 7.737v8.526L16.394 12zm2.982.416L8.777 19.482A.5.5 0 0 1 8 19.066V4.934a.5.5 0 0 1 .777-.416l10.599 7.066a.5.5 0 0 1 0 .832z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 310 B

+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="22"
height="22"
viewBox="0 0 5.8208332 5.8208335"
version="1.1"
id="svg8"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
sodipodi:docname="stock_media-pause.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="7.9999996"
inkscape:cx="7.3825825"
inkscape:cy="8.7516629"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1360"
inkscape:window-height="718"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid10"
spacingx="0.52916667"
spacingy="0.52916667" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Capa 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-291.17915)">
<path
style="fill:currentColor;fill-opacity:1;stroke:currentColor;stroke-width:1.29999995;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 1.5875,292.23748 v 4.23334"
id="path892"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path894"
d="m 3.7041667,292.23748 v 4.23334"
style="fill:currentColor;fill-opacity:1;stroke:currentColor;stroke-width:1.29999995;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+4
View File
@@ -233,4 +233,8 @@
.info {
width: 100%!important;
}
.reader-toolbar {
user-select: none;
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<svg width="160" height="160" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1">
<g class="layer">
<title>Layer 1</title>
<path d="m0,0c3.02,1.3 3.59,3.13 5,6.06c0.52,1.05 1.04,2.11 1.58,3.2c0.53,1.11 1.06,2.23 1.6,3.38c0.54,1.09 1.08,2.19 1.63,3.32c6.71,13.8 6.71,13.8 5.25,18.93c-2.32,0.23 -2.32,0.23 -5,0c-1.58,-1.56 -1.58,-1.56 -2.9,-3.72c-0.48,-0.77 -0.96,-1.54 -1.45,-2.33c-0.72,-1.21 -0.72,-1.21 -1.46,-2.45c-0.51,-0.82 -1.01,-1.63 -1.52,-2.46c-1.24,-2.01 -2.46,-4.02 -3.67,-6.04c-0.33,0.7 -0.67,1.4 -1.01,2.12c-1.52,3.19 -3.07,6.38 -4.62,9.56c-0.52,1.11 -1.04,2.21 -1.58,3.35c-5.32,10.86 -5.32,10.86 -10.79,13.97c-3.43,0.96 -6.84,1.42 -10.38,1.81c-1.36,0.18 -1.36,0.18 -2.75,0.36c-5.32,0.68 -10.5,0.95 -15.87,0.83c3.98,5.47 9.07,9.54 14.23,13.86c1.06,0.9 1.06,0.9 2.14,1.82c0.97,0.81 0.97,0.81 1.97,1.64c2.25,2.27 3.49,4.43 3.92,7.63c-0.04,6.35 -1.04,12.38 -2.26,18.61c-0.19,1.01 -0.38,2.02 -0.58,3.05c-0.47,2.47 -0.94,4.93 -1.42,7.39c0.71,-0.4 1.43,-0.79 2.17,-1.2c3.27,-1.78 6.55,-3.54 9.83,-5.3c1.69,-0.93 1.69,-0.93 3.41,-1.88c11.87,-6.32 11.87,-6.32 17.59,-4.62c4.49,1.89 8.73,4.14 13,6.5c1.16,0.62 2.31,1.24 3.5,1.87c2.84,1.53 5.67,3.07 8.5,4.63c-0.18,-0.93 -0.18,-0.93 -0.37,-1.88c-0.55,-2.85 -1.09,-5.71 -1.63,-8.56c-0.19,-0.98 -0.38,-1.95 -0.58,-2.95c-2.75,-14.71 -2.75,-14.71 -0.05,-20.61c3.86,-4.41 8.2,-8.18 12.63,-12c1.44,-1.27 2.89,-2.53 4.33,-3.79c1.04,-0.91 1.04,-0.91 2.1,-1.84c0.52,-0.46 1.04,-0.91 1.57,-1.37c-0.65,-0.15 -1.3,-0.29 -1.97,-0.43c-2.95,-0.64 -5.9,-1.3 -8.84,-1.95c-1.03,-0.22 -2.05,-0.44 -3.11,-0.67c-1.47,-0.33 -1.47,-0.33 -2.98,-0.67c-0.9,-0.19 -1.81,-0.39 -2.74,-0.6c-2.36,-0.68 -2.36,-0.68 -5.36,-2.68c0.87,-4.88 0.87,-4.88 2,-6c8.59,0.19 17.15,1.66 25.62,3c1.17,0.17 2.34,0.35 3.55,0.53c8.57,1.34 8.57,1.34 10.83,2.47c0.44,2.06 0.44,2.06 0,5c-3,3.85 -6.9,6.92 -10.56,10.12c-0.65,0.57 -1.3,1.15 -1.97,1.74c-1.39,1.2 -2.79,2.4 -4.21,3.57c-1.96,1.62 -3.89,3.28 -5.82,4.94c-0.65,0.52 -1.29,1.03 -1.96,1.57c-3.36,2.92 -3.36,2.92 -4.75,7.01c0.23,2.42 0.71,4.68 1.27,7.05c0.4,2.11 0.8,4.23 1.19,6.35c0.69,3.44 1.38,6.88 2.08,10.32c0.22,1.11 0.44,2.22 0.67,3.37c0.2,1 0.41,2 0.62,3.03c0.46,3.05 0.65,5.84 0.44,8.93c-0.66,0.66 -1.32,1.32 -2,2c-4.67,-0.29 -8.48,-2.68 -12.5,-4.89c-1.46,-0.78 -2.92,-1.56 -4.38,-2.34c-2.28,-1.23 -4.57,-2.47 -6.84,-3.72c-2.21,-1.2 -4.43,-2.39 -6.65,-3.57c-1.01,-0.56 -1.01,-0.56 -2.05,-1.14c-3.18,-1.67 -5.05,-2.44 -8.64,-1.78c-3.25,1.59 -6.49,3.18 -9.69,4.88c-1.28,0.67 -2.56,1.34 -3.84,2.01c-1.99,1.06 -3.98,2.11 -5.97,3.17c-1.94,1.03 -3.88,2.05 -5.82,3.07c-1.15,0.61 -2.3,1.22 -3.49,1.85c-2.81,1.31 -5.04,2.22 -8.13,2.46c-2,-2 -2,-2 -2.07,-4.14c0.92,-8.69 2.62,-17.17 4.43,-25.72c0.22,-1.11 0.45,-2.21 0.68,-3.36c0.31,-1.48 0.31,-1.48 0.63,-3c0.46,-3.9 0.21,-6.66 -2.24,-9.78c-0.66,-0.62 -1.31,-1.24 -1.99,-1.88c-0.73,-0.69 -1.46,-1.38 -2.21,-2.1c-3.86,-3.5 -7.76,-6.97 -11.77,-10.3c-1.69,-1.41 -3.36,-2.85 -5.02,-4.29c-0.55,-0.44 -1.1,-0.89 -1.67,-1.35c-1.52,-1.34 -1.52,-1.34 -3.77,-4.08c0.36,-3.18 0.36,-3.18 1,-6c1.18,0 2.36,0.01 3.57,0.02c21.48,0.4 21.48,0.4 40.41,-7.78c7.42,-8.72 11.34,-20.16 14.81,-30.92c1.61,-4.42 1.61,-4.42 5.15,-4.21z" fill="#FA6725" id="svg_1" transform="matrix(1 0 0 1 80.9409 19.1143)"/>
<path d="m20.12,63.38c0.82,-0.37 1.69,-0.55 2.47,-0.68c0.91,-0.15 1.77,-0.26 2.65,-0.33c0.91,-0.07 1.77,-0.01 2.64,-0.01c0.95,0 1.81,0 2.59,0l0.96,0l0.92,0l0.92,0" fill="none" id="svg_7"/>
<line fill="none" id="svg_8" x1="20.12" x2="64.63" y1="63.55" y2="52.5"/>
<path d="m17.86,63.72c0,0 6.25,-0.94 6.26,-1c0.01,-0.06 4.24,-0.25 4.24,-0.25c0,0 4.25,-1 4.26,-1.06c0.01,-0.06 3.24,-0.25 3.19,-0.31c-0.05,-0.06 5.43,-1.25 5.49,-1.25c0.06,0 6.19,-1.19 6.14,-1.25c-0.05,-0.06 3.67,-1.06 3.86,-1.13c0.19,-0.06 3.56,-1 3.62,-1c0.06,0 2.81,-0.69 3.12,-0.75c0.31,-0.06 3.44,-0.56 3.39,-0.62c-0.05,-0.06 2.05,-0.13 2,-0.19c-0.05,-0.06 1.67,3.31 1.63,3.25c-0.05,-0.06 -1.7,3.5 -1.83,3.62c-0.12,0.12 -7.31,2.62 -7.5,2.75c-0.19,0.12 -10.37,2.37 -10.42,2.31c-0.05,-0.06 -14.51,0.06 -14.56,0c-0.05,-0.06 -5.26,0.87 -5.95,0.87c-0.69,0 -6.94,-4 -6.94,-4z" fill="#FA6725" id="svg_9"/>
<path d="m26.18,70.75l-10.61,-3.37l-0.75,-1.81l0.31,-1.25l0.94,-1.56l1,-0.81l5.44,4.38l3.68,4.44z" fill="#FA6725" id="svg_11"/>
<path d="m18.61,71c0,0 -2.5,-3.06 -2.55,-3.12c-0.05,-0.06 -0.51,-0.44 -0.45,-0.44c0.06,0 2.12,-1.56 2.07,-1.62c-0.05,-0.06 1.43,2.37 1.38,2.31c-0.05,-0.06 -0.26,2.12 -0.31,2.06c-0.05,-0.06 -0.14,0.81 -0.14,0.81z" fill="#FA6725" id="svg_12"/>
<path d="m50.46,58c0,0 -17.62,2.75 -17.65,2.69c-0.03,-0.06 -15.22,1.44 -15.25,1.38c-0.03,-0.06 12.9,3.19 12.88,3.13c-0.03,-0.06 20.03,-7.19 20.03,-7.19z" fill="#FA6725" id="svg_13"/>
<path d="m76.07,22.44c0,0 -3.81,9.5 -3.82,9.44c-0.01,-0.06 -8.43,24.81 -8.43,24.81c0,0 0.69,-7.5 0.68,-7.56c-0.01,-0.06 12.95,-14.31 12.94,-14.38c-0.01,-0.06 -1.36,-12.31 -1.36,-12.31z" fill="#FA6725" id="svg_14"/>
<path d="m72.26,31.62c0,0 -2.5,6.12 -2.51,6.06c-0.01,-0.06 -1.99,4.56 -2,4.5c-0.01,-0.06 -3.3,6.94 -3.3,6.94c0,0 -2.12,3.81 -2.14,3.75c-0.01,-0.06 2.45,2.31 2.5,2.25c0.05,-0.06 5.7,-7.19 5.7,-7.19c0,0 6.25,-9.75 6.24,-9.81c-0.01,-0.06 -4.49,-6.5 -4.49,-6.5z" fill="#FA6725" id="svg_15"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0"?>
<svg width="160" height="160" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1">
<g class="layer">
<title>Layer 1</title>
<g id="svg_3" transform="matrix(1 0 0 1 0 0)">
<title>Layer 1</title>
<path d="m80.38,19.81c4.13,1.87 5.64,5.2 7.62,9.19c0.71,2.16 1.39,4.33 2.03,6.51c3.56,11.4 3.56,11.4 10.97,20.49c9.26,4.51 21.01,4.52 31.12,4.7c4.07,0.08 7.22,0.5 10.88,2.3c0.49,2.51 0.49,2.51 0,6c-2.29,2.96 -5.07,5.35 -7.87,7.81c-0.78,0.71 -1.55,1.41 -2.35,2.14c-3.32,3.02 -6.69,5.99 -10.1,8.92c-0.88,0.76 -1.77,1.53 -2.68,2.32c-0.76,0.63 -1.53,1.26 -2.31,1.92c-2.13,2.39 -2.33,3.71 -2.69,6.89c0.42,3.39 0.42,3.39 1.25,7.02c0.28,1.37 0.56,2.73 0.84,4.1c0.45,2.13 0.91,4.25 1.37,6.38c0.44,2.07 0.86,4.14 1.29,6.21c0.26,1.23 0.53,2.46 0.8,3.73c0.48,3.77 0.16,6.84 -0.55,10.56c-3.37,1.13 -4.81,1.07 -8.18,-0.23c-1.19,-0.61 -2.37,-1.24 -3.55,-1.88c-0.63,-0.33 -1.26,-0.66 -1.9,-1c-2.01,-1.06 -4,-2.13 -6,-3.2c-1.34,-0.71 -2.69,-1.42 -4.04,-2.13c-9.57,-5.05 -9.57,-5.05 -13.33,-7.56c-5.94,-0.93 -10.05,1.76 -15.06,4.56c-0.84,0.45 -1.67,0.9 -2.53,1.37c-3.82,2.06 -7.6,4.17 -11.37,6.33c-1.12,0.63 -2.25,1.27 -3.42,1.93c-0.97,0.57 -1.95,1.14 -2.96,1.73c-3.06,1.24 -4.55,1.06 -7.66,0.08c-0.98,-5.18 -0.85,-9.14 0.25,-14.29c0.14,-0.67 0.27,-1.33 0.42,-2.02c0.43,-2.11 0.88,-4.21 1.33,-6.31c0.45,-2.12 0.9,-4.23 1.33,-6.34c0.4,-1.92 0.81,-3.83 1.22,-5.74c0.43,-3.18 0.38,-5.25 -0.55,-8.3c-1.31,-1.83 -1.31,-1.83 -3.06,-3.37c-0.64,-0.61 -1.28,-1.21 -1.93,-1.83c-3.8,-3.41 -7.63,-6.8 -11.54,-10.08c-1.67,-1.41 -3.32,-2.85 -4.97,-4.28c-0.54,-0.45 -1.08,-0.9 -1.64,-1.36c-2.8,-2.47 -3.81,-3.64 -4.2,-7.42c0.11,-0.88 0.22,-1.76 0.34,-2.66c4.51,-2.13 8.91,-2.69 13.8,-3.29c0.84,-0.11 1.67,-0.22 2.54,-0.33c2.65,-0.34 5.31,-0.67 7.97,-1c1.81,-0.24 3.62,-0.47 5.43,-0.7c4.42,-0.57 8.84,-1.13 13.26,-1.68c0.27,-0.86 0.54,-1.72 0.82,-2.6c1.83,-5.27 4.3,-10.21 6.72,-15.22c0.9,-1.87 1.79,-3.74 2.67,-5.61c0.57,-1.18 1.14,-2.36 1.72,-3.58c0.52,-1.08 1.04,-2.16 1.57,-3.27c3.09,-5.61 3.09,-5.61 6.88,-5.91z" fill="#FA6725" id="svg_1"/>
</g>
<path d="m81.39,20.37c0,0 0.37,0.56 0.37,0.56c0,0 1.12,2 1.12,2c0,0 2.94,7.69 2.94,7.69c0,0 -3.56,3.56 -3.57,3.5c-0.01,-0.06 -0.86,-13.75 -0.86,-13.75z" fill="#FA6725" id="svg_8" transform="matrix(1 0 0 1 0 0)"/>
<path d="m85.76,30.56c0,-0.12 5.44,13.06 5.44,13.06c0,0 6.56,8.31 6.56,8.31c0,0 -3.94,4.75 -3.94,4.75c0,0 -19.5,-15 -19.51,-15.06c-0.01,-0.06 11.45,-11.06 11.45,-11.06z" fill="#FA6725" id="svg_9"/>
<path d="m46.45,58.24c0,0 4.75,-1.25 4.75,-1.25c0,0 3.25,-1.06 3.25,-1.11c-0.01,-0.04 2.51,-1.08 2.5,-1.13c-0.01,-0.04 2.26,-1.46 2.25,-1.5c-0.01,-0.04 3.01,-3.58 3.01,-3.58c0,0 0.94,-2.19 0.94,-2.19c0,0 2.88,1.5 2.88,1.5c0,0 1.44,4.69 1.43,4.65c-0.01,-0.04 -2.25,6.86 -2.25,6.81c-0.01,-0.04 -11.5,1.67 -11.5,1.63c-0.01,-0.04 -7.25,-3.83 -7.25,-3.83z" fill="#FA6725" id="svg_13"/>
<path d="m82.81,23.28c0,0 11.38,24.8 11.38,24.8c0,0 5.61,8.49 5.61,8.49c0,0 -11.72,19.2 -11.72,19.2c0,0 -10.87,-32.45 -10.87,-32.79c0,-0.34 1.02,-14.44 0.94,-14.45c-0.07,-0.01 4.66,-5.26 4.66,-5.26z" fill="#FA6725" id="svg_16"/>
<path d="m80.6,20.05c0,0 2.38,0.68 2.38,0.68c0,0 1.02,2.04 1.02,2.04c0,0 1.36,3.57 1.36,3.57c0,0 2.21,6.29 2.13,6.28c-0.07,-0.01 4.49,8.5 4.49,8.5c0,0 4.42,9 4.34,9c-0.07,-0.01 -3.15,12.24 -3.32,12.24c-0.17,0 -18.18,-18.01 -18.25,-18.02c-0.07,-0.01 5.85,-24.29 5.85,-24.29z" fill="#FA6725" id="svg_17"/>
<path d="m79.97,20.12c0,0 1.36,0 1.36,0c0,0 2.04,0.17 1.94,0.1c-0.1,-0.07 1.97,3.47 1.87,3.4c-0.1,-0.07 2.31,6.19 2.31,6.19c0,0 3.4,8.84 3.3,8.77c-0.1,-0.07 6.9,13.32 6.9,13.32c0,0 -2.72,6.8 -2.82,6.73c-0.1,-0.07 -15.53,-12 -15.63,-12.06c-0.1,-0.07 0.78,-26.44 0.78,-26.44z" fill="#FA6725" id="svg_18" transform="matrix(1 0 0 1 0 0)"/>
<path d="m78.76,21.19c0,0 1.31,-1.37 1.31,-1.37c0,0 1.19,0.06 1.18,0c-0.01,-0.06 1.89,0.37 1.89,0.37c0,0 -0.87,1.62 -0.89,1.56c-0.01,-0.06 -1.68,1.06 -1.75,1.06c-0.07,0 -1.74,-1.63 -1.74,-1.63z" fill="#FA6725" id="svg_19"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+72
View File
@@ -0,0 +1,72 @@
// 听书悬浮栏的挂载/卸载。与 downloadPanel 同构:全局单例。
//
// 暗色跟随页面:番茄切日夜间只是给 div 加/去 muye-reader-dark 类,
// 没有事件可监听,所以用 MutationObserver 盯着 class 变化。
import { createApp, h, type App } from 'vue'
import AudioBar from './views/AudioBar.vue'
import audiobookcss from './assets/audiobook.css?raw'
const CONTAINER_ID = 'fqa-audio-root'
const STYLE_ID = 'fqa-audio-style'
let app: App | null = null
let container: HTMLElement | null = null
let themeObserver: MutationObserver | null = null
function injectStyle(): void {
if (document.getElementById(STYLE_ID)) return
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = audiobookcss
document.head.appendChild(style)
}
/**
* 同步暗色。
*
* 悬浮栏挂在 body 下,拿不到 .muye-reader-dark 的后代选择器,
* 所以把判定结果写成自己的类名。
*/
function syncTheme(): void {
if (!container) return
const dark = document.querySelector('div.muye-reader-dark') !== null
container.classList.toggle('fqa-audio-dark', dark)
}
function watchTheme(): void {
if (themeObserver) return
themeObserver = new MutationObserver(syncTheme)
themeObserver.observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ['class'],
})
}
/** 挂载悬浮栏。多次调用只生效一次,状态由 controller 驱动 */
export function initAudioPanel(): void {
if (app) return
injectStyle()
container = document.createElement('div')
container.id = CONTAINER_ID
document.body.appendChild(container)
syncTheme()
watchTheme()
app = createApp({ render: () => h(AudioBar) })
app.config.errorHandler = (err, _instance, info) => {
console.error(`[fqa:audio] Vue error (${info}):`, err)
}
app.mount(container)
}
export function destroyAudioPanel(): void {
themeObserver?.disconnect()
themeObserver = null
app?.unmount()
app = null
container?.remove()
container = null
}
+400
View File
@@ -0,0 +1,400 @@
// 听书的状态机。UI 只读这里的响应式状态,所有播放操作都走这里。
//
// 换音色的关键约定:不同音色的音频时长不一样,所以恢复位置只能按段落,
// 不能按时间。切完之后回到当前段落的开头(时间点表里那一段的 startms),
// 退回段首是可接受的。
import { reactive } from 'vue'
import { getBookAvailableTones, getChapterParagraphTimeTag, getPlayInfo } from '../api/audiobook'
import { CencAudioPlayer, type CencPlayerState } from '../player'
import { gmAudioFetch } from '../player/gmFetch'
import type { Tone } from '../types'
import { ParagraphHighlighter } from './paragraph'
import { read, write } from '../localStorage'
import { settings } from '../settings'
/** 上次选过的音色,下次进听书直接用 */
const TONE_STORE_KEY = 'audiobook_tone'
export interface AudiobookState {
/** 悬浮栏是否挂在页面上 */
open: boolean
/** 收起成一个小箭头 */
collapsed: boolean
/** 音色选择弹窗 */
tonePickerOpen: boolean
/** 正在取播放地址 / 缓冲首段 */
loading: boolean
playing: boolean
/** 封面该不该转:播放中或缓冲中都转 */
spinning: boolean
tones: Tone[]
toneId: number | null
toneName: string
cover: string
title: string
error: string
}
export const state = reactive<AudiobookState>({
open: false,
collapsed: false,
tonePickerOpen: false,
loading: false,
playing: false,
spinning: false,
tones: [],
toneId: null,
toneName: '',
cover: '',
title: '',
error: '',
})
const highlighter = new ParagraphHighlighter()
let audio: HTMLAudioElement | null = null
let player: CencAudioPlayer | null = null
/** 当前章节 */
let itemId = ''
let bookId = ''
/** 高亮跟随播放进度的定时器。timeupdate 每 250ms 才一次,段落跟不上 */
let trackTimer: number | null = null
/** 换音色时记下的段落,等新音频 ready 后跳回去 */
let pendingTagIndex = -1
/** 递增的播放请求序号,用于丢弃过期的异步结果 */
let playToken = 0
/** 正文上的「点段落跳转」监听,关掉听书时要摘掉 */
let paragraphClickBound = false
/**
* 点正文段落跳到对应进度。
*
* 挂在 document 上做事件委托:正文会被整段替换(切章),
* 绑在容器上每次都得重新绑。
*/
const onParagraphClick = (event: MouseEvent): void => {
if (!state.open || state.toneId === null) return
// 点注释上标之类的交互元素时不抢行为
const target = event.target
if (target instanceof HTMLElement && target.closest('a, button, sup')) return
const index = highlighter.tagIndexOfNode(target as Node)
if (index < 0) return
const seconds = highlighter.startSecondsOf(index)
if (seconds === null || !audio) return
audio.currentTime = seconds
highlighter.applyTag(index, false)
// 暂停时点段落也直接开始读
if (!state.playing) void togglePlay()
}
function bindParagraphClick(): void {
if (paragraphClickBound) return
document.addEventListener('click', onParagraphClick)
paragraphClickBound = true
}
function unbindParagraphClick(): void {
if (!paragraphClickBound) return
document.removeEventListener('click', onParagraphClick)
paragraphClickBound = false
}
function ensurePlayer(): CencAudioPlayer {
if (player) return player
audio = document.createElement('audio')
audio.preload = 'none'
// 悬浮栏自己画控件,原生的不要
audio.style.display = 'none'
document.body.appendChild(audio)
player = new CencAudioPlayer(audio, {
// 音频 CDN 不在页面 CSP 的 connect-src 里,页面 fetch 会被 report-only
// 策略上报到 mon.zijieapi.com。走 GM 通道绕开上报,详见 gmFetch
fetch: gmAudioFetch,
onStateChange: onPlayerState,
onMessage: (message, level) => {
if (level === 'error') {
console.error('[fqa:audio]', message)
state.error = message
} else {
console.log('[fqa:audio]', message)
}
},
})
return player
}
function onPlayerState(playerState: CencPlayerState): void {
state.playing = playerState === 'playing'
// loading/ready 都算在缓冲,封面继续转,避免一卡就停
state.spinning = playerState === 'playing' || playerState === 'loading'
if (playerState === 'playing') state.error = ''
if (playerState === 'playing') startTracking()
else stopTracking()
if (playerState === 'ended') {
highlighter.clear()
onChapterEnd()
}
}
/** 网页端的「下一章」按钮。读到最后一章时这个元素会消失 */
const NEXT_CHAPTER_SELECTOR = 'div.chapter-btn.next'
/**
* 一章读完。
*
* 设置成 next 时点一下页面自带的「下一章」——切页会触发 onUrlChange,
* 走 readerHook 的 switchChapter 把音频也接上,不用自己去算下一章是谁。
*/
function onChapterEnd(): void {
if (settings.audiobookChapterEnd !== 'next') {
state.spinning = false
return
}
const next = document.querySelector(NEXT_CHAPTER_SELECTOR)?.firstChild as HTMLElement | null
if (typeof next?.click !== 'function') {
// 元素不在了就是读到最后一章了
state.spinning = false
state.error = '已经是最后一章'
return
}
// 切页要一会儿,先让封面继续转着,别闪一下停
state.spinning = true
const before = itemId
next.click()
// 正常情况下切页会触发 onUrlChange -> switchChapter。
// 万一站点那个按钮没走 history(或被别的脚本拦了),这里兜一下,
// 否则会一直转圈但没有声音
unsafeWindow.setTimeout(() => {
if (!state.open || itemId !== before) return
const current = window.location.pathname.split('/').pop()?.substring(0, 19) || ''
if (current && current !== before) {
void switchChapter(current, { cover: state.cover, title: state.title })
} else {
state.spinning = false
state.error = '自动切章失败'
}
}, 3000)
}
function startTracking(): void {
if (trackTimer !== null) return
trackTimer = unsafeWindow.setInterval(() => {
if (!audio) return
highlighter.update(audio.currentTime * 1000, settings.audiobookFollow)
}, 120)
}
function stopTracking(): void {
if (trackTimer === null) return
unsafeWindow.clearInterval(trackTimer)
trackTimer = null
}
/** 记住的音色。书里没有这个音色时返回 null,让调用方回退到第一个 */
function storedToneId(): number | null {
const raw = read(TONE_STORE_KEY)
return typeof raw === 'number' ? raw : null
}
/**
* 打开听书。先取音色列表,然后弹窗让用户选。
*
* @param chapter 当前章节 item_id
* @param book 当前书 book_id,取音色列表要用
* @param meta 悬浮栏上显示的封面与标题
*/
export async function openAudiobook(
chapter: string,
book: string,
meta: { cover: string; title: string },
): Promise<void> {
itemId = chapter
bookId = book
state.cover = meta.cover
state.title = meta.title
state.error = ''
state.open = true
state.collapsed = false
if (state.tones.length === 0) {
try {
state.tones = await getBookAvailableTones(bookId)
} catch (e) {
state.error = '取音色列表失败'
console.error('[fqa:audio] 取音色列表失败:', e)
return
}
}
if (state.tones.length === 0) {
state.error = '这本书没有可用音色'
return
}
// 上次用过的音色还在这本书里就直接开播,不每次都问
const remembered = storedToneId()
if (remembered !== null && state.tones.some(t => t.id === remembered)) {
await selectTone(remembered)
return
}
state.tonePickerOpen = true
}
/** 选中音色:关掉弹窗并开始播放 */
export async function selectTone(toneId: number): Promise<void> {
state.tonePickerOpen = false
const tone = state.tones.find(t => t.id === toneId)
state.toneId = toneId
state.toneName = tone?.name ?? ''
write(TONE_STORE_KEY, toneId)
await playCurrent()
}
/** 重开音色弹窗(悬浮栏上的三个点) */
export function openTonePicker(): void {
state.tonePickerOpen = true
}
export function closeTonePicker(): void {
state.tonePickerOpen = false
// 还没选过音色就关掉弹窗,等于放弃听书
if (state.toneId === null) closeAudiobook()
}
/**
* 换音色。
*
* 换之前记下当前段落,新音频 ready 后跳到那一段的开头。
* 不能沿用时间:同一段在不同音色里的时间点完全不同。
*/
export async function changeTone(toneId: number): Promise<void> {
if (toneId === state.toneId) {
state.tonePickerOpen = false
return
}
pendingTagIndex = highlighter.currentTagIndex
await selectTone(toneId)
}
/** 取播放地址 + 时间点表,然后交给播放器 */
async function playCurrent(): Promise<void> {
const toneId = state.toneId
if (toneId === null || !itemId) return
// 连续切章/切音色时,晚回来的旧请求不能盖掉新的
const token = ++playToken
const chapter = itemId
state.loading = true
state.spinning = true
state.error = ''
try {
const [contexts, tags] = await Promise.all([
getPlayInfo(chapter, toneId),
getChapterParagraphTimeTag(chapter, toneId),
])
if (token !== playToken) return
const context = contexts.find(c => c.item_id === chapter) ?? contexts[0]
const url = context?.urls[0]
if (!context || !url) {
state.error = '这一章没有音频'
return
}
highlighter.reset(tags)
bindParagraphClick()
const instance = ensurePlayer()
await instance.start({ url, key: context.key })
if (token !== playToken) return
// 换音色回到原段落。start 之后再 seek,让播放器自己去拉对应位置
if (pendingTagIndex >= 0) {
const tag = tags[pendingTagIndex]
if (tag && audio) {
audio.currentTime = tag.startms / 1000
highlighter.applyTag(pendingTagIndex, true)
}
pendingTagIndex = -1
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
state.error = message
console.error('[fqa:audio] 播放失败:', e)
} finally {
state.loading = false
// 失败时别让封面一直转
if (!state.playing) state.spinning = false
}
}
/** 点封面:播放 / 暂停 */
export async function togglePlay(): Promise<void> {
if (!player) return
if (state.playing) {
player.pause()
return
}
// 播完了再点就从头开始
if (player.currentState === 'ended' && audio) audio.currentTime = 0
try {
await player.play()
} catch (e) {
console.warn('[fqa:audio] play() 被拒:', e)
}
}
export function toggleCollapsed(): void {
state.collapsed = !state.collapsed
}
/** 切章:正文换了,段落索引和音频都要重来 */
export async function switchChapter(chapter: string, meta: { cover: string; title: string }): Promise<void> {
if (!state.open || state.toneId === null) return
itemId = chapter
state.cover = meta.cover
state.title = meta.title
pendingTagIndex = -1
await playCurrent()
}
/** 正文重新插入后段落节点都换了,重新抓一遍 */
export function refreshParagraphs(): void {
if (state.open) highlighter.refresh()
}
export function closeAudiobook(): void {
stopTracking()
unbindParagraphClick()
// 让还在飞的 playCurrent 结果作废,否则关掉之后又冒出来一段
playToken++
highlighter.clear()
player?.stop()
state.open = false
state.collapsed = false
state.tonePickerOpen = false
state.playing = false
state.spinning = false
state.loading = false
state.error = ''
}
/** 工具栏那个「听书」按钮:在听就停,没听就开 */
export function isListening(): boolean {
return state.open
}
/** 离开阅读页时彻底清理 */
export function destroyAudiobook(): void {
stopTracking()
unbindParagraphClick()
highlighter.clear()
player?.destroy()
player = null
audio?.remove()
audio = null
state.open = false
state.tones = []
state.toneId = null
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 听书。
*
* 用法:readerHook 里点「听书」-> openAudiobook 取音色 -> selectTone 开播。
* 悬浮栏由 audioPanel 挂载,只读 controller 的响应式 state。
*/
export {
changeTone,
closeAudiobook,
destroyAudiobook,
openAudiobook,
refreshParagraphs,
selectTone,
state,
switchChapter,
togglePlay,
type AudiobookState,
} from './controller'
export { ParagraphHighlighter, TITLE_IDX } from './paragraph'
+193
View File
@@ -0,0 +1,193 @@
// 段落定位:把时间点表和页面上的正文段落对应起来。
//
// APP 正文里每个段落带 idx,时间点表用同一套 idx 表示「这一段从第几毫秒读到第几毫秒」。
// 网页端把 idx 留在了外层 <p> 上:
// <p idx="1" p_idx="40000"><blk …>“凭什么爹可以喝花酒,我就不行!”</blk></p>
// 所以定位只看外层 <p[idx]>,不管里面的 blk 怎么切。
import type { ParagraphTimeTag } from '../types'
/** 标题的 idx。时间点表里用这个值表示「正在读标题」 */
export const TITLE_IDX = 10000
/** 脚本接管的正文容器 id,与 readerHook 保持一致 */
const CONTENT_SELECTOR = '#fqa-reader-content'
/** 页面原有的章节标题 */
const TITLE_SELECTOR = 'h1.muye-reader-title'
/** 当前高亮的段落类名 */
export const ACTIVE_CLASS = 'fqa-audio-active'
/**
* 收集正文里所有带 idx 的段落,按 idx 建索引。
*
* 同一个 idx 理论上只有一个 <p>,但真出现重复也不丢弃 —— 一起高亮比只亮一半好。
*/
export function collectParagraphs(): Map<number, HTMLElement[]> {
const map = new Map<number, HTMLElement[]>()
const container = document.querySelector(CONTENT_SELECTOR)
if (!container) return map
for (const node of container.querySelectorAll<HTMLElement>('p[idx]')) {
const idx = Number(node.getAttribute('idx'))
if (!Number.isFinite(idx)) continue
const list = map.get(idx)
if (list) list.push(node)
else map.set(idx, [node])
}
return map
}
/**
* 标题时间点该高亮谁。
*
* 时间点表里只有一条 is_title 时是普通章节,标题就是页面上那个 h1。
* 出现多条则说明是「复杂章节」(卷首语、分节小标题等各自成段),
* 光靠 idx=10000 分不出是哪一个,索性都不高亮 —— 与其亮错位置,不如不亮。
*/
export function titleTarget(tags: ParagraphTimeTag[]): HTMLElement | null {
const titles = tags.filter(t => t.is_title)
if (titles.length !== 1) return null
return document.querySelector<HTMLElement>(TITLE_SELECTOR)
}
/** 找出 timeMs 落在哪个时间点区间。找不到返回 -1 */
export function tagIndexAt(tags: ParagraphTimeTag[], timeMs: number): number {
let low = 0
let high = tags.length - 1
let found = -1
// 时间点表按时间递增,二分找最后一个 startms <= timeMs 的
while (low <= high) {
const middle = (low + high) >> 1
if (tags[middle]!.startms <= timeMs) {
found = middle
low = middle + 1
} else {
high = middle - 1
}
}
if (found < 0) return -1
// 落在两段之间的空隙(上一段已读完、下一段还没开始)就保持上一段亮着
return found
}
/**
* 高亮某个时间点覆盖的所有段落,并把上一次的高亮清掉。
*
* 一个时间点可能跨多段(startidx..endidx),所以按区间取。
*/
export class ParagraphHighlighter {
private paragraphs = new Map<number, HTMLElement[]>()
private tags: ParagraphTimeTag[] = []
private active: HTMLElement[] = []
private activeTagIndex = -1
/** 换章或正文重新插入后调用,重建索引 */
reset(tags: ParagraphTimeTag[]): void {
this.clear()
this.tags = tags
this.paragraphs = collectParagraphs()
this.activeTagIndex = -1
}
/** 正文 DOM 被替换过(切音色不会换 DOM,但切章会),重新抓一遍段落 */
refresh(): void {
this.paragraphs = collectParagraphs()
this.activeTagIndex = -1
}
get timeTags(): ParagraphTimeTag[] {
return this.tags
}
/** 当前高亮对应的时间点下标,用于切音色时保持段落 */
get currentTagIndex(): number {
return this.activeTagIndex
}
clear(): void {
for (const node of this.active) node.classList.remove(ACTIVE_CLASS)
this.active = []
}
/**
* 按播放时间更新高亮。
*
* @param scroll 是否把高亮段落滚进视口
* @returns 当前时间点下标,没有变化时返回原值
*/
update(timeMs: number, scroll: boolean): number {
if (this.tags.length === 0) return -1
const index = tagIndexAt(this.tags, timeMs)
if (index < 0 || index === this.activeTagIndex) return this.activeTagIndex
this.activeTagIndex = index
this.applyTag(index, scroll)
return index
}
/** 直接高亮第 index 个时间点,供切音色后恢复位置用 */
applyTag(index: number, scroll: boolean): void {
const tag = this.tags[index]
if (!tag) return
this.clear()
const targets = tag.is_title
? [titleTarget(this.tags)].filter((n): n is HTMLElement => n !== null)
: this.rangeTargets(tag.startidx, tag.endidx)
for (const node of targets) node.classList.add(ACTIVE_CLASS)
this.active = targets
if (scroll && targets[0]) {
targets[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
private rangeTargets(startidx: number, endidx: number): HTMLElement[] {
const targets: HTMLElement[] = []
for (let idx = startidx; idx <= endidx; idx++) {
const nodes = this.paragraphs.get(idx)
if (nodes) targets.push(...nodes)
}
return targets
}
/**
* 点段落跳转用:找出这个节点属于哪个时间点。
*
* 从点击目标往上找最近的 <p[idx]>(点到 blk 或行内标签上也能命中),
* 再拿 idx 去时间点表里查覆盖它的那一段。
*/
tagIndexOfNode(node: Node | null): number {
const paragraph = nearestParagraph(node)
if (paragraph) {
const idx = Number(paragraph.getAttribute('idx'))
if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx)
}
// 点在标题上
if (node instanceof Node && titleTarget(this.tags)?.contains(node)) {
return this.tags.findIndex(t => t.is_title)
}
return -1
}
/** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */
tagIndexOfIdx(idx: number): number {
return this.tags.findIndex(t => !t.is_title && idx >= t.startidx && idx <= t.endidx)
}
/** 第 index 个时间点的起始秒数,供 seek 用 */
startSecondsOf(index: number): number | null {
const tag = this.tags[index]
return tag ? tag.startms / 1000 : null
}
}
/** 从任意节点往上找最近的带 idx 的段落 */
function nearestParagraph(node: Node | null): HTMLElement | null {
let current: Node | null = node
while (current) {
if (current instanceof HTMLElement && current.matches('p[idx]')) return current
// 到正文容器就停,别一路找到 body
if (current instanceof HTMLElement && current.id === 'fqa-reader-content') return null
current = current.parentNode
}
return null
}
+53
View File
@@ -0,0 +1,53 @@
import { b64decode } from '.'
function decodeBase36(c: number): number {
if (c >= 48 && c <= 57) return c - 48; // '0'-'9'
if (c >= 97 && c <= 122) return c - 97 + 10; // 'a'-'z'
return 0xFF;
}
function bitCount(n: number): number {
n = n - ((n >> 1) & 0x55555555);
n = (n & 0x33333333) + ((n >> 2) & 0x33333333);
return ((n + (n >> 4) & 0xF0F0F0F) * 0x1010101) >> 24;
}
function decryptSpadeInner(spadeKey: Uint8Array): Uint8Array {
const result = new Uint8Array(spadeKey);
const buff = new Uint8Array(2 + spadeKey.length);
buff.set([0xFA, 0x55], 0);
buff.set(spadeKey, 2);
for (let i = 0; i < result.length; i++) {
let v = (spadeKey[i]! ^ buff[i]!) - bitCount(i) - 21;
while (v < 0) { v += 0xFF; }
result[i] = v;
}
return result;
}
function decryptSpade(spadeKeyBytes: Uint8Array): string {
const spadeKeyLen = spadeKeyBytes.length;
if (spadeKeyLen < 3) return "";
const paddingLen = (spadeKeyBytes[0]! ^ spadeKeyBytes[1]! ^ spadeKeyBytes[2]!) - 48;
if (spadeKeyLen < paddingLen + 2) return "";
const innerInput = spadeKeyBytes.slice(1, spadeKeyLen - paddingLen);
const tmpBuff = decryptSpadeInner(innerInput);
if (tmpBuff.length === 0) return "";
const skipBytes = decodeBase36(tmpBuff[0]!);
const decodedMessageLen = spadeKeyLen - paddingLen - 2;
const endIndex = 1 + decodedMessageLen - skipBytes;
if (endIndex > tmpBuff.length) return "";
const finalBytes = tmpBuff.slice(1, endIndex);
return new TextDecoder("utf-8").decode(finalBytes);
}
export function decryptSpadeA(spadeAStr: string): string {
try {
const bytes = new Uint8Array(b64decode(spadeAStr));
return decryptSpade(bytes);
} catch (e) {
console.error("Spade parsing error", e);
return "";
}
}
+60
View File
@@ -9,6 +9,14 @@ import { cloneElement } from '../utils';
import { fetchArrayBuffer } from '../utils/request';
import { type Book } from '../types'
import { decryptComicImage } from '../crypto/content';
import { initAudioPanel } from '../audioPanel'
import {
closeAudiobook,
openAudiobook,
refreshParagraphs,
state as audioState,
switchChapter,
} from '../audiobook/controller'
// import moment from 'moment'
let currentBook: Book | null = null
@@ -303,6 +311,36 @@ async function insertContent() {
}
}
}
// 正文换了:段落节点全是新的,正在听书就把音频也切到这一章
if (audioState.open) {
await switchChapter(itemId, {
cover: currentBook?.cover_url ?? '',
title: currentBook?.title ?? document.title,
})
} else {
refreshParagraphs()
}
}
/**
* 工具栏「听书」按钮:停止 / 开始听书。
*
* 已经在听的时候再点就是停止。切音色走左下角悬浮栏,不从这里进。
*/
async function startAudioPlay(): Promise<void> {
if (audioState.open) {
closeAudiobook()
return
}
const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || ''
if (!itemId) return
initAudioPanel()
// 封面和书名从已取到的书籍信息来;正文还没加载完时退化成空封面
await openAudiobook(itemId, currentBook?.book_id ?? '', {
cover: currentBook?.cover_url ?? '',
title: currentBook?.title ?? document.title,
})
}
// let currentBook: Book | null = null
@@ -322,6 +360,28 @@ async function onLoad(): Promise<void> {
document.head.appendChild(style)
}
*/
// 切换日夜间的按钮,不管怎么样它的状态都是完全一样的。适合作为模板。
const toolbar = document.querySelector("div.reader-toolbar > div")
const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)")
if (toolbarButton && toolbar) {
const c = cloneElement(toolbarButton)
c.id = 'fqa-toggle-audiobook'
const listenIcon = document.createElement('span')
listenIcon.textContent = '听'
listenIcon.style.width = '24px'
listenIcon.style.height = '24px'
listenIcon.style.fontSize = '24px'
listenIcon.style.lineHeight = '24px'
listenIcon.classList.add('muyeicon-icon')
listenIcon.classList.add('reader-toolbar-item-icon')
c.firstChild?.replaceWith(listenIcon)
const l = c.lastChild as HTMLDivElement
if (l) {
l.textContent = '听书'
}
c.addEventListener('click', () => void startAudioPlay())
toolbar.appendChild(c)
}
const btns = document.querySelector("div.muye-reader-btns") // single div
if (btns) {
// TODO: 直接覆写按钮行为,切章节由脚本完成
File diff suppressed because it is too large. Load diff
+109
View File
@@ -0,0 +1,109 @@
/** 二进制播放器使用的基础字节工具。 */
export type Bytes = Uint8Array<ArrayBuffer>
export function asBytes(value: ArrayBuffer | Uint8Array): Bytes {
if (value instanceof Uint8Array) {
return new Uint8Array(value) as Bytes
}
return new Uint8Array(value) as Bytes
}
export function concatBytes(...arrays: readonly Uint8Array[]): Bytes {
const length = arrays.reduce((sum, array) => sum + array.byteLength, 0)
const result = new Uint8Array(length)
let offset = 0
for (const array of arrays) {
result.set(array, offset)
offset += array.byteLength
}
return result as Bytes
}
export function ascii(text: string): Bytes {
const result = new Uint8Array(text.length)
for (let i = 0; i < text.length; i++) {
result[i] = text.charCodeAt(i) & 0xff
}
return result as Bytes
}
export function hexToBytes(text: string): Bytes {
const hex = text.replace(/\s+/g, '')
if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) {
throw new Error('无效的十六进制数据')
}
const result = new Uint8Array(hex.length / 2)
for (let i = 0; i < result.length; i++) {
result[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
}
return result as Bytes
}
export function uintBytes(value: number): Bytes {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`无效的无符号整数: ${value}`)
}
if (value === 0) return new Uint8Array([0]) as Bytes
const result: number[] = []
let current = value
while (current > 0) {
result.unshift(current & 0xff)
current = Math.floor(current / 256)
}
return new Uint8Array(result) as Bytes
}
export function be16(value: number): Bytes {
const result = new Uint8Array(2)
new DataView(result.buffer).setUint16(0, value)
return result as Bytes
}
export function be32(value: number): Bytes {
const result = new Uint8Array(4)
new DataView(result.buffer).setUint32(0, value >>> 0)
return result as Bytes
}
export function be64(value: number | bigint): Bytes {
const result = new Uint8Array(8)
new DataView(result.buffer).setBigUint64(0, BigInt(value))
return result as Bytes
}
export function findBytes(
buffer: Uint8Array,
fourcc: string,
from = 0,
to = buffer.length - 4,
): number {
if (fourcc.length !== 4) throw new Error(`fourcc 必须是 4 个字符: ${fourcc}`)
const c0 = fourcc.charCodeAt(0)
const c1 = fourcc.charCodeAt(1)
const c2 = fourcc.charCodeAt(2)
const c3 = fourcc.charCodeAt(3)
for (let i = from; i <= to; i++) {
if (
buffer[i] === c0 &&
buffer[i + 1] === c1 &&
buffer[i + 2] === c2 &&
buffer[i + 3] === c3
) return i
}
return -1
}
export function strBytes(text: string): Bytes {
return new TextEncoder().encode(text) as Bytes
}
export function readFourcc(buffer: Uint8Array, offset: number): string {
return String.fromCharCode(
buffer[offset] ?? 0,
buffer[offset + 1] ?? 0,
buffer[offset + 2] ?? 0,
buffer[offset + 3] ?? 0,
)
}
+287
View File
@@ -0,0 +1,287 @@
import { asBytes, type Bytes, hexToBytes, findBytes, readFourcc } from './bytes'
import { getSubtle } from '../crypto'
export type CencCodec = 'opus' | 'aac' | 'flac'
export interface CencMetadata {
codec: CencCodec
sampleCount: number
sizes: Uint32Array
/** 每个采样的 8/16 字节 CENC IV,按 sample 顺序拼接 */
ivs: Bytes
durations: Uint32Array
timescale: number
channels: number
preSkip: number
/** mdat 的负载起点,相对于 head buffer */
mdatStart: number
/** 每个 sample 在 mdat 负载中的累计偏移,长度为 sampleCount + 1 */
offsets: Float64Array
totalTicks: number
}
export interface CencKey {
key: CryptoKey
/** AES-CTR counter 的低位初值,CENC 常见为 0;某些流使用 1 */
counterStart: 0 | 1
}
interface IsoBox {
type: string
offset: number
size: number
headerSize: number
end: number
}
const CONTAINERS = new Set([
'moov', 'trak', 'mdia', 'minf', 'stbl', 'edts', 'dinf', 'udta', 'mvex', 'meta',
])
function readBox(buffer: Uint8Array, offset: number, limit: number): IsoBox | null {
if (offset + 8 > limit) return null
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const size32 = view.getUint32(offset)
const type = readFourcc(buffer, offset + 4)
let headerSize = 8
let size: number
if (size32 === 1) {
if (offset + 16 > limit) return null
const extended = view.getBigUint64(offset + 8)
if (extended > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error(`MP4 box 过大: ${type}`)
}
size = Number(extended)
headerSize = 16
} else if (size32 === 0) {
size = limit - offset
} else {
size = size32
}
if (size < headerSize || offset + size > limit) return null
return { type, offset, size, headerSize, end: offset + size }
}
function walkBoxes(
buffer: Uint8Array,
start: number,
end: number,
visit: (box: IsoBox) => void,
depth = 0,
): void {
if (depth > 16) return
let offset = start
while (offset + 8 <= end) {
const box = readBox(buffer, offset, end)
if (!box) return
visit(box)
if (CONTAINERS.has(box.type)) {
// meta 是 FullBox,版本/flags 后才开始嵌套 box。
const childStart = box.offset + box.headerSize + (box.type === 'meta' ? 4 : 0)
if (childStart < box.end) walkBoxes(buffer, childStart, box.end, visit, depth + 1)
}
offset = box.end
}
}
function findBox(boxes: readonly IsoBox[], type: string): IsoBox | undefined {
return boxes.find(box => box.type === type)
}
/** 读取顶层 mdat 的负载起点,即使 mdat 本体不在当前 head buffer 内也能定位。 */
function findMdatStart(buffer: Uint8Array): number {
let offset = 0
while (offset + 8 <= buffer.length) {
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const size32 = view.getUint32(offset)
const type = readFourcc(buffer, offset + 4)
const headerSize = size32 === 1 ? 16 : 8
if (type === 'mdat') return offset + headerSize
if (size32 === 0) return -1
if (size32 === 1) {
if (offset + 16 > buffer.length) return -1
const size = view.getBigUint64(offset + 8)
if (size > BigInt(Number.MAX_SAFE_INTEGER)) return -1
offset += Number(size)
} else {
offset += size32
}
}
return -1
}
function parseSampleSizes(box: IsoBox, view: DataView): Uint32Array {
if (box.offset + 20 > box.end) throw new Error('stsz box 不完整')
const sampleSize = view.getUint32(box.offset + 12)
const count = view.getUint32(box.offset + 16)
const sizes = new Uint32Array(count)
if (sampleSize !== 0) {
sizes.fill(sampleSize)
return sizes
}
if (box.offset + 20 + count * 4 > box.end) throw new Error('stsz 采样表不完整')
for (let i = 0; i < count; i++) sizes[i] = view.getUint32(box.offset + 20 + i * 4)
return sizes
}
function parseDurations(box: IsoBox, sampleCount: number, view: DataView): Uint32Array {
if (box.offset + 16 > box.end) throw new Error('stts box 不完整')
const entryCount = view.getUint32(box.offset + 12)
const durations = new Uint32Array(sampleCount)
let index = 0
for (let i = 0; i < entryCount; i++) {
const offset = box.offset + 16 + i * 8
if (offset + 8 > box.end) throw new Error('stts 采样表不完整')
const count = view.getUint32(offset)
const duration = view.getUint32(offset + 4)
for (let j = 0; j < count && index < sampleCount; j++) durations[index++] = duration
}
if (index !== sampleCount) throw new Error(`stts 采样数不一致: ${index}/${sampleCount}`)
return durations
}
function parseIvs(box: IsoBox, sampleCount: number, view: DataView): Bytes {
if (box.offset + 16 > box.end) throw new Error('senc box 不完整')
const flags = view.getUint32(box.offset + 8) & 0xffffff
if ((flags & 0x02) !== 0) throw new Error('暂不支持带 subsample encryption 的音频')
const count = view.getUint32(box.offset + 12)
if (count !== sampleCount) throw new Error(`senc 采样数不一致: ${count}/${sampleCount}`)
const dataStart = box.offset + 16
const dataLength = box.end - dataStart
if (count === 0 || dataLength % count !== 0) throw new Error('senc IV 表不完整')
const ivLength = dataLength / count
if (ivLength !== 8 && ivLength !== 16) throw new Error(`不支持的 CENC IV 长度: ${ivLength}`)
return asBytes(new Uint8Array(view.buffer, view.byteOffset + dataStart, dataLength))
}
function parseTimescale(box: IsoBox, view: DataView): number {
if (box.offset + 24 > box.end) throw new Error('mdhd box 不完整')
const version = view.getUint8(box.offset + 8)
const offset = version === 1 ? box.offset + 28 : box.offset + 20
if (offset + 4 > box.end) throw new Error('mdhd timescale 缺失')
const timescale = view.getUint32(offset)
if (timescale === 0) throw new Error('MP4 timescale 为 0')
return timescale
}
function parseCodec(buffer: Uint8Array, mdatStart: number): Pick<CencMetadata, 'codec' | 'channels' | 'preSkip'> {
const end = Math.max(0, Math.min(mdatStart - 1, buffer.length - 4))
const dOps = findBytes(buffer, 'dOps', 0, end)
if (dOps >= 0) {
const channels = buffer[dOps + 5] ?? 1
const preSkip = ((buffer[dOps + 6] ?? 0) << 8) | (buffer[dOps + 7] ?? 0)
return { codec: 'opus', channels, preSkip }
}
// AudioSampleEntry 的 channelcount 位于 type 后 20 字节;加密入口也保留这段布局。
const encodedEntry = findBytes(buffer, 'enca', 0, end)
const plainEntry = findBytes(buffer, 'mp4a', 0, end)
const entryType = encodedEntry >= 0 ? encodedEntry : plainEntry
const channels = entryType >= 0 && entryType + 22 <= buffer.length
? new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16(entryType + 20)
: 1
if (findBytes(buffer, 'dfLa', 0, end) >= 0) {
return { codec: 'flac', channels: channels || 1, preSkip: 0 }
}
if (entryType >= 0) {
return { codec: 'aac', channels: channels || 1, preSkip: 0 }
}
throw new Error('未识别的音频编码(需要 Opus、AAC 或 FLAC)')
}
/** 解析一份已包含 moov 元数据的 MP4 CENC 头。 */
export function parseCencMetadata(head: Uint8Array): CencMetadata {
const mdatStart = findMdatStart(head)
if (mdatStart < 0) throw new Error('未找到 mdat,MP4 头可能尚未拉完整')
const boxes: IsoBox[] = []
walkBoxes(head, 0, Math.min(mdatStart - 8, head.length), box => boxes.push(box))
const stsz = findBox(boxes, 'stsz')
const stts = findBox(boxes, 'stts')
const senc = findBox(boxes, 'senc')
const mdhd = findBox(boxes, 'mdhd')
if (!stsz || !stts || !senc || !mdhd) {
throw new Error('CENC MP4 头缺少 stsz/stts/senc/mdhd')
}
const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
const sizes = parseSampleSizes(stsz, view)
const durations = parseDurations(stts, sizes.length, view)
const ivs = parseIvs(senc, sizes.length, view)
const timescale = parseTimescale(mdhd, view)
const codec = parseCodec(head, mdatStart)
const offsets = new Float64Array(sizes.length + 1)
for (let i = 0; i < sizes.length; i++) {
offsets[i + 1] = offsets[i]! + sizes[i]!
if (!Number.isSafeInteger(offsets[i + 1])) throw new Error('音频采样数据超过 JavaScript 安全整数范围')
}
let totalTicks = 0
for (const duration of durations) {
totalTicks += duration
if (!Number.isSafeInteger(totalTicks)) throw new Error('音频总时长超过 JavaScript 安全整数范围')
}
return {
...codec,
sampleCount: sizes.length,
sizes,
ivs,
durations,
timescale,
mdatStart,
offsets,
totalTicks,
}
}
export function cencIvSize(meta: CencMetadata): number {
return meta.ivs.length / meta.sampleCount
}
export function normalizeCencKey(value: string | ArrayBuffer | Uint8Array): Bytes {
if (typeof value === 'string') {
const bytes = hexToBytes(value)
if (bytes.length !== 16) throw new Error('AES-128 key 必须是 32 位 hex')
return bytes
}
const bytes = asBytes(value)
if (bytes.length !== 16) throw new Error('AES-128 key 必须是 16 字节')
return bytes.slice() as Bytes
}
export async function importCencKey(value: string | ArrayBuffer | Uint8Array): Promise<CryptoKey> {
return getSubtle().importKey(
'raw',
normalizeCencKey(value),
{ name: 'AES-CTR' },
false,
['decrypt'],
)
}
/** 按 CENC 的 8/16 字节 IV 构造 Web Crypto 的 16 字节 counter。 */
export function makeCencCounter(iv: Uint8Array, counterStart: 0 | 1): Bytes {
if (iv.length !== 8 && iv.length !== 16) throw new Error(`无效的 CENC IV 长度: ${iv.length}`)
const counter = new Uint8Array(16)
counter.set(iv)
if (counterStart === 1) counter[15] = 1
return counter as Bytes
}
export async function decryptCencSample(
key: CryptoKey,
iv: Uint8Array,
ciphertext: Uint8Array,
counterStart: 0 | 1,
): Promise<Bytes> {
// TS 6 会把普通 Uint8Array 视为可能由 SharedArrayBuffer 支撑;Web Crypto 不接受它。
const data = asBytes(ciphertext)
const plain = await getSubtle().decrypt(
{ name: 'AES-CTR', counter: makeCencCounter(iv, counterStart), length: 64 },
key,
data,
)
return new Uint8Array(plain) as Bytes
}
+191
View File
@@ -0,0 +1,191 @@
// 音频 CDN 的取流通道。
//
// 为什么不用页面 fetch:番茄网页站的 CSP(report-only)里 connect-src 没有任何
// *vod.com 域名,音频 CDN 一个都不在白名单上。report-only 不会真拦请求,但每次
// 都会往 mon.zijieapi.com/monitor_browser/collect/batch/security/ 上报一条违规,
// 等于把听书行为连着 URL 一起报给服务端。所以走 GM_xmlhttpRequest:
// 它在页面 CSP 之外,既不触发上报,也天然不带页面 Referer 和 Cookie。
//
// 试过换 aid 让服务端返回白名单内的域名,行不通 —— CDN 域名跟着 aid 所属产品走:
// 1967 番茄小说 -> *.fqnovelvod.com 3040 番茄畅听 -> *.novelfmvod.com
// 13/35 头条 -> *.toutiaovod.com 6589 -> *.wkbrowser.com
// 1217/1128/8478/32/596084 -> PARAM_INVALID(不支持听书接口)
// 没有一个落在 connect-src 里,而且签名里的 aid 必须与 query 一致,
// 否则网关直接回 200 空响应体。
type AnyResponse = Tampermonkey.Response<unknown> & { response?: unknown }
/**
* 用 GM_xmlhttpRequest 实现的、够播放器用的 fetch 子集。
*
* 只支持播放器实际用到的部分:GET + Range + AbortSignal + 流式 body。
*
* 关于 responseType 的坑(踩过两次,记在这):
* - `'stream'`:TM 会把 ReadableStream 放进 res.response,但**不是**在
* readyState 2 那一刻就绪。在 readyState 2 判断 instanceof 永远失败。
* - `'arraybuffer'`:res.response 只在 readyState 4 才有值,onprogress
* 期间一直是 null,拿不到增量。
* - `responseText`:绝对不能用来还原二进制。它按字符解码过,>0xFF 的码位
* 已经丢了原始字节,喂进 MSE 会直接报 SourceBuffer 更新失败。
* 所以这里两种都试:优先等 'stream',拿不到就退回整段 arraybuffer。
*/
export function gmAudioFetch(
input: string | URL | Request,
init: RequestInit = {},
): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const headers = normalizeHeaders(init.headers)
const signal = init.signal ?? null
return new Promise<Response>((resolve, reject) => {
if (signal?.aborted) {
reject(abortError())
return
}
let settled = false
let request: { abort: () => void } | undefined
const onAbort = (): void => {
try { request?.abort() } catch { /* 尚未开始或已结束 */ }
if (settled) return
settled = true
reject(abortError())
}
signal?.addEventListener('abort', onAbort, { once: true })
const cleanup = (): void => {
signal?.removeEventListener('abort', onAbort)
}
const succeed = (res: Tampermonkey.ResponseBase, body: BodyInit | null): void => {
if (settled) return
settled = true
cleanup()
resolve(makeResponse(res, body))
}
const fail = (error: Error): void => {
if (settled) return
settled = true
cleanup()
reject(error)
}
request = GM_xmlhttpRequest({
method: 'GET',
url,
headers,
responseType: 'stream',
// GM 请求不带页面 Referer;anonymous 同时也不带 Cookie
anonymous: true,
onreadystatechange(res) {
// readyState 3/4 时 TM 才把 stream 挂上来。拿到就立刻交出去,
// 播放器那边就能边下边解密,不必等整段下完
if (settled || res.readyState < 3) return
const stream = (res as AnyResponse).response
if (stream instanceof ReadableStream) succeed(res, stream)
},
onload(res) {
if (settled) return
// 没走成 stream:这时 readyState 已是 4,response 里是完整数据
const stream = (res as AnyResponse).response
if (stream instanceof ReadableStream) {
succeed(res, stream)
return
}
const bytes = toBytes(res)
if (!bytes) {
fail(new Error(
'GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)',
))
return
}
if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) {
fail(new Error('音频响应为空'))
return
}
succeed(res, bytes as unknown as BodyInit)
},
onerror(res) {
fail(new Error(`音频 CDN 请求失败:${describe(res)}`))
},
ontimeout() {
fail(new Error('音频 CDN 请求超时'))
},
}) as unknown as { abort: () => void }
})
}
function abortError(): DOMException {
return new DOMException('音频请求已取消', 'AbortError')
}
/** 把 GM 的响应头文本解析成 Headers */
function parseHeaders(raw: string | undefined): Headers {
const headers = new Headers()
if (!raw) return headers
for (const line of raw.split(/\r?\n/)) {
const colon = line.indexOf(':')
if (colon <= 0) continue
const name = line.slice(0, colon).trim()
const value = line.slice(colon + 1).trim()
if (!name) continue
try {
headers.append(name, value)
} catch {
// 个别响应头名字不合规范(比如带空格),跳过即可
}
}
return headers
}
/**
* 用 GM 的状态和响应头拼一个 Response。
*
* 播放器只读 status / ok / body,不需要还原全部语义。
* body 为空时给一条空流:206 是合法状态,但 `new Response(null, {status:206})`
* 会抛错(只有 204/304 允许空 body)。
*/
function makeResponse(res: Tampermonkey.ResponseBase, body: BodyInit | null): Response {
return new Response(body ?? new Uint8Array(), {
status: res.status || 200,
statusText: res.statusText || '',
headers: parseHeaders(res.responseHeaders),
})
}
/**
* 从 GM 响应里取出完整字节。
*
* 只认真正的二进制载荷 —— 见文件头关于 responseText 的说明,
* 拿不到就返回 null 让上层报错,比悄悄喂坏数据好。
*/
function toBytes(res: Tampermonkey.Response<unknown>): Uint8Array | null {
const raw = (res as AnyResponse).response
if (raw instanceof ArrayBuffer) return new Uint8Array(raw)
if (raw instanceof Uint8Array) return raw
return null
}
function normalizeHeaders(init: HeadersInit | undefined): Record<string, string> {
const out: Record<string, string> = {}
if (!init) return out
if (init instanceof Headers) {
init.forEach((value, key) => { out[key] = value })
return out
}
if (Array.isArray(init)) {
for (const [key, value] of init) {
if (key !== undefined && value !== undefined) out[key] = value
}
return out
}
return { ...init }
}
function describe(res: Tampermonkey.ErrorResponse | Tampermonkey.ResponseBase): string {
if ('error' in res && res.error) return String(res.error)
const status = (res as Tampermonkey.ResponseBase).status
return status ? `HTTP ${status}` : '网络错误'
}
+24
View File
@@ -0,0 +1,24 @@
/**
* CENC 听书播放器。
*
* 最小用法:
* const player = new CencAudioPlayer(audioElement)
* await player.start({ url, key })
* // 切章:再次 start;离开听书页:player.destroy()
*/
export {
CencAudioPlayer,
type CencAudioPlayerCallbacks,
type CencAudioPlayerOptions,
type CencAudioSource,
type CencMediaInfo,
type CencPlayerState,
type CencProgress,
} from './CencAudioPlayer'
export {
normalizeCencKey,
parseCencMetadata,
type CencCodec,
type CencMetadata,
} from './cenc'
+260
View File
@@ -0,0 +1,260 @@
import { ascii, be16, be32, be64, concatBytes, findBytes, hexToBytes, strBytes, uintBytes, type Bytes } from './bytes'
import type { CencCodec, CencMetadata } from './cenc'
function ebmlVint(value: number, length: number): Bytes {
if (!Number.isSafeInteger(value) || value < 0 || length < 1 || length > 8) {
throw new Error(`无效的 EBML VINT: ${value}`)
}
const result = new Uint8Array(length)
result[0] = 0x80 >> (length - 1)
let current = value
for (let i = length - 1; i > 0; i--) {
result[i] = current & 0xff
current = Math.floor(current / 256)
}
const max = Math.pow(2, 7 * length)
if (current >= max) throw new Error(`EBML VINT 溢出: ${value}`)
result[0] |= current
return result as Bytes
}
/**
* 编码 EBML 元素长度。
*
* 关键约束:全 1 的 VINT(1 字节的 0xFF、2 字节的 0x7FFF……)在 EBML 里是保留值,
* 表示「长度未知」。所以 2^(7n)-1 这些边界值必须多用一个字节来编,
* 否则解析器会把这个元素当成 unknown-size,从它开始一路吞掉后面的数据。
*
* 踩过的坑:Opus 帧正好 127 字节时 SimpleBlock 的 payload 长度就是 127,
* 编成 0xFF 之后 Chromium 报 CHUNK_DEMUXER_ERROR_APPEND_FAILED,
* ffmpeg 报 "Unknown-sized element inside parent with finite size",
* 750 帧里只解出 8 帧。
*/
function ebmlSize(value: number): Bytes {
for (let length = 1; length <= 8; length++) {
// < 2^(7n)-1:留出全 1 这个保留值,等于时进位到下一个长度
if (value < Math.pow(2, 7 * length) - 1) return ebmlVint(value, length)
}
throw new Error(`EBML size 过大: ${value}`)
}
function ebmlElement(id: string, payload: Uint8Array): Bytes {
return concatBytes(hexToBytes(id), ebmlSize(payload.length), payload)
}
function mp4Box(type: string, payload: Uint8Array): Bytes {
return concatBytes(be32(8 + payload.length), ascii(type), payload)
}
function matrixIdentity(): Bytes {
const matrix = new Uint8Array(36)
const view = new DataView(matrix.buffer)
view.setUint32(0, 0x10000)
view.setUint32(16, 0x10000)
view.setUint32(32, 0x40000000)
return matrix as Bytes
}
function signedBe16(value: number): Bytes {
const result = new Uint8Array(2)
new DataView(result.buffer).setInt16(0, value)
return result as Bytes
}
/** 构造只包含 Opus 音轨的 WebM 初始化段。 */
export function buildWebmInit(channels: number, preSkip: number, durationSeconds: number): Bytes {
const ebml = ebmlElement('1A45DFA3', concatBytes(
ebmlElement('4286', hexToBytes('01')),
ebmlElement('42F7', hexToBytes('01')),
ebmlElement('42F2', hexToBytes('04')),
ebmlElement('42F3', hexToBytes('08')),
ebmlElement('4282', strBytes('webm')),
ebmlElement('4287', hexToBytes('04')),
ebmlElement('4285', hexToBytes('02')),
))
const codecDelayNs = Math.round(preSkip * 1e9 / 48000)
const duration = new Uint8Array(8)
new DataView(duration.buffer).setFloat64(0, durationSeconds, false)
const info = ebmlElement('1549A966', concatBytes(
ebmlElement('2AD7B1', uintBytes(1000000)),
ebmlElement('4489', duration as Bytes),
ebmlElement('4D80', strBytes('clearKeyStreamer')),
ebmlElement('5741', strBytes('clearKeyStreamer')),
))
const sampleRate = new Uint8Array(4)
new DataView(sampleRate.buffer).setFloat32(0, 48000, false)
const audio = ebmlElement('E1', concatBytes(
ebmlElement('B5', sampleRate as Bytes),
ebmlElement('9F', uintBytes(channels)),
))
const track = ebmlElement('AE', concatBytes(
ebmlElement('D7', hexToBytes('01')),
ebmlElement('73C5', uintBytes(1)),
ebmlElement('83', hexToBytes('02')),
ebmlElement('86', strBytes('A_OPUS')),
ebmlElement('63A2', buildOpusHead(channels, preSkip)),
ebmlElement('56AA', uintBytes(codecDelayNs)),
ebmlElement('56BB', uintBytes(80000000)),
audio,
))
const tracks = ebmlElement('1654AE6B', track)
// Unknown-size Segment permits later clusters and seek-triggered re-buffering.
return concatBytes(ebml, hexToBytes('18538067'), hexToBytes('01FFFFFFFFFFFFFF'), info, tracks)
}
function buildOpusHead(channels: number, preSkip: number): Bytes {
const head = new Uint8Array(19)
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0)
const view = new DataView(head.buffer)
head[8] = 1
head[9] = channels
view.setUint16(10, preSkip, true)
view.setUint32(12, 48000, true)
view.setInt16(16, 0, true)
head[18] = 0
return head as Bytes
}
/** 构造一个包含若干 Opus Block 的 WebM Cluster。 */
export function buildWebmCluster(baseMs: number, relativeMs: readonly number[], packets: readonly Uint8Array[]): Bytes {
if (relativeMs.length !== packets.length) throw new Error('WebM 时间戳和采样数不一致')
const blocks: Bytes[] = []
for (let i = 0; i < packets.length; i++) {
const relative = relativeMs[i]
if (relative === undefined || relative < -32768 || relative > 32767) {
throw new Error(`WebM 相对时间戳超出 Int16: ${relative}`)
}
const trackAndFlags = new Uint8Array([0x81, 0x80])
blocks.push(ebmlElement('A3', concatBytes(
trackAndFlags.slice(0, 1),
signedBe16(relative),
trackAndFlags.slice(1),
packets[i]!,
)))
}
return ebmlElement('1F43B675', concatBytes(
ebmlElement('E7', uintBytes(baseMs)),
concatBytes(...blocks),
))
}
function replaceFourcc(buffer: Bytes, offset: number, type: string): void {
buffer.set(ascii(type), offset)
}
/** 从原始 MP4 头复制采样入口,并把加密入口改回浏览器可识别的明文入口。 */
export function extractSampleEntry(head: Uint8Array, mdatStart: number): Bytes {
const encoded = findBytes(head, 'enca', 0, mdatStart - 1)
const plain = findBytes(head, 'mp4a', 0, mdatStart - 1)
const typeOffset = encoded >= 0 ? encoded : plain
if (typeOffset < 4) throw new Error('未找到 AAC 音频采样入口')
const entryOffset = typeOffset - 4
const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
const size = view.getUint32(entryOffset)
if (size < 8 || entryOffset + size > head.length) throw new Error('AAC 采样入口不完整')
const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes
const enca = findBytes(entry, 'enca', 0)
if (enca >= 0) replaceFourcc(entry, enca, 'mp4a')
const sinf = findBytes(entry, 'sinf', 8)
if (sinf >= 0) replaceFourcc(entry, sinf, 'free')
return entry
}
/** 从原始 MP4 头复制 FLAC 采样入口,并去掉 CENC 标记。 */
export function extractFlacEntry(head: Uint8Array, mdatStart: number): Bytes {
const encoded = findBytes(head, 'enca', 0, mdatStart - 1)
if (encoded < 4) throw new Error('未找到 FLAC 加密采样入口')
const entryOffset = encoded - 4
const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
const size = view.getUint32(entryOffset)
if (size < 8 || entryOffset + size > head.length) throw new Error('FLAC 采样入口不完整')
const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)) as Bytes
replaceFourcc(entry, encoded - entryOffset, 'fLaC')
const sinf = findBytes(entry, 'sinf', 8)
if (sinf >= 0) replaceFourcc(entry, sinf, 'free')
return entry
}
/** 构造 AAC/FLAC 的 fragmented MP4 初始化段。 */
export function buildMp4Init(sampleEntry: Uint8Array, timescale: number, totalTicks: number): Bytes {
const totalSeconds = totalTicks / timescale
const matrix = matrixIdentity()
const mvhd = mp4Box('mvhd', concatBytes(
be32(0), be32(0), be32(0), be32(1000), be32(Math.round(totalSeconds * 1000)),
be32(0x10000), be16(0x100), be16(0), new Uint8Array(8),
matrix, new Uint8Array(24), be32(2),
))
const tkhd = mp4Box('tkhd', concatBytes(
be32(7), be32(0), be32(0), be32(1), be32(0), be32(0), new Uint8Array(8),
be16(0), be16(0), be16(0x100), be16(0), matrix, be32(0), be32(0),
))
const mdhd = mp4Box('mdhd', concatBytes(
be32(0), be32(0), be32(0), be32(timescale), be32(totalTicks), be16(0x55c4), be16(0),
))
const hdlr = mp4Box('hdlr', concatBytes(
be32(0), be32(0), ascii('soun'), new Uint8Array(12),
))
const smhd = mp4Box('smhd', concatBytes(be32(0), be16(0), be16(0)))
const url = mp4Box('url ', be32(1))
const dref = mp4Box('dref', concatBytes(be32(0), be32(1), url))
const dinf = mp4Box('dinf', dref)
const stsd = mp4Box('stsd', concatBytes(be32(0), be32(1), sampleEntry))
const stts = mp4Box('stts', concatBytes(be32(0), be32(0)))
const stsc = mp4Box('stsc', concatBytes(be32(0), be32(0)))
const stsz = mp4Box('stsz', concatBytes(be32(0), be32(0), be32(0)))
const stco = mp4Box('stco', concatBytes(be32(0), be32(0)))
const stbl = mp4Box('stbl', concatBytes(stsd, stts, stsc, stsz, stco))
const minf = mp4Box('minf', concatBytes(smhd, dinf, stbl))
const mdia = mp4Box('mdia', concatBytes(mdhd, hdlr, minf))
const trak = mp4Box('trak', concatBytes(tkhd, mdia))
const trex = mp4Box('trex', concatBytes(be32(0), be32(1), be32(1), be32(0), be32(0), be32(0)))
const mvex = mp4Box('mvex', trex)
const moov = mp4Box('moov', concatBytes(mvhd, trak, mvex))
const ftyp = mp4Box('ftyp', concatBytes(ascii('isom'), be32(512), ascii('isomiso2mp41dash')))
return concatBytes(ftyp, moov)
}
export interface Mp4Sample {
data: Uint8Array
duration: number
}
/** 构造一个 fragmented MP4 media segment。 */
export function buildMp4Segment(
baseDecodeTick: number,
samples: readonly Mp4Sample[],
sequence: number,
): Bytes {
if (samples.length === 0) throw new Error('MP4 segment 不能没有采样')
const sampleTable = samples.flatMap(sample => [be32(sample.duration), be32(sample.data.length)])
const mfhd = mp4Box('mfhd', concatBytes(be32(0), be32(sequence)))
const tfhd = mp4Box('tfhd', concatBytes(be32(0x020000), be32(1)))
const tfdt = mp4Box('tfdt', concatBytes(be32(0x01000000), be64(baseDecodeTick)))
const makeTrun = (offset: number) => mp4Box('trun', concatBytes(
be32(0x000301), be32(samples.length), be32(offset), concatBytes(...sampleTable),
))
const makeMoof = (offset: number) => mp4Box('moof', concatBytes(
mfhd, mp4Box('traf', concatBytes(tfhd, tfdt, makeTrun(offset))),
))
const mediaPayload = concatBytes(...samples.map(sample => sample.data))
const dataOffset = makeMoof(0).length + 8
return concatBytes(makeMoof(dataOffset), mp4Box('mdat', mediaPayload))
}
export function codecMime(codec: CencCodec): string {
if (codec === 'opus') return 'audio/webm;codecs="opus"'
if (codec === 'flac') return 'audio/mp4; codecs="fLaC"'
return 'audio/mp4; codecs="mp4a.40.2"'
}
export function initSegment(
meta: CencMetadata,
sampleEntry?: Uint8Array,
): Bytes {
if (meta.codec === 'opus') {
const playableTicks = Math.max(0, meta.totalTicks - meta.preSkip)
return buildWebmInit(meta.channels, meta.preSkip, playableTicks / meta.timescale)
}
if (!sampleEntry) throw new Error('MP4 编码缺少 sample entry')
return buildMp4Init(sampleEntry, meta.timescale, meta.totalTicks)
}
+28
View File
@@ -9,9 +9,15 @@ export type ApiPreference = 'app' | 'redcandle'
/** 下载格式 */
export type DownloadFormat = 'epub' | 'txt'
/** 书架里单击书本的去向 */
export type BookshelfClickAction = 'read' | 'detail'
/** TXT 编码 */
export type DownloadCharset = 'utf-8' | 'gbk'
/** 听书读完一章之后做什么 */
export type AudiobookChapterEnd = 'next' | 'stop'
export interface Settings {
/* --- 常规 --- */
/** 解密网页端混淆字体 */
@@ -28,6 +34,11 @@ export interface Settings {
customCssEnabled: boolean
/** 自定义 CSS 内容。关闭开关时仍然保留,只是不应用 */
customCss: string
/**
* 书架里单击书本的默认去向。
* 按住 Ctrl 单击取相反项;中键(或 Ctrl+中键)在新标签页打开对应目标。
*/
bookshelfClickAction: BookshelfClickAction
/* --- 搜索 --- */
/** 接管网页搜索界面 */
@@ -55,6 +66,12 @@ export interface Settings {
/** EPUB:保留书籍自带的排版样式(css_map) */
downloadBookCss: boolean
/* --- 听书 --- */
/** 读完一章之后:next=自动下一章,stop=停下 */
audiobookChapterEnd: AudiobookChapterEnd
/** 高亮朗读中的段落,并跟随滚动 */
audiobookFollow: boolean
/* --- 协议 --- */
apiPreference: ApiPreference
/** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */
@@ -71,6 +88,8 @@ export const DEFAULT_SETTINGS: Settings = {
readerFont: '',
customCssEnabled: false,
customCss: '',
// 原站书架点封面是继续阅读,保持一致
bookshelfClickAction: 'read',
enhanceSearch: true,
// 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启
@@ -88,6 +107,9 @@ export const DEFAULT_SETTINGS: Settings = {
downloadImages: true,
downloadBookCss: true,
audiobookChapterEnd: 'next',
audiobookFollow: true,
apiPreference: 'app',
deviceId: '',
installId: '',
@@ -116,6 +138,12 @@ function normalize(raw: unknown): Settings {
if (s.downloadCharset !== 'utf-8' && s.downloadCharset !== 'gbk') {
s.downloadCharset = DEFAULT_SETTINGS.downloadCharset
}
if (s.bookshelfClickAction !== 'read' && s.bookshelfClickAction !== 'detail') {
s.bookshelfClickAction = DEFAULT_SETTINGS.bookshelfClickAction
}
if (s.audiobookChapterEnd !== 'next' && s.audiobookChapterEnd !== 'stop') {
s.audiobookChapterEnd = DEFAULT_SETTINGS.audiobookChapterEnd
}
// 数字项来自输入框,可能是 NaN 或越界值
s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize)
s.downloadInterval = clampInt(s.downloadInterval, 0, 10_000, DEFAULT_SETTINGS.downloadInterval)
+128
View File
@@ -210,3 +210,131 @@ export interface SearchLandingSection {
words: SearchHotWord[]
books: SearchSuggestBook[]
}
/* --------------------------------- 书评 --------------------------------- */
/** 评论作者。头衔(vip/作者等)接口下发的是内嵌 JSON 字符串,这里只留解析后的名字 */
export interface CommentUser {
user_id: string
name: string
avatar: string
description: string
is_author: boolean
is_vip: boolean
/** 头衔名,如 vip、榜一大哥 */
titles: string[]
}
/**
* 正文里的富文本片段(书名号引用、话题等)。
*
* s/e 是 UTF-16 码元下标(实测与 String.prototype.slice 一致,
* 不是 UTF-8 字节,也不是码点序号)。tp 目前只见过 4 = 搜索跳转。
*/
export interface CommentTextExt {
start: number
end: number
text: string
type: number
/** dragon1967:// 开头的 APP 内部 schema,网页端点不了,仅保留原文 */
uri?: string
}
/** 评论与回复共有的部分 */
export interface CommentBase {
id: string
text: string
/** 富文本片段,可能为空 */
exts: CommentTextExt[]
/** 秒级时间戳 */
create_time: number
user: CommentUser
digg_count: number
reply_count: number
/** 当前用户是否赞过。未登录(或读不到 sessionid)时恒为 false */
user_digg: boolean
user_disagree: boolean
}
/** 一楼书评 */
export interface BookComment extends CommentBase {
/** 评分,1-10,缺失为 0 */
score: number
/** 接口下发的展示文案,如「阅读19小时后点评」 */
score_text: string
/** 作者置顶 */
author_stick: boolean
/** 追评。同一用户在原评论之后补写的内容 */
addition?: CommentBase
/** 接口下发的前几条回复,展开前先用它 */
preview_reply_count: number
}
/** 楼中楼回复。二级回复挂在 sub 里 */
export interface CommentReply extends CommentBase {
/** 所属评论 id */
to_comment_id: string
/** 二级回复时,父回复的 id */
to_reply_id: string
/** 接口预览下发的子回复 */
sub: CommentReply[]
}
/** 书评区筛选标签,如「长评」「文笔很好」 */
export interface CommentFilterTag {
tag_id: string
tag_name: string
count: number
}
/** 评论排序。1=最热,3=最新(其余值接口报参数错误) */
export type CommentSort = 1 | 3
export interface CommentListResult {
comments: BookComment[]
/** 服务端游标,原样回传即可翻页 */
cursor: string
has_more: boolean
/** 该书评论总数(受 tag 过滤影响) */
total: number
tags: CommentFilterTag[]
/** 点评人数展示文案,如「1.1万人点评」 */
score_text: string
score_count: number
/**
* 当前登录用户自己写的那条书评 / 打分。
* 服务端把它单独放在 extra.user_comment,不混在列表里;
* 没登录或还没点评过时为 null。打过分后即可用它回显星级。
*/
user_comment: BookComment | null
}
export interface ReplyListResult {
replies: CommentReply[]
cursor: string
has_more: boolean
total: number
}
export interface CencAudioContext {
urls: string[]
vid: string
key: string // empty for a not encrypted audio
item_id: string
}
export interface Tone {
id: number
name: string
icon: string // 空字符串是没有图标
gender: number // 1=男声 2=女声
description: string
}
export interface ParagraphTimeTag {
is_title: boolean // idx=10000 就是标题
startidx: number
endidx: number
startms: number
endms: number
}
+127
View File
@@ -0,0 +1,127 @@
// Cookie 读取。
//
// 网页站的 sessionid 是 HttpOnly,document.cookie 读不到,而书评接口挂在
// reading.snssdk.com 根路径(网页站只把 /reading 反代到了 APP 网关),
// 拿不到同源那份 Cookie,只能自己把 sessionid 塞进请求头。
//
// GM_cookie 能读 HttpOnly,但 Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie,
// 需要用户手动放开:
// 设置 → 通用 → 配置模式 → 高级
// 设置 → 高级 → 安全 → 允许脚本访问 Cookie → All
// 见 https://github.com/Tampermonkey/tampermonkey/issues/465#issuecomment-2479061865
//
// 没有任何接口能告诉我们「是不是被这个开关挡住了」,所以只能反推:
// 用户明明是登录状态(checkLogin 通过),却读不到 sessionid —— 那就是被挡了。
/** 放开 Cookie 访问的操作路径,取不到 sessionid 时提示用户 */
export const COOKIE_PERMISSION_HINT =
'读不到登录态(sessionid)。若确认已登录,请在 Tampermonkey 里放开 Cookie 访问:' +
'设置 → 通用 → 配置模式 改为「高级」,再到 设置 → 高级 → 安全 →' +
'「允许脚本访问 Cookie」改为 All。'
/** GM_cookie.list 迟迟不回调时的等待上限 */
const LIST_TIMEOUT = 5_000
export type CookieFailure =
/** 脚本管理器不支持,或没授权 GM_cookie */
| 'no-api'
/** GM_cookie.list 报错或超时 */
| 'api-error'
/** 接口正常但没有这条 Cookie(未登录,或被 HttpOnly 开关挡住) */
| 'not-found'
export interface CookieLookup {
value: string | null
reason?: CookieFailure
/** api-error 时的原始错误信息 */
error?: string
}
function hasGMCookie(): boolean {
// @grant GM_cookie 缺失时这个标识符压根不存在,typeof 才不会抛
return typeof GM_cookie !== 'undefined' && typeof GM_cookie?.list === 'function'
}
/** 把回调式的 GM_cookie.list 包成 Promise,并防它不回调 */
function listCookies(details: Tampermonkey.ListCookiesDetails): Promise<Tampermonkey.Cookie[]> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
clearTimeout(timer)
fn()
}
const timer = setTimeout(
() => finish(() => reject(new Error('GM_cookie.list 无响应'))),
LIST_TIMEOUT,
)
try {
GM_cookie.list(details, (cookies, error) => {
finish(() => (error ? reject(new Error(error)) : resolve(cookies ?? [])))
})
} catch (err) {
finish(() => reject(err instanceof Error ? err : new Error(String(err))))
}
})
}
/** document.cookie 兜底,只能读到非 HttpOnly 的 */
function readFromDocument(name: string): string | null {
for (const part of document.cookie.split(';')) {
const raw = part.trim()
if (raw.startsWith(name + '=')) return raw.slice(name.length + 1)
}
return null
}
/**
* 读一条 Cookie。先试 GM_cookie(能读 HttpOnly),再退到 document.cookie。
*
* @param name Cookie 名
* @param url 归属页面,默认当前文档
*/
export async function readCookie(name: string, url?: string): Promise<CookieLookup> {
if (hasGMCookie()) {
try {
const cookies = await listCookies(url ? { url, name } : { name })
// 指定了 name 仍可能匹配到多条(不同 domain/path),取第一条有值的
const hit = cookies.find(c => c.name === name && c.value)
if (hit) return { value: hit.value }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.warn('[fqa:cookie] GM_cookie.list 失败,退到 document.cookie:', message)
const fallback = readFromDocument(name)
if (fallback) return { value: fallback }
return { value: null, reason: 'api-error', error: message }
}
const fallback = readFromDocument(name)
return fallback ? { value: fallback } : { value: null, reason: 'not-found' }
}
const fallback = readFromDocument(name)
return fallback ? { value: fallback } : { value: null, reason: 'no-api' }
}
const SESSION_COOKIE = 'sessionid'
/** 成功读到的 sessionid。同一页面内复用,避免每次点赞都问一遍脚本管理器 */
let cached: string | null = null
/**
* 读网页站的 sessionid。
*
* 只在需要写操作(点赞等)或需要「我是否赞过」状态时调用;
* 拿到的值仅随请求发往字节官方的 reading.snssdk.com,不经任何第三方。
*/
export async function getSessionId(): Promise<CookieLookup> {
if (cached) return { value: cached }
const found = await readCookie(SESSION_COOKIE)
if (found.value) cached = found.value
return found
}
/** 登录状态变化后调用,丢掉旧的 sessionid */
export function clearSessionIdCache(): void {
cached = null
}
+5
View File
@@ -78,4 +78,9 @@ export function concatArrayBuffers(...buffers: ArrayBuffer[]): ArrayBuffer {
offset += buf.byteLength
}
return result.buffer
}
export function clearNodeEventListeners<T extends Element>(element: T): void {
element.replaceWith(cloneElement(element))
return
}
+58
View File
@@ -0,0 +1,58 @@
// 书籍卡片的跳转辅助。
//
// 交互约定:
// 单击 -> 设置里选的默认去向(继续阅读 / 查看详情)
// Ctrl+单击 -> 另一个去向
// 中键 -> 默认去向,新标签页
// Ctrl+中键 -> 另一个去向,新标签页
//
// macOS 上 Ctrl+单击被系统当成右键(会触发 contextmenu),所以 Cmd 也算「反向」,
// 否则 Mac 用户没有可用的反向键。
/** 鼠标中键 */
const MIDDLE_BUTTON = 1
export interface OpenModifiers {
/** 取与默认相反的去向 */
flip: boolean
/** 在新标签页打开 */
newTab: boolean
}
/** 从鼠标/键盘事件里读出修饰意图。键盘事件没有 button,只可能 flip */
export function modifiersOf(event: MouseEvent | KeyboardEvent): OpenModifiers {
return {
flip: event.ctrlKey || event.metaKey,
newTab: 'button' in event && event.button === MIDDLE_BUTTON,
}
}
/** 中键按下时浏览器会进入自动滚动模式,光标变成罗盘,需要拦掉 */
export function isMiddleButton(event: MouseEvent): boolean {
return event.button === MIDDLE_BUTTON
}
export function readerUrl(chapterId: string): string {
return `https://fanqienovel.com/reader/${chapterId}`
}
export function bookPageUrl(bookId: string): string {
return `https://fanqienovel.com/page/${bookId}`
}
/**
* 跳转。newTab 为真时开新标签页。
*
* 不传 'noopener':按规范带上它 window.open 一律返回 null,就没法判断是否被拦了。
* 目标是同源页面,opener 可访问本身无害。
*/
export function openUrl(url: string, newTab = false): void {
if (!newTab) {
unsafeWindow.location.href = url
return
}
// 中键点击是用户手势,正常不会被拦;真被拦了也不要偷偷改成当前页跳转
if (!unsafeWindow.open(url, '_blank')) {
console.warn('[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:', url)
}
}
+27
View File
@@ -6,6 +6,30 @@ export type ApiResponse = Tampermonkey.Response<unknown> & {
json<T = unknown>(): T;
};
/**
* 响应没有任何正文。
*
* 番茄网关对「不认识的设备」就是这个回应:HTTP 200、`content-length: 0`、
* 一个字节都没有。裸 JSON.parse 会抛 `SyntaxError: "undefined" is not valid JSON`
* (Tampermonkey 在无正文时不给 responseText),完全看不出真正的原因,
* 所以这里换成一个能认出来的错误类型。
*/
export class EmptyResponseError extends Error {
readonly status: number;
constructor(status: number) {
super(`服务端返回了空响应体(HTTP ${status})`);
this.name = "EmptyResponseError";
this.status = status;
}
}
/** 响应体是否为空。Tampermonkey 可能给 undefined,也可能给空串 */
export function isEmptyResponse(res: Pick<Tampermonkey.ResponseBase, "responseText">): boolean {
const text = res.responseText;
return typeof text !== "string" || text === "";
}
const supportedMethods = new Set<GMRequestMethod>([
"GET",
"HEAD",
@@ -59,6 +83,9 @@ export default function apiFetch(
cleanup();
resolve(Object.assign(response, {
json<T = unknown>(this: Tampermonkey.Response<unknown>): T {
if (isEmptyResponse(this)) {
throw new EmptyResponseError(this.status);
}
return JSON.parse(this.responseText) as T;
},
}));
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
// 左下角悬浮播放栏。
// 布局:圆形旋转封面(点=播放/暂停) · 标题 · 三个点(重选音色) · left.svg(收起)
// 收起后只留一个水平翻转的 left.svg(朝右),再点展开。
import leftIcon from '../assets/left.svg?raw'
import playingIcon from '../assets/playing.svg?raw'
import pausedIcon from '../assets/paused.svg?raw'
import {
changeTone,
closeAudiobook,
closeTonePicker,
openTonePicker,
selectTone,
state,
toggleCollapsed,
togglePlay,
} from '../audiobook/controller'
import TonePicker from './TonePicker.vue'
/** 首次选择直接播;已经在播的是切音色,要按段落续上 */
function onSelect(id: number): void {
if (state.toneId === null) void selectTone(id)
else void changeTone(id)
}
</script>
<template>
<div v-if="state.open" class="fqa-audio-root" :class="{ 'fqa-audio-collapsed': state.collapsed }">
<!-- 收起态:只有一个朝右的箭头 -->
<button
v-if="state.collapsed"
type="button"
class="fqa-audio-expand"
title="展开听书栏"
aria-label="展开听书栏"
@click="toggleCollapsed"
>
<span class="fqa-audio-icon fqa-audio-icon-flip" v-html="leftIcon"></span>
</button>
<div v-else class="fqa-audio-bar">
<button
type="button"
class="fqa-audio-cover"
:title="state.playing ? '暂停' : '播放'"
:aria-label="state.playing ? '暂停' : '播放'"
@click="togglePlay"
>
<!-- 只有这层转,状态图标不跟着转 -->
<span class="fqa-audio-disc" :class="{ 'fqa-audio-spin': state.spinning }">
<img v-if="state.cover" :src="state.cover" alt="" />
<span v-else class="fqa-audio-cover-empty">听</span>
</span>
<!-- playing.svg 是暂停符号(两条竖线),播放中显示它表示「点了会暂停」 -->
<span class="fqa-audio-state" v-html="state.playing ? playingIcon : pausedIcon"></span>
</button>
<div class="fqa-audio-meta">
<span class="fqa-audio-title">{{ state.title }}</span>
<span class="fqa-audio-sub">
{{ state.error || (state.loading ? '缓冲中…' : state.toneName) }}
</span>
</div>
<button
type="button"
class="fqa-audio-btn"
title="切换音色"
aria-label="切换音色"
@click="openTonePicker"
>
<span class="fqa-audio-dots"></span>
</button>
<button
type="button"
class="fqa-audio-btn"
title="收起"
aria-label="收起听书栏"
@click="toggleCollapsed"
>
<span class="fqa-audio-icon" v-html="leftIcon"></span>
</button>
<button
type="button"
class="fqa-audio-btn fqa-audio-close"
title="关闭听书"
aria-label="关闭听书"
@click="closeAudiobook"
>
×
</button>
</div>
<TonePicker
v-if="state.tonePickerOpen"
:tones="state.tones"
:current="state.toneId"
@select="onSelect"
@close="closeTonePicker"
/>
</div>
</template>
+25 -4
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { BookShelfEntry } from '../types'
import { isMiddleButton, modifiersOf, type OpenModifiers } from '../utils/navigate'
const props = defineProps<{ entry: BookShelfEntry }>()
const emit = defineEmits<{
(e: 'hover', payload: { entry: BookShelfEntry; el: HTMLElement }): void
(e: 'leave'): void
(e: 'open', entry: BookShelfEntry): void
(e: 'open', payload: { entry: BookShelfEntry; mods: OpenModifiers }): void
(e: 'visible', entry: BookShelfEntry): void
(e: 'contextmenu', payload: { entry: BookShelfEntry; x: number; y: number }): void
}>()
@@ -56,6 +57,24 @@ function onContextMenu(event: MouseEvent) {
emit('contextmenu', { entry: props.entry, x: event.clientX, y: event.clientY })
}
function open(event: MouseEvent | KeyboardEvent) {
emit('open', { entry: props.entry, mods: modifiersOf(event) })
}
/**
* 中键在 click 事件里拿不到(浏览器只对左键派发 click),走 auxclick。
* mousedown 上还要 preventDefault,否则中键会启动自动滚动。
*/
function onAuxClick(event: MouseEvent) {
if (!isMiddleButton(event)) return
event.preventDefault()
open(event)
}
function onMouseDown(event: MouseEvent) {
if (isMiddleButton(event)) event.preventDefault()
}
/** 封面淡入。用状态而非直接改 class,避免重渲染后卡在透明态 */
const imgLoaded = ref(false)
watch(
@@ -101,9 +120,11 @@ onBeforeUnmount(() => {
@mouseenter="onEnter"
@mouseleave="emit('leave')"
@contextmenu="onContextMenu"
@click="emit('open', entry)"
@keydown.enter.prevent="emit('open', entry)"
@keydown.space.prevent="emit('open', entry)"
@click="open"
@auxclick="onAuxClick"
@mousedown="onMouseDown"
@keydown.enter.prevent="open"
@keydown.space.prevent="open"
>
<!-- 详情未到达前先占位,避免高度跳动 -->
<template v-if="!detail">
+20 -12
View File
@@ -8,6 +8,7 @@ import { TABS, useBookshelf } from './useBookshelf'
import { moveToGroup, removeFromBookshelf } from '../api/bookshelf'
import { startDownload } from '../download'
import { settings } from '../settings'
import { bookPageUrl, openUrl, readerUrl, type OpenModifiers } from '../utils/navigate'
import type { BookShelfEntry, BookShelfGroup, BookShelfTabKey, MenuItem } from '../types'
/** 持续悬停多久后展示详情 */
@@ -232,15 +233,21 @@ function onGroupVisible(entries: BookShelfEntry[]) {
/* -------------------------------- 交互 -------------------------------- */
function openBook(entry: BookShelfEntry) {
hideHover(true)
/** 继续阅读的地址;没有阅读记录时返回 null,调用方退回详情页 */
function continueReadingUrl(entry: BookShelfEntry): string | null {
const chapterId = entry.detail?.current_chapter_id || entry.item.last_read_chapter_id
// 没有阅读记录时退回书籍详情页
const url =
chapterId && chapterId !== '0'
? `https://fanqienovel.com/reader/${chapterId}`
: `https://fanqienovel.com/page/${entry.item.book_id}`
unsafeWindow.location.href = url
return chapterId && chapterId !== '0' ? readerUrl(chapterId) : null
}
/**
* 单击书本。去向由设置决定,Ctrl(Mac 上还有 Cmd)取反,中键开新标签页。
* 选了「继续阅读」但这本书还没读过时,退回详情页。
*/
function openBook({ entry, mods }: { entry: BookShelfEntry; mods: OpenModifiers }) {
hideHover(true)
const wantRead = (settings.bookshelfClickAction === 'read') !== mods.flip
const url = (wantRead && continueReadingUrl(entry)) || bookPageUrl(entry.item.book_id)
openUrl(url, mods.newTab)
}
function openGroup(group: BookShelfGroup) {
@@ -283,7 +290,7 @@ const menuItems = computed<MenuItem[]>(() => {
if (current) targets.push({ key: NO_GROUP_KEY, label: '无分组' })
const items: MenuItem[] = [
{ key: 'open', label: '打开' },
{ key: 'read', label: '继续阅读' },
{ key: 'detail', label: '查看详情' },
{
key: 'move',
@@ -318,12 +325,13 @@ async function onMenuSelect(key: string) {
if (!entry) return
const bookId = entry.item.book_id
if (key === 'open') {
openBook(entry)
if (key === 'read') {
// 没读过就退回详情,和单击的行为一致
openUrl(continueReadingUrl(entry) ?? bookPageUrl(bookId))
return
}
if (key === 'detail') {
unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`
openUrl(bookPageUrl(bookId))
return
}
if (key.startsWith(DOWNLOAD_PREFIX)) {
+50 -1
View File
@@ -15,13 +15,14 @@ function resetDownloadTuning() {
flushSettings()
}
type SectionKey = 'general' | 'ui' | 'search' | 'download' | 'protocol' | 'about'
type SectionKey = 'general' | 'ui' | 'search' | 'download' | 'audiobook' | 'protocol' | 'about'
const SECTIONS: Array<{ key: SectionKey; label: string }> = [
{ key: 'general', label: '常规' },
{ key: 'ui', label: '界面' },
{ key: 'search', label: '搜索' },
{ key: 'download', label: '下载' },
{ key: 'audiobook', label: '听书' },
{ key: 'protocol', label: '协议' },
{ key: 'about', label: '关于' },
]
@@ -151,6 +152,24 @@ const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant'
></textarea>
<p class="fqa-set-note">关闭开关后内容会保留,只是不再应用。</p>
</div>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">书架单击书本</span>
<div class="fqa-set-radios">
<label class="fqa-set-radio">
<input v-model="settings.bookshelfClickAction" type="radio" value="read" />
<span>继续阅读</span>
</label>
<label class="fqa-set-radio">
<input v-model="settings.bookshelfClickAction" type="radio" value="detail" />
<span>查看详情</span>
</label>
</div>
<p class="fqa-set-note">
按住 Ctrl 单击去往另一项;中键在新标签页打开,Ctrl+中键同样反向。
选「继续阅读」但这本书还没读过时,会退回详情页。
</p>
</div>
</template>
<!-- 搜索 -->
@@ -285,6 +304,36 @@ const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant'
</div>
</template>
<!-- 听书 -->
<template v-else-if="active === 'audiobook'">
<h3 class="fqa-set-h">听书</h3>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">读完一章后</span>
<div class="fqa-set-radios">
<label class="fqa-set-radio">
<input v-model="settings.audiobookChapterEnd" type="radio" value="next" />
<span>自动下一章</span>
</label>
<label class="fqa-set-radio">
<input v-model="settings.audiobookChapterEnd" type="radio" value="stop" />
<span>停止播放</span>
</label>
</div>
<p class="fqa-set-note">
自动下一章会点击页面上的「下一章」,读到全书最后一章时自动停下。
</p>
</div>
<label class="fqa-set-row">
<span class="fqa-set-label">高亮并跟随朗读段落</span>
<input v-model="settings.audiobookFollow" type="checkbox" class="fqa-set-switch" />
</label>
<p class="fqa-set-note">
关闭后仍会高亮当前段落,但不再自动滚动页面。点击任意段落可跳到该处朗读。
</p>
</template>
<!-- 协议 -->
<template v-else-if="active === 'protocol'">
<h3 class="fqa-set-h">协议</h3>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Tone } from '../types'
const props = defineProps<{
tones: Tone[]
/** 当前音色,用来标出选中项;首次选择时为 null */
current: number | null
}>()
const emit = defineEmits<{
(e: 'select', id: number): void
(e: 'close'): void
}>()
/** 1=男声 2=女声,其他值就不标了 */
function genderLabel(gender: number): string {
if (gender === 1) return '男声'
if (gender === 2) return '女声'
return ''
}
</script>
<template>
<div class="fqa-tone-mask" @click.self="emit('close')">
<div class="fqa-tone-box" role="dialog" aria-modal="true" aria-label="选择音色">
<h3 class="fqa-tone-title">{{ props.current === null ? '选择音色' : '切换音色' }}</h3>
<p class="fqa-tone-sub">切换后会从当前段落开头继续</p>
<div class="fqa-tone-list">
<button
v-for="tone in props.tones"
:key="tone.id"
type="button"
class="fqa-tone-item"
:class="{ 'fqa-tone-item-active': tone.id === props.current }"
@click="emit('select', tone.id)"
>
<img v-if="tone.icon" class="fqa-tone-icon" :src="tone.icon" :alt="tone.name" />
<span v-else class="fqa-tone-icon fqa-tone-icon-empty">{{ tone.name.slice(0, 1) }}</span>
<span class="fqa-tone-text">
<span class="fqa-tone-name">
{{ tone.name }}
<span v-if="genderLabel(tone.gender)" class="fqa-tone-gender">
{{ genderLabel(tone.gender) }}
</span>
</span>
<span v-if="tone.description" class="fqa-tone-desc">{{ tone.description }}</span>
</span>
</button>
</div>
<div class="fqa-tone-actions">
<button type="button" class="fqa-tone-btn" @click="emit('close')">取消</button>
</div>
</div>
</div>
</template>
+9 -1
View File
@@ -30,7 +30,10 @@ export default defineConfig({
author: 'naiyQAQ',
'run-at': 'document-start',
match: ['*://*.fanqienovel.com/*'],
grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'unsafeWindow'],
// GM_cookie 用于读 HttpOnly 的 sessionid(书评点赞需要登录态)。
// 注意:Tampermonkey 稳定版默认只返回非 HttpOnly 的 Cookie,
// 用户需在 设置→通用→配置模式→高级,再 设置→高级→安全→允许脚本访问 Cookie→All
grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'GM_cookie', 'unsafeWindow'],
connect: [
'fanqienovel.com', // 主站
'jxbhmy.com', // 红烛小说 API
@@ -38,6 +41,11 @@ export default defineConfig({
'byteimg.com', // 图床
'fqnovelpic.com', // 图床
'bytecdn.cn', // 图床
// 听书音频 CDN。页面 CSP 的 connect-src 不含这些域名,
// 走页面 fetch 会被 report-only 策略上报,所以改用 GM_xmlhttpRequest
'fqnovelvod.com', // 番茄小说 TTS
'novelfmvod.com', // 番茄畅听
'volcautovod.com', // 火山引擎回源
],
},
build: {