diff --git a/src/api/book.ts b/src/api/book.ts index 84a0334..4a9ed74 100644 --- a/src/api/book.ts +++ b/src/api/book.ts @@ -17,23 +17,18 @@ export function mappingCreationStatus(status: string): string { } /** - * 书籍详情,对应 APP 的 /bookapi/multi-detail/v。 + * 书籍详情,对应 APP 的 /bookapi/detail/v。 * 响应字段与红烛接口一致,签名通用。 */ export async function getBookInfoRaw(bookId: string): Promise { - const response = await appGet('/bookapi/multi-detail/v', { book_id: bookId }) + const response = await appGet('/bookapi/detail/v', { book_id: bookId }) const j: any = response.json() console.log('Book Info:', j) - if ( - typeof j === 'object' && - j !== null && - 'data' in j && - Array.isArray(j.data) && - j.data.length > 0 - ) return j.data[0] - return null + return j.data } +export async function multiDetailRaw() {} + export async function getBookInfo(bookId: string): Promise { const bookInfo = await getBookInfoRaw(bookId) if (!bookInfo) { diff --git a/src/api/catalog.ts b/src/api/catalog.ts index 86197e0..f7ef1ff 100644 --- a/src/api/catalog.ts +++ b/src/api/catalog.ts @@ -1,5 +1,6 @@ import type { Book, CatalogResult, ChapterItem, VolumeItem } from '../types' import { appGet } from './app' +import apiFetch from '../utils/request' import moment from 'moment' /** @@ -8,7 +9,7 @@ import moment from 'moment' * 章节明细在 data.item_data_list(含 volume_name / title / first_pass_time / * chapter_word_number);data.catalog_data 只有 item_id 和标题,信息不全。 */ -export async function getCatalogRaw(bookId: string): Promise { +export async function getCatalogRaw(bookId: string): Promise> { const response = await appGet('/bookapi/directory/all_items/v', { book_id: bookId }) const j: any = response.json() const items = j?.data?.item_data_list @@ -18,20 +19,56 @@ export async function getCatalogRaw(bookId: string): Promise { return [items, items.map((it: any) => String(it.item_id))] } +export async function webCatalog(bookId: string): Promise> { + // https://fanqienovel.com/api/reader/directory/detail?bookId=书号 + const url = `https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId}` + const response = await apiFetch(url) + const rj = response.json() as any + const d = rj.data + const allItems = d.allItemIds + /* + { + "itemId": "7513108127756075582", + "needPay": 0, + "title": "第37章 原来应先生这么强啊?", + "isChapterLock": true, + "isPaidPublication": false, + "isPaidStory": false, + "volume_name": "第一卷:默认", + "realChapterOrder": "37", + "firstPassTime": "1749281928" + }, + */ + const volmap: Record> = {} + const vname: string[] = d.volumeNameList + for (let i = 0; i < vname.length; i++) { + const volumeName = vname[i] + if (volumeName !== undefined) { + volmap[volumeName] = d.chapterListWithVolume[i] + } + } + return [volmap, allItems] +} + export async function getCatalog(bookId: string): Promise { - const r = await getCatalogRaw(bookId) as Array - const catalogRaw = r[0] as any[] // item_data_list - const allItemIds = r[1] as string[] // all item ids + const r = await getCatalogRaw(bookId) + let catalogRaw = r[0] as any[] // item_data_list + let allItemIds = r[1] as string[] // all item ids + if (!catalogRaw || !allItemIds) { + const rw = await webCatalog(bookId) + catalogRaw = rw[0] + allItemIds = rw[1] + } const vmap: Record = {} // title -> VolumeItem const chapters: Array = [] catalogRaw.forEach((item: any): void => { const volumeName = item.volume_name ?? '' const chapterItem = { - item_id: String(item.item_id), + item_id: String(item.item_id || item.itemId), title: item.title, // YYYY-MM-DD HH:mm:ss - update_time: moment(item.first_pass_time * 1000).format('YYYY-MM-DD HH:mm:ss') as string, + update_time: moment((item.first_pass_time || item.firstPassTime) * 1000).format('YYYY-MM-DD HH:mm:ss') as string, char_count: item.chapter_word_number || 0, volume_title: volumeName, } as ChapterItem diff --git a/src/api/content.ts b/src/api/content.ts index 1a4e307..e635a8c 100644 --- a/src/api/content.ts +++ b/src/api/content.ts @@ -4,6 +4,7 @@ import { b64decode, b64encode } from '../crypto' import { read, write } from '../localStorage' import { decryptChapter } from '../crypto/content' import { encryptKeyinfoBody, decryptKeyinfoResponse } from '../crypto/registerkey' +import { sleep } from '../utils' async function refreshKeyinfo(): Promise { @@ -27,6 +28,24 @@ async function refreshKeyinfo(): Promise { }) // cache key info } +/** 正在进行的注册。并发调用共享同一次,避免彼此覆盖刚拿到的密钥 */ +let refreshInflight: Promise | null = null + +/** + * 强制重新注册密钥,绕过本地缓存。 + * + * 设备长时间没用过,服务端的密钥注册会失效:此时 keyver 往往还和本地一致, + * 但正文一律返回 'Invalid'。这种情况只能重新注册,读缓存没有意义。 + */ +export function refreshKey(): Promise { + if (!refreshInflight) { + refreshInflight = refreshKeyinfo().finally(() => { + refreshInflight = null + }) + } + return refreshInflight +} + async function ensureKeyinfo(expectedKeyVersion?: number): Promise { const keyinfo = config.currentConfig.key_info const cachedKeyInfo = read('keyinfo') @@ -43,11 +62,11 @@ async function ensureKeyinfo(expectedKeyVersion?: number): Promise { } if (!keyinfo) { // get from zero - return await refreshKeyinfo() + return await refreshKey() } if (keyinfo?.keyver !== expectedKeyVersion) { // refresh from server - return await refreshKeyinfo() + return await refreshKey() } } @@ -68,7 +87,13 @@ export async function getChapter(itemId: string, _retry?: number): Promise } if (j?.content === 'Invalid' || j?.key_version !== config.currentConfig.key_info?.keyver) { // keyreg expired console.warn('Key reg expired, regster again and retrying...') - await ensureKeyinfo(parseInt(j?.key_version)) + // content 是 'Invalid' 时说明注册本身失效了,keyver 可能还和本地一样, + // 这时候走 ensureKeyinfo 会命中缓存直接返回,密钥还是坏的,必须强制重注册 + if (j?.content === 'Invalid') { + await refreshKey() + } else { + await ensureKeyinfo(parseInt(j?.key_version)) + } return await getChapter(itemId, _retry + 1) } j.content = await decryptChapter(j?.content, j, config.currentConfig) @@ -90,6 +115,7 @@ export interface BatchChapter { export async function getChapters( itemIds: string[], bookId = '0', + _retry = 0, ): Promise> { if (itemIds.length === 0) return {} if (!config.currentConfig.key_info) { @@ -102,7 +128,7 @@ export async function getChapters( novel_text_type: '1', req_type: '1', }) - + const raw = res.json()?.data const entries: Array<[string, any]> = raw && typeof raw === 'object' ? (Array.isArray(raw) @@ -114,6 +140,33 @@ export async function getChapters( throw new Error(`Failed to batch get chapters: ${res.responseText}`) } + // 密钥失效的表现:content 是 'Invalid',或者 key_version 和本地不一致。 + // 设备闲置一段时间后服务端会撤销密钥注册,此时 keyver 常常还和本地相同, + // 只有正文变成 'Invalid',所以两种情况都要算上。 + // 单章的 getChapter 早就处理了这个,批量以前只是标记失败, + // 于是整本书的所有分片都会失败(重试也没用,密钥还是旧的)。 + const localKeyver = config.currentConfig.key_info?.keyver + const expired = entries.filter(([, item]) => + item?.code === 0 || item?.code === undefined + ? item?.content === 'Invalid' || + (item?.key_version !== undefined && Number(item.key_version) !== localKeyver) + : false + ) + + if (expired.length > 0 && _retry < 2) { + const [, sample] = expired[0]! + console.warn( + `[fqa:api] 批量正文密钥失效(${expired.length}/${entries.length} 章),` + + `重新注册后重试。本地 keyver=${localKeyver},服务端=${sample?.key_version}` + ) + // 强制重新注册:keyver 可能没变,走 ensureKeyinfo 会命中缓存直接返回 + await refreshKey() + // 背靠背的请求会被限流成每次只回 1 章,重试前先等一下, + // 否则密钥虽然修好了,这一批还是只能拿回一章 + await sleep(800) + return await getChapters(itemIds, bookId, _retry + 1) + } + const results: Record = {} for (const [id, item] of entries) { if (!id) continue diff --git a/src/api/history.ts b/src/api/history.ts new file mode 100644 index 0000000..82ff01c --- /dev/null +++ b/src/api/history.ts @@ -0,0 +1,12 @@ +// 阅读历史(未实现)。 +// +// APP 端接口,抓包示例: +// https://api5-normal-sinfonlineb.fqnovel.com/reading/bookapi/read_history/list/v +// ?book_type=0&limit=0&is_first_load=false&offset=1482 +// &last_min_read_timestamp_ms=0&full_field=false +// + 常规设备参数(iid / device_id / aid=1967 / app_name=novelapp / version_code …) +// +// TODO: 走 appGet('/bookapi/read_history/list/v', …),分页参数是 offset + limit, +// 返回结构待抓包核对后再补类型。 + +export function getHistory() {} diff --git a/src/assets/default.css b/src/assets/default.css index a039492..7576464 100644 --- a/src/assets/default.css +++ b/src/assets/default.css @@ -2,6 +2,10 @@ * 番茄小说APP内置CSS * 版权归属为番茄小说APP官方 * 修改以兼容网页环境 + * + * line-space / theme-color 是 APP 排版引擎的私有属性,浏览器和 EPUB 阅读器都不认, + * 已注释保留以便和原始样式对照。theme-color 的值 color1#0.7 还会让 CSS 解析器跑偏, + * 把后面几条声明一起吃掉,必须注释而不能留着。 */ html{ @@ -30,7 +34,7 @@ h1{ font-weight:bold; margin:22px 0em 3em 0em; text-align:left; - line-space:0.5em; + /* line-space:0.5em; */ } h2{ @@ -57,15 +61,15 @@ sup{ text-indent:0em; text-align:center; margin:0em 0em 0em 0em; - line-space:0em; + /* line-space:0em; */ } .pictureDesc{ font-size:0.73em; text-indent:0em; margin:0.4em 0em 1em 0em; - theme-color:color1#0.7; - line-space:0.3em; + /* theme-color:color1#0.7; */ + /* line-space:0.3em; */ text-align:left; } @@ -73,9 +77,9 @@ sup{ font-size:0.73em; text-indent:0em; margin:1em 0em 0.3em 0em; - theme-color:color1#0.7; + /* theme-color:color1#0.7; */ text-align:left; - line-space:0em; + /* line-space:0em; */ } .collectTitle{ @@ -107,7 +111,7 @@ sup{ font-size:1em; margin:1.5em 0em 1.5em 2em; font-family:'FZShengShiKaiShuS-M-GB'; - line-space:0.5em; + /* line-space:0.5em; */ } .quoteStyle1{ @@ -124,7 +128,7 @@ sup{ margin:0.6em 0em 1.5em 32px; font-family:'FZShengShiKaiShuS-M-GB'; text-align:right; - line-space:0.5em; + /* line-space:0.5em; */ } .chapterTitle1{ display:block; @@ -132,7 +136,7 @@ sup{ font-weight:bold; margin:22px 0em 3em 0em; text-align:left; - line-space:0.5em; + /* line-space:0.5em; */ } .chapterTitle2{ diff --git a/src/assets/download.css b/src/assets/download.css new file mode 100644 index 0000000..4b1787d --- /dev/null +++ b/src/assets/download.css @@ -0,0 +1,167 @@ +/* 下载进度弹窗 */ + +.fqa-dl-mask { + position: fixed; + inset: 0; + z-index: 2147483300; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, + sans-serif; + font-size: 14px; + line-height: 1.6; +} + +.fqa-dl-box { + --fqa-dl-bg: #fff; + --fqa-dl-text: #1f2329; + --fqa-dl-sub: #8f959e; + --fqa-dl-border: rgba(31, 35, 41, 0.1); + --fqa-dl-accent: #ff6f3d; + --fqa-dl-track: rgba(31, 35, 41, 0.08); + --fqa-dl-danger: #f53f3f; + + box-sizing: border-box; + width: min(420px, 90vw); + padding: 22px 24px 18px; + background: var(--fqa-dl-bg); + color: var(--fqa-dl-text); + border-radius: 12px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.24); +} + +.fqa-dl-title { + margin: 0 0 4px; + font-size: 16px; + font-weight: 600; +} + +/* 正在处理的章节名,长标题截断 */ +.fqa-dl-sub { + margin: 0 0 14px; + min-height: 20px; + color: var(--fqa-dl-sub); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fqa-dl-bar { + height: 8px; + border-radius: 4px; + background: var(--fqa-dl-track); + overflow: hidden; +} + +.fqa-dl-fill { + height: 100%; + border-radius: 4px; + background: var(--fqa-dl-accent); + transition: width 0.2s ease; +} + +/* 总量未知时的循环动画 */ +.fqa-dl-fill-indeterminate { + width: 35% !important; + animation: fqa-dl-slide 1.1s ease-in-out infinite; +} + +@keyframes fqa-dl-slide { + 0% { + margin-left: -35%; + } + 100% { + margin-left: 100%; + } +} + +.fqa-dl-meta { + display: flex; + justify-content: space-between; + margin-top: 8px; + color: var(--fqa-dl-sub); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.fqa-dl-error { + margin: 12px 0 0; + padding: 8px 10px; + border-radius: 6px; + background: rgba(245, 63, 63, 0.1); + color: var(--fqa-dl-danger); + font-size: 12px; + word-break: break-all; +} + +.fqa-dl-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; +} + +.fqa-dl-btn { + padding: 7px 18px; + border: 1px solid var(--fqa-dl-border); + border-radius: 6px; + background: var(--fqa-dl-bg); + color: var(--fqa-dl-text); + font-size: 13px; + font-family: inherit; + cursor: pointer; +} + +.fqa-dl-btn:hover { + border-color: var(--fqa-dl-accent); + color: var(--fqa-dl-accent); +} + +.fqa-dl-btn:disabled { + color: var(--fqa-dl-sub); + border-color: var(--fqa-dl-border); + cursor: not-allowed; +} + +.fqa-dl-btn-primary { + border-color: var(--fqa-dl-accent); + background: var(--fqa-dl-accent); + color: #fff; +} + +.fqa-dl-btn-primary:hover { + background: #ff8a5f; + border-color: #ff8a5f; + color: #fff; +} + +/** + * 详情页注入的下载按钮。 + * + * 原站 .info-btn / .add-bookshelf-btn 自带 `position:absolute; bottom:0` 和固定 + * 尺寸,但这两个 class 同时带页面行为(会被站点脚本当成自己的按钮),所以不能照抄。 + * 这里复刻它们的尺寸,水平位置由脚本按前面按钮的实际宽度算出来。 + */ +.fqa-dl-entry { + position: absolute; + bottom: 0; + width: 150px; + height: 40px; + line-height: 40px; + padding: 0; + border-radius: 4px; + font-size: 16px; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + .fqa-dl-box { + --fqa-dl-bg: #23272e; + --fqa-dl-text: #e5e6eb; + --fqa-dl-border: rgba(255, 255, 255, 0.12); + --fqa-dl-track: rgba(255, 255, 255, 0.12); + } +} diff --git a/src/assets/script.css b/src/assets/script.css index 517e877..6de5757 100644 --- a/src/assets/script.css +++ b/src/assets/script.css @@ -229,4 +229,8 @@ --fqa-menu-hover: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.1); } +} + +.info { + width: 100%!important; } \ No newline at end of file diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 605ee90..5e163f3 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -59,10 +59,6 @@ export function hex(buffer: ArrayBuffer): string { return hexString; } -/** - * PKCS#7 填充。注意 WebCrypto 的 AES-CBC 会自动补 PKCS#7, - * 只有在需要手动分块(如 Simon/Speck)时才要调用本函数。 - */ export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array { const padLength = blockSize - (data.length % blockSize); const padded = new Uint8Array(data.length + padLength); diff --git a/src/download/chapters.ts b/src/download/chapters.ts new file mode 100644 index 0000000..4d54394 --- /dev/null +++ b/src/download/chapters.ts @@ -0,0 +1,112 @@ +// 全书正文抓取。 +// +// 关于批量接口的实测结论(reader/batch_full/v): +// - 一次请求无论传多少 item_id,最多只有 **前 30 个** 会带正文, +// 其余虽然出现在 data 里但 code=500、没有 content。所以分片固定 30。 +// - 有明显的时间窗限流:两次请求间隔太短时,后一次只返回 1 条正文。 +// 并发更糟,6 个并发里通常只有 1 个成功。所以这里串行 + 最小间隔, +// 不做并发。间隔 750ms 实测稳定(约 22 章/秒的吞吐)。 +// - 被限流的响应不报错,只是 content 缺失,所以要按 item_id 逐个核对, +// 把缺的收集起来重试,而不能只看 HTTP 状态。 + +import { getChapters, type BatchChapter } from '../api/content' +import { chunk, sleep } from '../utils' +import { settings } from '../settings' +import { CancelledError, DownloadTask } from './task' + +/** 单次请求真正会返回正文的章节数,服务端硬限制 */ +export const MAX_BATCH_SIZE = 30 + +export interface FetchChaptersOptions { + /** 每批章节数,默认取设置值,上限 MAX_BATCH_SIZE */ + batchSize?: number + /** 两批之间的最小间隔(ms),默认取设置值 */ + interval?: number + /** 整批失败后的重试轮数,默认取设置值 */ + retries?: number + /** 用于日志与进度副标题的章节标题 */ + titleOf?: (itemId: string) => string | undefined +} + +export interface FetchChaptersResult { + /** item_id -> 章节数据(含解密后的 content) */ + chapters: Record + /** 重试后仍然拿不到正文的 item_id */ + failed: string[] +} + +function hasContent(chapter: BatchChapter | undefined): boolean { + return Boolean(chapter?.content) && chapter!.content !== 'Invalid' +} + +/** + * 按 item_id 列表抓取全部正文。 + * + * 进度以「已拿到正文的章节数」计,重试不会让进度回退。 + */ +export async function fetchChapters( + itemIds: string[], + bookId: string, + task: DownloadTask, + options: FetchChaptersOptions = {}, +): Promise { + const batchSize = Math.min( + Math.max(1, options.batchSize ?? settings.downloadBatchSize), + MAX_BATCH_SIZE, + ) + const interval = Math.max(0, options.interval ?? settings.downloadInterval) + const retries = Math.max(0, options.retries ?? settings.downloadRetries) + + const chapters: Record = {} + let pending = [...itemIds] + + task.stage('缓存章节…', itemIds.length) + + for (let round = 0; round <= retries && pending.length > 0; round++) { + if (round > 0) { + console.warn(`[fqa:download] 第 ${round} 轮重试,剩余 ${pending.length} 章`) + task.setSubtitle(`重试 ${pending.length} 章(第 ${round} 轮)`) + // 上一轮很可能是被限流打断的,重试前多等一会儿 + await sleep(Math.max(interval, 1000)) + } + + const missed: string[] = [] + const batches = chunk(pending, batchSize) + + for (let i = 0; i < batches.length; i++) { + task.throwIfCancelled() + const batch = batches[i]! + + try { + const result = await getChapters(batch, bookId) + for (const itemId of batch) { + const chapter = result[itemId] + if (hasContent(chapter)) { + chapters[itemId] = chapter! + } else { + missed.push(itemId) + } + } + } catch (err) { + if (err instanceof CancelledError) throw err + console.error('[fqa:download] 批量获取失败:', err) + missed.push(...batch) + } + + const got = Object.keys(chapters).length + const lastTitle = options.titleOf?.(batch[batch.length - 1]!) ?? '' + task.update(got, lastTitle) + + // 最后一批不用等 + if (i < batches.length - 1 && interval > 0) await sleep(interval) + } + + pending = missed + } + + if (pending.length > 0) { + console.warn(`[fqa:download] ${pending.length} 章最终失败:`, pending.slice(0, 20)) + } + + return { chapters, failed: pending } +} diff --git a/src/download/content.ts b/src/download/content.ts new file mode 100644 index 0000000..32cff4d --- /dev/null +++ b/src/download/content.ts @@ -0,0 +1,136 @@ +// 章节正文(XHTML)的解析与拆分。 +// +// APP 接口下发的正文是一份完整 XHTML 文档,长这样: +// +// +// +//

+//

…章节标题…

+//

正文段落

+// +// +// +// 注意它和 web.html 处理的 PyFQWeb 格式不一样——那边是
包裹的片段, +// 所以原实现的 `div.innerHTML = content` + 收集文本节点在这里会出问题: +// 这些标签在片段解析里被忽略,但 + + + ${escapeXml(this.t('cover'))} + +` + } + + private generateVolumePageXhtml(volume: EpubVolume, volIdx: number): string { + let cssLinks = '' + if (this.cssFiles.has(0)) { + cssLinks += ` \n` + } + + let bodyContent = `

${escapeXml(volume.title)}

\n` + // blank 类型只保留卷名 + if (volume.options.volumePageType === 'navigator') { + const chapters = volume.sortedChapters() + if (chapters.length > 0) { + bodyContent += `

${escapeXml(this.t('chapters'))}

\n \n` + } + } + + return ` + + + ${escapeXml(volume.title)} + +${cssLinks} + +${bodyContent} +` + } + + /** 给 head 补 title / viewport / 样式引用,并按需插入正文标题 */ + private decorateChapterDoc(doc: Document, chapter: EpubChapter): void { + let head = doc.querySelector('head') + if (!head) { + head = doc.createElement('head') + doc.documentElement.insertBefore(head, doc.body) + } + + if (!head.querySelector('title')) { + const titleElement = doc.createElement('title') + titleElement.textContent = chapter.title + head.insertBefore(titleElement, head.firstChild) + } + + if (!head.querySelector('meta[name="viewport"]')) { + const viewportMeta = doc.createElement('meta') + viewportMeta.setAttribute('name', 'viewport') + viewportMeta.setAttribute('content', 'width=device-width, height=device-height, initial-scale=1.0') + head.appendChild(viewportMeta) + } + + const linkCss = (idx: number) => { + const href = `../Styles/style${idx}.css` + if (!this.cssFiles.has(idx) || head!.querySelector(`link[href="${href}"]`)) return + const cssLink = doc.createElement('link') + cssLink.setAttribute('rel', 'stylesheet') + cssLink.setAttribute('type', 'text/css') + cssLink.setAttribute('href', href) + head!.appendChild(cssLink) + } + if (chapter.useGlobalCSS) linkCss(0) + for (const cssIdx of chapter.cssIdxs) linkCss(cssIdx) + + const body = doc.querySelector('body') + if (body && chapter.insertTitle !== false) { + const hasHeading = Boolean(body.querySelector('h1, h2, h3, h4, h5, h6')) + // 番茄正文自带

,自动模式下不再插入,避免标题重复 + if (chapter.insertTitle === true || !hasHeading) { + const chapterHeading = doc.createElement('h2') + chapterHeading.textContent = chapter.title + body.insertBefore(chapterHeading, body.firstChild) + } + } + } + + private static finalizeXhtml(serialized: string): string { + let result = serialized + if (!result.startsWith('\n' + result + } + // 命名空间只能出现一次。正文本身带 xmlns 又走了 HTML 解析器时, + // 序列化会再补一个,重复的 xmlns 属性是非法 XML,严格的阅读器会拒绝打开 + result = result.replace(/]*>/i, tag => { + let seen = false + const deduped = tag.replace(/\s+xmlns="[^"]*"/g, match => { + if (seen) return '' + seen = true + return match + }) + return seen + ? deduped + : deduped.replace(/^ 或 + *

开头,症状就是所有段落跑进标题里)。 + */ + private generateHtmlChapter(chapter: EpubChapter): string { + const isDocument = /]/i.test(chapter.content) + // 包装用的 不要写 xmlns:HTML 解析器本来就会把文档放进 XHTML + // 命名空间,序列化时自动补一个,字面量再写一遍就成了重复属性 + const source = isDocument + ? chapter.content + : `${chapter.content}` + + try { + const parser = new DOMParser() + const xdoc = parser.parseFromString(source, 'application/xhtml+xml') + // 解析失败时退回 HTML 解析器,它容错更强 + const doc = xdoc.querySelector('parsererror') + ? parser.parseFromString(source, 'text/html') + : xdoc + + this.decorateChapterDoc(doc, chapter) + + // 只序列化根元素,避免把 XML 声明/DOCTYPE 重复写进去 + const serialized = new XMLSerializer().serializeToString(doc.documentElement) + // 不做重新缩进:formatXML 会在行内元素之间插入换行, + // 渲染时变成多余空格,正文里的 /
会被改变观感 + return EpubSaver.finalizeXhtml(serialized) + } catch (error) { + console.warn('Failed to parse XHTML content:', error) + return chapter.content + } + } + + private generateChapterXhtml(chapter: EpubChapter): string { + if (chapter.type === 'text') { + let cssLinks = '' + if (chapter.useGlobalCSS && this.cssFiles.has(0)) { + cssLinks += ` \n` + } + for (const cssIdx of chapter.cssIdxs) { + if (cssIdx !== 0 && this.cssFiles.has(cssIdx)) { + cssLinks += ` \n` + } + } + + const bodyContent = `

${escapeXml(chapter.content) + .replace(/\n\n/g, '

\n

') + .replace(/\n/g, '
')}

` + + return ` + + + ${escapeXml(chapter.title)} + +${cssLinks} + +

${escapeXml(chapter.title)}

+${bodyContent} + +` + } + + return this.generateHtmlChapter(chapter) + } + + /** + * 打包,返回 EPUB 字节。 + * + * 章节 XHTML 是在这里生成的(每章一次 DOMParser + XMLSerializer), + * 长篇小说上千章就是几十秒的同步计算。全部一口气跑完的话主线程被占死: + * 界面停在 0% 不动,取消按钮的点击事件也排不进事件循环。 + * 所以按批生成、批间让出主线程,并在批之间检查取消。 + */ + async save(options: SaveOptions = {}): Promise { + const { onWrite, onCompress, checkCancel } = options + /** 每批生成多少个章节文件后让出一帧 */ + const WRITE_BATCH = 20 + + // mimetype 必须是第一个且不压缩 + this.zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' }) + + this.zip.folder('META-INF') + this.zip.folder('OEBPS') + this.zip.folder('OEBPS/Text') + this.zip.folder('OEBPS/Styles') + this.zip.folder('OEBPS/Images') + + const opts: JSZip.JSZipFileOptions = { + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + } + + this.zip.file('META-INF/container.xml', this.generateContainer(), opts) + const tMeta = Date.now() + this.zip.file('OEBPS/content.opf', this.generateContentOpf(), opts) + this.zip.file('OEBPS/toc.ncx', this.generateTocNcx(), opts) + this.zip.file('OEBPS/nav.xhtml', this.generateNavXhtml(), opts) + console.log(`[epub] 目录/清单生成完毕,用时 ${Date.now() - tMeta}ms`) + + if (this.coverBuffer) { + this.zip.file(`OEBPS/Images/cover.${this.coverExtension}`, this.coverBuffer, opts) + this.zip.file('OEBPS/Text/cover.xhtml', this.generateCoverXhtml(), opts) + } + + for (const [filename, imageData] of this.images) { + this.zip.file(`OEBPS/Images/${filename}`, imageData.buffer, opts) + } + + for (const [idx, cssData] of this.cssFiles) { + this.zip.file(`OEBPS/Styles/style${idx}.css`, cssData.content, opts) + } + + for (const [path, content] of this.cssMap) { + this.zip.file(`OEBPS/Styles/${path}`, content, opts) + } + + const total = Array.from(this.volumes.values()) + .reduce((sum, v) => sum + v.size + (v.options.createVolumePage ? 1 : 0), 0) + let written = 0 + onWrite?.(0, total) + console.log(`[epub] 开始写入 ${total} 个章节文件`) + + for (const [volIdx, volume] of this.sortedVolumes()) { + if (volume.options.createVolumePage) { + this.zip.file( + `OEBPS/Text/volume${volIdx}_index.xhtml`, + this.generateVolumePageXhtml(volume, volIdx), + opts + ) + written++ + } + for (const [chapIdx, chapter] of volume.sortedChapters()) { + try { + this.zip.file( + `OEBPS/Text/volume${volIdx}_chapter${chapIdx}.xhtml`, + this.generateChapterXhtml(chapter), + opts + ) + } catch (err) { + // 单章生成失败不该让整本书打包不出来 + console.error( + `[epub] 第 ${chapIdx} 章「${chapter.title}」生成失败,用占位内容替代:`, err + ) + this.zip.file( + `OEBPS/Text/volume${volIdx}_chapter${chapIdx}.xhtml`, + `\n${escapeXml(chapter.title)}

${escapeXml(chapter.title)}

本章生成失败

`, + opts + ) + } + written++ + if (written % WRITE_BATCH === 0) { + onWrite?.(written, total) + await yieldFrame() + // 让出之后才有机会看到用户的取消 + checkCancel?.() + } + } + } + onWrite?.(written, total) + checkCancel?.() + + console.log(`[epub] 章节写入完成 ${written}/${total},条目总数 ${Object.keys(this.zip.files).length},开始压缩`) + + // 压缩。 + // + // JSZip 的异步调度全部走 utils.delay -> setImmediate,而它的 setImmediate + // 是自带 polyfill(优先 window.postMessage + message 监听)。StreamHelper + // 的 resume() 本身也经过 delay,所以只要这套调度在页面环境里失灵, + // 流就永远不会启动:没有 data、没有 end、没有 error,静默卡死。 + // + // 因此这里不依赖流式接口:用 generateAsync(同样受 delay 影响,但只需要 + // 一次调度即可返回 Promise),并加超时兜底 —— 真卡住时给出明确报错, + // 而不是让用户对着不动的进度条。 + const COMPRESS_TIMEOUT = 120_000 + const t0 = Date.now() + let lastPercent = -1 + /** 压缩途中收到的取消。generateAsync 不能中断,只能在收尾时抛出 */ + let cancelled: unknown = null + + const compressing = this.zip.generateAsync({ type: 'uint8array' }, metadata => { + if (lastPercent < 0) { + console.log(`[epub] 压缩开始,首次回调用时 ${Date.now() - t0}ms`) + } + lastPercent = metadata.percent + // generateAsync 没法中途中断,这里只负责把取消变成一次 reject, + // 压缩会在后台跑完再被丢弃 + try { + checkCancel?.() + } catch (err) { + cancelled = err + } + try { + onCompress?.(metadata.percent, metadata.currentFile) + } catch (err) { + // 进度回调出错不该拖垮打包 + console.warn('[epub] 压缩进度回调异常:', err) + } + }) + + let timer: ReturnType | undefined + const guard = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error( + `压缩超时(${COMPRESS_TIMEOUT / 1000}s 无进展,最后进度 ` + + `${lastPercent < 0 ? '未开始' : lastPercent.toFixed(1) + '%'})。` + + 'JSZip 的异步调度可能被页面环境干扰,请把控制台日志反馈给作者。' + )) + }, COMPRESS_TIMEOUT) + }) + + try { + const out = await Promise.race([compressing, guard]) + // 压缩期间用户点了取消,产物直接丢弃 + if (cancelled) throw cancelled + console.log(`[epub] 压缩完成:${out.length} 字节,用时 ${Date.now() - t0}ms`) + // 转成独立的 ArrayBuffer,避免把整个 zip 的内部缓冲一起留住 + return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength) as ArrayBuffer + } finally { + if (timer) clearTimeout(timer) + } + } +} + +export class EpubVolume { + readonly idx: number + readonly title: string + readonly options: ResolvedVolumeOptions + private readonly saver: EpubSaver + private readonly chapters = new Map() + + constructor( + idx: number, + title: string, + saver: EpubSaver, + options: EpubVolumeOptions = {}, + ) { + this.idx = idx + this.title = title + this.saver = saver + this.options = { + alwaysShowVolumeTitle: false, + createVolumePage: false, + volumePageType: 'navigator', + ...options, + } + } + + get size(): number { + return this.chapters.size + } + + sortedChapters(): Array<[number, EpubChapter]> { + return Array.from(this.chapters.entries()).sort(([a], [b]) => a - b) + } + + async addChapter( + idx: number, + title: string, + content: string, + type: ChapterContentType = 'text', + useGlobalCSS = false, + cssIdxs: number[] = [], + insertTitle?: boolean, + ): Promise { + let processedContent = content + if (type === 'html' || type === 'xhtml') { + processedContent = await this.saver.processCSSLinksInContent(content) + processedContent = await this.saver.downloadImagesFromContent(processedContent) + } + + this.chapters.set(idx, { + title, + content: processedContent, + type, + useGlobalCSS, + cssIdxs, + insertTitle, + }) + } +} + +export default EpubSaver diff --git a/src/fontDecrypt.ts b/src/fontDecrypt.ts index 12a9909..9765a98 100644 --- a/src/fontDecrypt.ts +++ b/src/fontDecrypt.ts @@ -1,39 +1,117 @@ import { watch } from 'vue' import { settings } from './settings' -const enTag = '.font-DNMrHsV173Pd4pgy' // 这个目前从存在解密算法到现在没改过。暂且认为它在加解密算法不变时长时不变。 +// 加密区间。两套字体共用同一段私有区码点,只是映射表不同 const code_ed = 58715 const code_st = 58344 -const mapping = [ - "D", "在", "主", "特", "家", "军", "然", "表", "场", "4", "要", "只", "v", "和", "?", "6", "别", "还", "g", - "现", "儿", "岁", "?", "?", "此", "象", "月", "3", "出", "战", "工", "相", "o", "男", "直", "失", "世", "F", - "都", "平", "文", "什", "V", "O", "将", "真", "T", "那", "当", "?", "会", "立", "些", "u", "是", "十", "张", - "学", "气", "大", "爱", "两", "命", "全", "后", "东", "性", "通", "被", "1", "它", "乐", "接", "而", "感", - "车", "山", "公", "了", "常", "以", "何", "可", "话", "先", "p", "i", "叫", "轻", "M", "士", "w", "着", "变", - "尔", "快", "l", "个", "说", "少", "色", "里", "安", "花", "远", "7", "难", "师", "放", "t", "报", "认", - "面", "道", "S", "?", "克", "地", "度", "I", "好", "机", "U", "民", "写", "把", "万", "同", "水", "新", "没", - "书", "电", "吃", "像", "斯", "5", "为", "y", "白", "几", "日", "教", "看", "但", "第", "加", "候", "作", - "上", "拉", "住", "有", "法", "r", "事", "应", "位", "利", "你", "声", "身", "国", "问", "马", "女", "他", - "Y", "比", "父", "x", "A", "H", "N", "s", "X", "边", "美", "对", "所", "金", "活", "回", "意", "到", "z", - "从", "j", "知", "又", "内", "因", "点", "Q", "三", "定", "8", "R", "b", "正", "或", "夫", "向", "德", "听", - "更", "?", "得", "告", "并", "本", "q", "过", "记", "L", "让", "打", "f", "人", "就", "者", "去", "原", "满", - "体", "做", "经", "K", "走", "如", "孩", "c", "G", "给", "使", "物", "?", "最", "笑", "部", "?", "员", "等", - "受", "k", "行", "一", "条", "果", "动", "光", "门", "头", "见", "往", "自", "解", "成", "处", "天", "能", - "于", "名", "其", "发", "总", "母", "的", "死", "手", "入", "路", "进", "心", "来", "h", "时", "力", "多", - "开", "已", "许", "d", "至", "由", "很", "界", "n", "小", "与", "Z", "想", "代", "么", "分", "生", "口", - "再", "妈", "望", "次", "西", "风", "种", "带", "J", "?", "实", "情", "才", "这", "?", "E", "我", "神", "格", - "长", "觉", "间", "年", "眼", "无", "不", "亲", "关", "结", "0", "友", "信", "下", "却", "重", "己", "老", - "2", "音", "字", "m", "呢", "明", "之", "前", "高", "P", "B", "目", "太", "e", "9", "起", "稜", "她", "也", - "W", "用", "方", "子", "英", "每", "理", "便", "四", "数", "期", "中", "C", "外", "样", "a", "海", "们", - "任", -] + +/** + * 码表。key 是字体 id,对应页面上的 class="font-"。 + * + * 每套表按 code_st..code_ed 顺序排列,共 372 个字符。'?' 表示该码点在字体里 + * 没有字形(编码区里的空位),不是「未知待补」——这些位置正文里不会出现, + * 命中时保持原样输出即可。番茄会不定期新增字体,加一套表就是加一个 key。 + */ +const mapping: Record = { + 'DNMrHsV173Pd4pgy': [ + 'D在主特家军然表场4要只v和?6别还g现儿岁??此象月3出战工相', + 'o男直失世F都平文什VO将真T那当?会立些u是十张学气大爱两命全', + '后东性通被1它乐接而感车山公了常以何可话先pi叫轻M士w着变尔快', + 'l个说少色里安花远7难师放t报认面道S?克地度I好机U民写把万同', + '水新没书电吃像斯5为y白几日教看但第加候作上拉住有法r事应位利你', + '声身国问马女他Y比父xAHNsX边美对所金活回意到z从j知又内因', + '点Q三定8Rb正或夫向德听更?得告并本q过记L让打f人就者去原满', + '体做经K走如孩cG给使物?最笑部?员等受k行一条果动光门头见往自', + '解成处天能于名其发总母的死手入路进心来h时力多开已许d至由很界n', + '小与Z想代么分生口再妈望次西风种带J?实情才这?E我神格长觉间年', + '眼无不亲关结0友信下却重己老2音字m呢明之前高PB目太e9起稜她', + '也W用方子英每理便四数期中C外样a海们任', + ], + 'fKts9tCXDjS49UhH': [ + '体y十现快使话却月物水的放知爱方?表风理O老也p常克平几最主她s', + '将法情o光a我呢J员太每望受教w利军已U人如变得要少斯门电m男没', + 'AK国时中走么何口小向问轻Td神下间车fG度D又大面远就写j给通', + '起实E?它去S到道数吃们加P是无把事西多界?发新外活解孩只作前Y', + '尔经?u心告父等Q民全这9果安?i母8r说任先和地C张战场g像c', + 'q你使?样总目x性处音头?应乐关能花I当名手4重字声力友然生代内', + '里本回真入师象?0点R亲V种动英命ZhX做特边高有B为期自年马认', + '出接至H正方感所明者棱F住学还分意更其n但比觉以由死家让失士L2', + 'I金叫身报听W再原山海白很见5直位第工个开岁好用都于可同3次四?', + '日信与女笑满并部什不从或机此?了记三e些bN夫会才几眼两美被一公', + '来立z长对己看k许因相色后往打结格过世气7子条在书之定v拉成进带', + '着东上想天他妈1文而路那别德6Mt行候难' + ], + '_search': [ + '?s?作口在他能并B士4U克才正们字声高全尔活者动其主报多望放h', + 'w次年?中3特于十入要男同G面分方K什再教本己结1等世N?说gu', + '期Z外美M行给9文将两许张友0英应向像此白安少何打气常定间花见孩', + '它直风数使道第水已女山解dP的通关性叫几L妈问回神来S?四里前国', + '些OvIA心平自无车光代是好却c得种就意先立z子过Yj表?么所接', + '了名金受J满眼没部那m每车度可R斯经现门明V如走命y6E战很上f', + '月西7长夫想话变海机x到W一成生信笑但父开内东马日小而后带以三几', + '为认X死员目位之学远入音呢我q乐象重对个被别F也书棱D写还因家发', + '时i或住德当oI比觉然吃去公a老亲情体太b方C电理?失力更拉物着', + '原她工实色感记看出相路大你候2和?与p样新只便最不进Tr做格母总', + '爱身师轻知往加从?天eH?听场由快边让把任8条头事至起点真手这难', + '都界用法n处下文Q告地5kt岁有会果利民' + ] +} + +/** 该码点在字体里没有字形(编码区空位),正文不会用到 */ +const NO_GLYPH = '?' + +/** 每套码表拍平成一个字符数组,按需构建后缓存 */ +const flatCache = new Map() + +function tableOf(fontId: string): string[] | null { + const cached = flatCache.get(fontId) + if (cached) return cached + + const rows = mapping[fontId] + if (!rows) return null + + // 用展开而不是索引:码表里有非 BMP 字符时 charAt 会把代理对切开 + const flat = [...rows.join('')] + const expected = code_ed - code_st + 1 + if (flat.length !== expected) { + // 长度不对说明码表抄漏了,整表偏移,宁可不解密也别输出错字 + console.error( + `[fqa:font] 码表 ${fontId} 长度异常:${flat.length},应为 ${expected},已禁用该字体的解密` + ) + flatCache.set(fontId, []) + return [] + } + flatCache.set(fontId, flat) + return flat +} + +/** 页面上的加密节点选择器,由已知的码表 id 拼出来 */ +const enTag = Object.keys(mapping) + .map(id => `.font-${id}`) + .join(', ') + +/** 从元素的 class 里找出它用的是哪套字体 */ +function fontIdOf(element: Element): string | null { + for (const cls of element.classList) { + if (!cls.startsWith('font-')) continue + const id = cls.slice(5) + if (mapping[id]) return id + } + return null +} /** * 解密文本 * @param text 解密前的文本 + * @param fontId 字体 id,决定用哪套码表 * @returns 解密后的文本 */ -export function decryptText(text: string): string { +export function decryptText(text: string, fontId: string): string { + // 搜索的时候 id 是 DNMrHsV173Pd4pgy ,实际是另一套码表,特殊处理一下 + if (window.location.pathname.startsWith('/search')) fontId = '_search' + const table = tableOf(fontId) + if (!table || table.length === 0) return text + let result = ""; let changed = false; for (const char of text) { @@ -45,8 +123,8 @@ export function decryptText(text: string): string { result += char; continue; } - const mapped = mapping[codePoint - code_st]; - if (mapped && mapped !== "?") { + const mapped = table[codePoint - code_st]; + if (mapped && mapped !== NO_GLYPH) { result += mapped; changed = true; } else { @@ -58,13 +136,26 @@ export function decryptText(text: string): string { /** * 解密一个 DOM 元素内的所有文本节点 - * @param element 要解密的 DOM 元素 + * @param element 要解密的 DOM 元素,需带 font- class */ export function decryptElement(element: Element) { if (!settings.decryptFont) return + const fontId = fontIdOf(element) + if (!fontId) return + const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, + { + // 后代里可能嵌着另一套字体的节点,那部分要用它自己的码表, + // 交给针对它的 decryptElement 处理,这里跳过整棵子树 + acceptNode(node) { + const owner = node.parentElement?.closest(enTag) + return owner && owner !== element + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT + }, + }, ); let textNode; while ((textNode = walker.nextNode())) { @@ -72,7 +163,7 @@ export function decryptElement(element: Element) { if (!oldText) { continue; } - const newText = decryptText(oldText); + const newText = decryptText(oldText, fontId); if (newText !== oldText) { textNode.nodeValue = newText; } @@ -82,6 +173,7 @@ export function decryptElement(element: Element) { function decryptPage(root: Document | Element): void { // 元素自身可能就是加密节点,其后代同样需要扫描: // MutationObserver 收到的新增节点通常是容器,加密节点在其内部。 + // enTag 是所有已知字体的选择器列表,matches / querySelectorAll 都支持逗号分隔。 if (root instanceof Element && root.matches(enTag)) { decryptElement(root); } diff --git a/src/hooks/downloadHook.ts b/src/hooks/downloadHook.ts new file mode 100644 index 0000000..27bac01 --- /dev/null +++ b/src/hooks/downloadHook.ts @@ -0,0 +1,159 @@ +// 书籍详情页的下载入口。 +// +// /page/ 上,登录后有「加入书架」按钮,未登录只有「开始阅读」。 +// 两种情况都在最后一个按钮后面插一个「下载」。页面是 SPA,按钮会被重新 +// 渲染,所以用 MutationObserver 持续补。 + +import type { HookConfig } from '../config' +import { startDownload } from '../download' +import { settings } from '../settings' +import { waitForElement } from '../utils' +import downloadcss from '../assets/download.css?raw' + +const STYLE_ID = 'fqa-download-style' +const ENTRY_CLASS = 'fqa-dl-entry' + +/** 优先跟在「加入书架」后面,没有就跟在「开始阅读」后面 */ +const ANCHOR_SELECTOR = `.add-bookshelf-btn:not(.${ENTRY_CLASS}), .info-btn:not(.${ENTRY_CLASS})` + +let observer: MutationObserver | null = null + +function injectStyle() { + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = downloadcss + document.head.appendChild(style) +} + +/** /page/7143038691944959011 -> 7143038691944959011 */ +function parseBookId(path: string): string { + return path.match(/^\/page\/(\d+)/)?.[1] ?? '' +} + +function isPagePath(path: string): boolean { + return /^\/page\/\d+/.test(path) +} + +/** + * 原站按钮上带行为/定位的类名,复制外观时要去掉: + * - add-bookshelf-btn / info-btn 会被页面脚本当成自己的按钮; + * - 而且这两个 class 自带 `position:absolute; bottom:0; left:0`(书架按钮是 + * left:160px),照抄会让我们的按钮压在「开始阅读」上面。定位由下面自己算。 + */ +const FUNCTIONAL_CLASSES = ['add-bookshelf-btn', 'info-btn'] + +/** 按钮之间的水平间距,和原站 info-btn(0) -> add-bookshelf-btn(160) 的 10px 间隔一致 */ +const BUTTON_GAP = 10 + +/** 复制原站按钮的外观类,视觉上和「加入书架」保持一致 */ +function createButton(anchor: HTMLElement, bookId: string): HTMLButtonElement { + const btn = document.createElement('button') + btn.type = 'button' + const classes = Array.from(anchor.classList).filter(c => !FUNCTIONAL_CLASSES.includes(c)) + btn.className = [...classes, ENTRY_CLASS].join(' ') + // 原按钮内部是 文字,照抄结构避免样式错位 + const span = document.createElement('span') + span.textContent = '下载' + btn.appendChild(span) + btn.addEventListener('click', event => { + event.preventDefault() + event.stopPropagation() + void startDownload(bookId, { format: settings.downloadFormat }) + }) + return btn +} + +/** + * 原站这排按钮是绝对定位的(info-btn 在 left:0,add-bookshelf-btn 在 left:160px), + * 所以我们的按钮也得绝对定位,并排在最右边那个之后。 + */ +function placeButton(btn: HTMLElement, siblings: HTMLElement[]): void { + let right = 0 + for (const el of siblings) { + if (el === btn) continue + right = Math.max(right, el.offsetLeft + el.offsetWidth) + } + const left = `${right + BUTTON_GAP}px` + // 值没变就别写:observer 监听的是整个 body,每次改样式都会再触发一轮回调 + if (btn.style.left === left) return + btn.style.position = 'absolute' + btn.style.bottom = '0' + btn.style.left = left +} + +function inject(bookId: string): void { + const anchors = Array.from(document.querySelectorAll(ANCHOR_SELECTOR)) + if (anchors.length === 0) return + + const existing = document.querySelector(`.${ENTRY_CLASS}`) + if (existing) { + // 「加入书架」是登录态就绪后才渲染的,出现后要重新算一次位置, + // 否则下载按钮会停在原来「开始阅读」右边、和书架按钮叠在一起 + placeButton(existing, anchors) + return + } + + // 多个按钮时挂在最后一个后面(加入书架在开始阅读之后) + const anchor = anchors[anchors.length - 1]! + if (!anchor.parentElement) return + + const btn = createButton(anchor, bookId) + anchor.insertAdjacentElement('afterend', btn) + placeButton(btn, anchors) +} + +function teardown(): void { + observer?.disconnect() + observer = null + document.querySelectorAll(`.${ENTRY_CLASS}`).forEach(el => el.remove()) +} + +async function mainHook(_previous?: string): Promise { + const path = window.location.pathname + if (!isPagePath(path) || !settings.enableDownload) { + teardown() + return + } + + const bookId = parseBookId(path) + if (!bookId) return + + injectStyle() + // 换书时旧按钮绑的是旧 bookId,先清掉 + teardown() + + await waitForElement(ANCHOR_SELECTOR, 8000) + if (window.location.pathname !== path) return + + inject(bookId) + + // 「加入书架」是登录态就绪后才渲染的,之后还要补一次 + observer = new MutationObserver(() => { + if (window.location.pathname !== path) return + inject(bookId) + }) + observer.observe(document.body, { childList: true, subtree: true }) +} + +function filter(path: string, _query: URLSearchParams, _hash: string): boolean { + // 已注入时也要放行,否则离开详情页没机会清理 + return isPagePath(path) || Boolean(observer) +} + +const _exports: HookConfig[] = [ + { + id: 'downloadHook_onload', + event: 'load', + filter, + handler: mainHook, + }, + { + id: 'downloadHook_onurlchange', + event: 'onUrlChange', + filter, + handler: mainHook, + }, +] + +export default _exports diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ec26b4b..4e7a558 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ import fetchHook from "./fetchHook"; import userHook from "./userHook"; import bookshelfHook from "./bookshelfHook"; import searchHook from "./searchHook"; +import downloadHook from "./downloadHook"; const hooks: HookConfig[] = [ ...readerHook, @@ -11,6 +12,7 @@ const hooks: HookConfig[] = [ ...userHook, ...bookshelfHook, ...searchHook, + ...downloadHook, ]; async function onEvent(event: HookEvent, previous?: string) { diff --git a/src/main.ts b/src/main.ts index cc184e2..e9c5485 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { onLoad, onUrlChange, onHashChange, onEnter } from './hooks' import { version, name } from '../package.json' import initUser from './api/user' import { ensureDevice } from './api/provision' +import { initDownloadPanel } from './downloadPanel' const win = unsafeWindow @@ -54,6 +55,9 @@ async function mainInit() { // 用户自定义样式(阅读器字体 / 自定义 CSS) initUserStyle() + // 下载进度弹窗随任务自动显示 + initDownloadPanel() + // APP 接口需要已注册的设备(首次会注册并激活会员,之后走本地缓存) await ensureDevice() diff --git a/src/settings.ts b/src/settings.ts index da32805..bf3c5c4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,6 +6,12 @@ const STORE_KEY = 'settings' /** API 偏好 */ export type ApiPreference = 'app' | 'redcandle' +/** 下载格式 */ +export type DownloadFormat = 'epub' | 'txt' + +/** TXT 编码 */ +export type DownloadCharset = 'utf-8' | 'gbk' + export interface Settings { /* --- 常规 --- */ /** 解密网页端混淆字体 */ @@ -29,6 +35,26 @@ export interface Settings { /** 搜索时携带登录态以获取个人化推荐 */ searchPersonalized: boolean + /* --- 下载 --- */ + /** 注入下载入口(详情页按钮、右键菜单) */ + enableDownload: boolean + /** 默认下载格式 */ + downloadFormat: DownloadFormat + /** TXT 编码。EPUB 固定 UTF-8 */ + downloadCharset: DownloadCharset + /** 每批请求的章节数,服务端上限 30 */ + downloadBatchSize: number + /** 两批之间的间隔(ms),太小会被限流 */ + downloadInterval: number + /** 失败章节的重试轮数 */ + downloadRetries: number + /** EPUB:为每卷生成独立的卷页 */ + downloadVolumePage: boolean + /** EPUB:下载正文插图(会明显变慢,且体积变大) */ + downloadImages: boolean + /** EPUB:保留书籍自带的排版样式(css_map) */ + downloadBookCss: boolean + /* --- 协议 --- */ apiPreference: ApiPreference /** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */ @@ -50,6 +76,18 @@ export const DEFAULT_SETTINGS: Settings = { // 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启 searchPersonalized: false, + enableDownload: true, + downloadFormat: 'epub', + downloadCharset: 'utf-8', + // 30 是接口单请求返回正文的上限,再大也只回 30 条 + downloadBatchSize: 30, + // 实测 750ms 能稳定拿满,更短会被限流成每次 1 条 + downloadInterval: 750, + downloadRetries: 3, + downloadVolumePage: false, + downloadImages: true, + downloadBookCss: true, + apiPreference: 'app', deviceId: '', installId: '', @@ -72,9 +110,25 @@ function normalize(raw: unknown): Settings { if (s.apiPreference !== 'app' && s.apiPreference !== 'redcandle') { s.apiPreference = DEFAULT_SETTINGS.apiPreference } + if (s.downloadFormat !== 'epub' && s.downloadFormat !== 'txt') { + s.downloadFormat = DEFAULT_SETTINGS.downloadFormat + } + if (s.downloadCharset !== 'utf-8' && s.downloadCharset !== 'gbk') { + s.downloadCharset = DEFAULT_SETTINGS.downloadCharset + } + // 数字项来自输入框,可能是 NaN 或越界值 + s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize) + s.downloadInterval = clampInt(s.downloadInterval, 0, 10_000, DEFAULT_SETTINGS.downloadInterval) + s.downloadRetries = clampInt(s.downloadRetries, 0, 10, DEFAULT_SETTINGS.downloadRetries) return s } +function clampInt(value: number, min: number, max: number, fallback: number): number { + const n = Math.round(Number(value)) + if (!Number.isFinite(n)) return fallback + return Math.min(max, Math.max(min, n)) +} + /** 全局设置对象。直接改字段即可,会自动持久化 */ export const settings = reactive(normalize(read(STORE_KEY))) diff --git a/src/utils/index.ts b/src/utils/index.ts index 4ec31fd..67ffdeb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,6 +2,26 @@ export async function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +/** + * 让出一帧,给浏览器重绘的机会。 + * + * 长时间同步计算(批量解析/序列化正文)中途要插几次,否则界面看起来是卡住的。 + * requestAnimationFrame 在后台标签页里不触发,所以加一个 setTimeout 兜底。 + */ +export function nextFrame(): Promise { + return new Promise(resolve => { + let settled = false + const done = () => { + if (settled) return + settled = true + resolve() + } + const raf = (unsafeWindow as Window & typeof globalThis).requestAnimationFrame + if (typeof raf === 'function') raf(() => done()) + setTimeout(done, 32) + }) +} + export function cloneElement(element: T): T { return element.cloneNode(true) as T; } diff --git a/src/utils/zipfix.ts b/src/utils/zipfix.ts new file mode 100644 index 0000000..26ecc25 --- /dev/null +++ b/src/utils/zipfix.ts @@ -0,0 +1,84 @@ +// JSZip 的异步调度修复。 +// +// JSZip 内部所有异步流转都走 utils.delay -> setImmediate,而它自带一个 +// setImmediate polyfill。这个 polyfill 优先用 window.postMessage 调度, +// 并在 message 处理里做身份校验: +// +// function d(e){ e.source === r && typeof e.data === 'string' && ... } +// +// 其中 r 是 polyfill 加载时捕获的全局对象。在 Tampermonkey 的沙箱里, +// 脚本看到的 window 是一个 Proxy,而真实 message 事件的 event.source 是 +// 原生 window —— `e.source === r` 永远为 false,回调永远不被派发。 +// 结果就是 generateAsync / generateInternalStream 静默卡死: +// 没有进度、没有 end、没有 error(实测日志里「开始压缩」之后再无输出)。 +// +// JSZip 由 @require 注入,加载早于本脚本,所以它的 polyfill 已经装好了, +// 不能只在「不存在时」补一个 —— 必须覆盖掉。好在 utils.delay 是在调用时 +// 才从作用域链上解析 setImmediate(`function(e,t,r){setImmediate(...)}`), +// 所以事后替换同样生效。 +// +// 这里用 MessageChannel 实现,它不依赖 window 身份校验;再退化到 setTimeout。 + +/** 标记已经装过,避免重复替换 */ +const FLAG = '__fqaSetImmediate' + +/** 装一个不依赖 window 身份的 setImmediate,覆盖 JSZip 自带的坏实现 */ +function installSetImmediate(target: Record): boolean { + if (target[FLAG]) return false + + type Task = { fn: (...args: unknown[]) => void; args: unknown[] } + const tasks = new Map() + let nextId = 1 + + const run = (id: number) => { + const task = tasks.get(id) + if (!task) return + tasks.delete(id) + try { + task.fn(...task.args) + } catch (err) { + // 保持和原生 setImmediate 一致:单个任务出错不影响后续调度 + console.error('[fqa:zipfix] setImmediate 任务异常:', err) + } + } + + let schedule: (id: number) => void + if (typeof MessageChannel === 'function') { + const channel = new MessageChannel() + channel.port1.onmessage = event => run(event.data as number) + schedule = id => channel.port2.postMessage(id) + } else { + // 兜底:setTimeout(0) 语义上比 setImmediate 慢,但功能正确 + schedule = id => void setTimeout(run, 0, id) + } + + target['setImmediate'] = (fn: (...args: unknown[]) => void, ...args: unknown[]): number => { + const id = nextId++ + tasks.set(id, { fn, args }) + schedule(id) + return id + } + target['clearImmediate'] = (id: number): void => void tasks.delete(id) + target[FLAG] = true + return true +} + +/** + * 修好 JSZip 的异步调度。必须在调用 zip.generateAsync 之前执行。 + * + * 同时装到沙箱全局和 unsafeWindow:JSZip 通过 @require 注入, + * 具体挂在哪个全局取决于脚本管理器,两边都装最稳妥。 + */ +export default function fixZipScheduler(): void { + const patched = installSetImmediate(globalThis as unknown as Record) + try { + const real = unsafeWindow as unknown as Record + if (real && real !== (globalThis as unknown as Record)) { + installSetImmediate(real) + } + } catch { + // 取不到 unsafeWindow 就算了,沙箱那份已经够用 + } + if (patched) console.debug('[fqa:zipfix] 已替换 setImmediate,修复 JSZip 异步调度') +} + diff --git a/src/views/BookshelfView.vue b/src/views/BookshelfView.vue index 62929c2..f3e9cbd 100644 --- a/src/views/BookshelfView.vue +++ b/src/views/BookshelfView.vue @@ -6,6 +6,8 @@ import BookHoverCard from './BookHoverCard.vue' import ContextMenu from './ContextMenu.vue' import { TABS, useBookshelf } from './useBookshelf' import { moveToGroup, removeFromBookshelf } from '../api/bookshelf' +import { startDownload } from '../download' +import { settings } from '../settings' import type { BookShelfEntry, BookShelfGroup, BookShelfTabKey, MenuItem } from '../types' /** 持续悬停多久后展示详情 */ @@ -266,6 +268,7 @@ function showToast(msg: string) { const MOVE_PREFIX = 'move:' const NO_GROUP_KEY = `${MOVE_PREFIX}` +const DOWNLOAD_PREFIX = 'download:' const menuItems = computed(() => { const entry = menuEntry.value @@ -279,7 +282,7 @@ const menuItems = computed(() => { // 已经在某个分组里时,额外给一个移出分组的选项 if (current) targets.push({ key: NO_GROUP_KEY, label: '无分组' }) - return [ + const items: MenuItem[] = [ { key: 'open', label: '打开' }, { key: 'detail', label: '查看详情' }, { @@ -287,9 +290,20 @@ const menuItems = computed(() => { label: '移动到分组', disabled: targets.length === 0, children: targets - }, - { key: 'remove', label: '从书架删除', danger: true } + } ] + if (settings.enableDownload) { + items.push({ + key: 'download', + label: '下载', + children: [ + { key: `${DOWNLOAD_PREFIX}epub`, label: 'EPUB' }, + { key: `${DOWNLOAD_PREFIX}txt`, label: 'TXT' } + ] + }) + } + items.push({ key: 'remove', label: '从书架删除', danger: true }) + return items }) function onCardContextMenu({ entry, x, y }: { entry: BookShelfEntry; x: number; y: number }) { @@ -312,6 +326,11 @@ async function onMenuSelect(key: string) { unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}` return } + if (key.startsWith(DOWNLOAD_PREFIX)) { + const format = key.slice(DOWNLOAD_PREFIX.length) as 'epub' | 'txt' + void startDownload(bookId, { format }) + return + } if (key === 'remove') { if (!unsafeWindow.confirm(`确定要把《${entry.detail?.title ?? bookId}》从书架删除吗?`)) return try { diff --git a/src/views/DownloadProgress.vue b/src/views/DownloadProgress.vue new file mode 100644 index 0000000..689ab28 --- /dev/null +++ b/src/views/DownloadProgress.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/views/SearchView.vue b/src/views/SearchView.vue index 7e414d6..b09488d 100644 --- a/src/views/SearchView.vue +++ b/src/views/SearchView.vue @@ -5,6 +5,7 @@ import SearchLanding from './SearchLanding.vue' import ContextMenu from './ContextMenu.vue' import { useSearch } from './useSearch' import { addToBookshelf } from '../api/bookshelf' +import { startDownload } from '../download' import { settings } from '../settings' import { userState } from '../api/user' import { pushQuery, routeQuery } from './searchRoute' @@ -36,6 +37,8 @@ const { const input = ref(routeQuery.value) const filterOpen = ref(false) +const DOWNLOAD_PREFIX = 'download:' + /** 无搜索词时展示落地页 */ const showLanding = computed(() => !query.value.trim()) @@ -120,15 +123,28 @@ function showToast(msg: string) { }, 2600) } -const menuItems = computed(() => [ - { key: 'detail', label: '查看详情' }, - { - key: 'shelf', - label: menuBook.value?.in_bookshelf ? '已在书架' : '加入书架', - disabled: !userState.isLogin || Boolean(menuBook.value?.in_bookshelf), - }, - { key: 'author', label: '搜索该作者', disabled: !menuBook.value?.author }, -]) +const menuItems = computed(() => { + const items: MenuItem[] = [ + { key: 'detail', label: '查看详情' }, + { + key: 'shelf', + label: menuBook.value?.in_bookshelf ? '已在书架' : '加入书架', + disabled: !userState.isLogin || Boolean(menuBook.value?.in_bookshelf), + }, + { key: 'author', label: '搜索该作者', disabled: !menuBook.value?.author }, + ] + if (settings.enableDownload) { + items.push({ + key: 'download', + label: '下载', + children: [ + { key: `${DOWNLOAD_PREFIX}epub`, label: 'EPUB' }, + { key: `${DOWNLOAD_PREFIX}txt`, label: 'TXT' }, + ], + }) + } + return items +}) function onCardContextMenu({ book, x, y }: { book: SearchBook; x: number; y: number }) { menuBook.value = book @@ -152,6 +168,11 @@ async function onMenuSelect(key: string) { void doSearch(book.author) return } + if (key.startsWith(DOWNLOAD_PREFIX)) { + const format = key.slice(DOWNLOAD_PREFIX.length) as 'epub' | 'txt' + void startDownload(book.book_id, { format }) + return + } if (key === 'shelf') { try { await addToBookshelf(book.book_id) diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index c94b15b..52fe3e9 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -1,18 +1,27 @@