feat: txt/epub download && new encrypted font decryption table

This commit is contained in:
naiy committed 2026-08-24 14:51:16 +08:00
1 parent 57f7a8ca8d
commit 7b10fada5d
30 files changed
+3044 -76

No files matched your search

+112
View File
@@ -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<string, BatchChapter>
/** 重试后仍然拿不到正文的 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<FetchChaptersResult> {
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<string, BatchChapter> = {}
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 }
}
+136
View File
@@ -0,0 +1,136 @@
// 章节正文(XHTML)的解析与拆分。
//
// APP 接口下发的正文是一份完整 XHTML 文档,长这样:
// <?xml …?><!DOCTYPE …><html>
// <head><style>.pageBg{…} .chapterTitle1{…}</style></head>
// <body idx="40000">
// <p class="volumePicture"><img src="…"/></p>
// <h1 class="chapterTitle1">…章节标题…</h1>
// <p><blk>正文段落</blk></p>
// </body>
// </html>
//
// 注意它和 web.html 处理的 PyFQWeb 格式不一样——那边是 <article> 包裹的片段,
// 所以原实现的 `div.innerHTML = content` + 收集文本节点在这里会出问题:
// <head><body> 这些标签在片段解析里被忽略,但 <style> 会留下来,
// 它的 CSS 文本会被当成正文收进去(每章多出 6KB 样式表)。
// 所以这里统一用 DOMParser 正经解析,再按用途拆。
/** 番茄用 class 标记章节标题,h1/h2/h3 都可能 */
const TITLE_CLASS = /(^|\s)chapterTitle\d?(\s|$)/
/** 比对标题时忽略空白与零宽字符(正文标题里夹了 ​) */
function normalizeTitle(text: string): string {
return text.replace(/[\s​‌‍]+/g, '')
}
/** 解析正文文档。失败返回 null */
function parseChapterDoc(xhtml: string): Document | null {
const parser = new DOMParser()
// 先按 XHTML 解析(正文本身就是 XHTML),失败再退到容错更强的 HTML 解析器
const xdoc = parser.parseFromString(xhtml, 'application/xhtml+xml')
if (!xdoc.querySelector('parsererror') && xdoc.body) return xdoc
const hdoc = parser.parseFromString(xhtml, 'text/html')
return hdoc.body ? hdoc : null
}
/** 正文容器:优先 <article>,否则 <body> */
function contentRoot(doc: Document): HTMLElement | null {
return (doc.querySelector('article') as HTMLElement | null) ?? doc.body
}
/**
* 去掉开头重复的章节标题。
*
* 正文里的标题是 <h1 class="chapterTitle1">,文字常带编号变体
* (目录写「第1章 空屋」,正文写「第一章空屋」),所以不能靠字符串相等去判断:
* 只要是靠前的 chapterTitle 元素就删,另外也接受与目录标题完全一致的普通标题。
*/
function removeLeadingTitle(root: ParentNode, title: string): void {
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6')
const wanted = normalizeTitle(title)
let index = 0
for (const heading of headings) {
// 只看前两个标题,正文中后续的小标题要保留
if (index++ >= 2) break
const cls = heading.getAttribute('class') ?? ''
if (TITLE_CLASS.test(cls) || normalizeTitle(heading.textContent ?? '') === wanted) {
heading.remove()
return
}
}
}
/** 收集并移除 <style>,返回样式文本 */
function takeStyles(doc: Document): string[] {
const styles: string[] = []
doc.querySelectorAll('style').forEach(el => {
const text = el.textContent?.trim()
if (text) styles.push(text)
el.remove()
})
return styles
}
export interface ChapterBody {
/** 正文 HTML,不含 <style>/<link> */
html: string
/** 章节内联样式表,多章之间常常完全相同,调用方可去重 */
styles: string[]
}
/**
* 供 EPUB 用:取出正文 HTML 与内联样式。
*
* 保留标题元素(EPUB 里靠它显示章节名),只摘掉在 EPUB 内无意义的
* <link>(指向 EPUB 相对路径,由 addCSSMap 另行处理)。
*/
export function chapterBody(raw: unknown): ChapterBody {
if (typeof raw !== 'string' || !raw.trim()) {
return { html: '<p>正文内容为空</p>', styles: [] }
}
const doc = parseChapterDoc(raw)
if (!doc) return { html: raw, styles: [] }
const styles = takeStyles(doc)
// EPUB 相对路径的样式表,在这里解析不了,交给 addCSSMap
doc.querySelectorAll('link').forEach(el => el.remove())
const root = contentRoot(doc)
const html = root?.innerHTML?.trim()
return { html: html || '<p>正文内容为空</p>', styles }
}
/** 递归收集文本节点 */
function collectText(node: Node, out: string[]): void {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent?.trim()
if (text) out.push(text)
} else if (child.nodeType === Node.ELEMENT_NODE) {
collectText(child, out)
}
}
}
/**
* 供 TXT 用:正文 -> 段落数组。
*
* @param title 目录里的章节标题,用于去掉正文开头重复的标题
*/
export function chapterParagraphs(raw: unknown, title = ''): string[] {
if (typeof raw !== 'string' || !raw.trim()) return []
const doc = parseChapterDoc(raw)
if (!doc) return []
takeStyles(doc)
doc.querySelectorAll('script, link').forEach(el => el.remove())
const root = contentRoot(doc)
if (!root) return []
if (title) removeLeadingTitle(root, title)
const out: string[] = []
collectText(root, out)
return out
}
+180
View File
@@ -0,0 +1,180 @@
// EPUB 导出。移植自 PyFQWeb web.html 的 downloadEpub。
//
// 与原实现的差异:
// - 图片/CSS 抓取注入了项目的请求封装,图床没有 CORS 头时会退到
// GM_xmlhttpRequest(见 utils/request 的 fetchArrayBuffer);
// - 插图下载、书籍样式、卷页做成可关的设置项;
// - 每章正文自带一段几乎完全相同的内联 <style>(约 6KB,1500 章就是 9MB),
// 这里抽出来去重后作为独立样式表,章节按索引引用;
// - 正文自带 <h1 class="chapterTitle1">,所以 insertTitle 传 false,
// 不再额外插一个 <h2>(否则导出后每章标题会重复一次)。
import EpubSaver from '../epub'
import type { EpubVolume } from '../epub'
import type { BatchChapter } from '../api/content'
import type { ChapterItem } from '../types'
import { parseCssMap } from '../api/bookcss'
import { fetchArrayBuffer } from '../utils/request'
import { nextFrame } from '../utils'
import { settings } from '../settings'
// 番茄 APP 的正文基础样式。正文 XHTML 的 class(chapterTitle1、bdFootnote 等)
// 都依赖它,EPUB 里不带的话标题和插图说明会退化成普通段落
import appcss from '../assets/default.css?raw'
import { chapterBody } from './content'
import { describeBook, hdCover, type BookMeta } from './meta'
import { DownloadTask } from './task'
/** css_map 里的 uri 是相对路径,要拼上 CDN 前缀 */
const CSS_CDN_PREFIX = 'https://p3-novel.byteimg.com/origin/'
/** 章节内联样式的起始索引,0 留给 APP 基础样式 */
const INLINE_CSS_BASE = 1
export interface BuildEpubOptions {
meta: BookMeta
/** 目录顺序 */
catalog: ChapterItem[]
/** item_id -> 章节数据 */
chapters: Record<string, BatchChapter>
/** 任一章节的 novel_data.css_map,用于书籍自带排版 */
cssMap?: unknown
task: DownloadTask
}
export async function buildEpub({
meta,
catalog,
chapters,
cssMap,
task,
}: BuildEpubOptions): Promise<Blob> {
const saver = new EpubSaver({
// 部分图床不给 CORS 头,走项目的双通道实现
fetchBinary: fetchArrayBuffer,
fetchText: async url => {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.text()
},
// 关掉插图下载时保留原地址,联网仍能看,比直接删掉损失小
images: settings.downloadImages ? 'download' : 'keep',
})
saver.setInfo('title', meta.title)
saver.setInfo('language', 'zh-CN')
// 作者用 dc:creator,dc:author 不是合法的 Dublin Core 元素
saver.setInfo('creator', meta.author)
saver.setInfo('description', describeBook(meta))
saver.setInfo('publisher', '番茄小说')
task.stage('准备 EPUB…', 1)
if (meta.cover_url) {
try {
await saver.cover(hdCover(meta.cover_url))
} catch (err) {
console.warn('[fqa:download] 封面下载失败,跳过:', err)
}
}
task.update(1)
// 正文 class 依赖的基础样式
await saver.addCSS(0, appcss, 'Styles/dragon-common.css')
// 书籍自带排版(css_map)。值是相对 uri,要补 CDN 前缀
if (settings.downloadBookCss) {
const map = parseCssMap(cssMap)
const absolute: Record<string, string> = {}
for (const [path, uri] of Object.entries(map)) {
if (uri) absolute[path] = CSS_CDN_PREFIX + uri
}
if (Object.keys(absolute).length) {
try {
await saver.addCSSMap(absolute)
} catch (err) {
console.warn('[fqa:download] 书籍样式获取失败,跳过:', err)
}
}
}
task.stage('组装章节…', catalog.length)
/** 内联样式去重:样式文本 -> addCSS 索引 */
const styleIndex = new Map<string, number>()
let nextCssIdx = INLINE_CSS_BASE
let volumeName = ''
let volumeIdx = 0
let currentVolume: EpubVolume | null = null
let cursor = 0
for (const item of catalog) {
task.throwIfCancelled()
const chapter = chapters[item.item_id]
// 卷名优先用正文里的,缺失时退回目录里的
const name = (chapter?.novel_data?.volume_name as string) || item.volume_title || '默认卷'
if (!currentVolume || name !== volumeName) {
volumeName = name
currentVolume = await saver.addVolume(volumeIdx++, name, {
createVolumePage: settings.downloadVolumePage,
volumePageType: 'blank',
})
}
const { html, styles } = chapterBody(chapter?.content)
// 各章样式基本一致,同一份只存一次
const cssIdxs: number[] = []
for (const style of styles) {
let idx = styleIndex.get(style)
if (idx === undefined) {
idx = nextCssIdx++
styleIndex.set(style, idx)
await saver.addCSS(idx, style)
}
cssIdxs.push(idx)
}
await currentVolume.addChapter(
cursor++,
item.title,
html,
'html',
true,
cssIdxs,
// 正文自带标题元素,不要再插一个
false,
)
task.update(cursor, item.title)
// 组装每章都是同步的重活(DOM 解析 + 序列化),中间不让出控制权的话
// 浏览器没机会重绘,进度条看起来是卡住的。每 20 章歇一帧
if (cursor % 20 === 0) await nextFrame()
}
console.log(`[fqa:download] 章节内联样式去重后 ${styleIndex.size} 份`)
// save() 内部分两步:先逐章生成 XHTML(同步重活),再压缩。
// 两步都要能刷新进度和响应取消,否则界面停在 0% 且点不动取消
task.stage('写入章节…', catalog.length)
let lastPercent = -1
const buffer = await saver.save({
onWrite: (done, total) => {
if (total !== task.snapshot.total) task.setTotal(total)
task.update(done)
},
onCompress: (percent, currentFile) => {
const rounded = Math.floor(percent)
// JSZip 每个数据块都会回调,同一个百分比不重复通知界面
if (rounded === lastPercent) return
if (lastPercent < 0) task.stagePercent('压缩 EPUB…')
lastPercent = rounded
task.update(rounded, currentFile ?? '正在压缩…')
},
checkCancel: () => task.throwIfCancelled(),
})
task.update(100, '')
return new Blob([buffer], { type: 'application/epub+zip' })
}
+87
View File
@@ -0,0 +1,87 @@
// GBK 编码。移植自 PyFQWeb web.html 里的 gbk.js。
//
// 浏览器的 TextEncoder 只支持 UTF-8,导出 GBK 的 TXT 只能自己编码。
// 思路是反过来用 TextDecoder('gbk'):先按 GBK 的编码区间枚举出所有合法双字节
// 组合,解码得到对应的 Unicode 字符,再建立 Unicode -> GBK 字节的反查表。
// 表有 64K 项(128KB 内存),只在首次编码时构建。
/** GBK 各编码区间 [高位起, 高位止, 低位起, 低位止] */
const GBK_RANGES: Array<[number, number, number, number]> = [
[161, 169, 161, 254],
[176, 247, 161, 254],
[129, 160, 64, 254],
[170, 254, 64, 160],
[168, 169, 64, 160],
[170, 175, 161, 254],
[248, 254, 161, 254],
[161, 167, 64, 160],
]
/** Unicode 码点 -> GBK 双字节(低位在高 8 位)。0xFFFF 表示不可编码 */
let table: Uint16Array | null = null
function initTable(): Uint16Array {
// 枚举所有合法 GBK 双字节序列
const pairs = new Uint16Array(23940)
let count = 0
for (const [hiStart, hiEnd, loStart, loEnd] of GBK_RANGES) {
for (let lo = loStart; lo <= loEnd; lo++) {
if (lo === 127) continue // 0x7F 不用作低位
for (let hi = hiStart; hi <= hiEnd; hi++) {
pairs[count++] = (lo << 8) | hi
}
}
}
const next = new Uint16Array(65536).fill(0xffff)
// 一次性解码,按位置对应回去
const decoded = new TextDecoder('gbk').decode(pairs)
for (let i = 0; i < decoded.length; i++) {
next[decoded.charCodeAt(i)] = pairs[i]!
}
table = next
return next
}
export interface EncodeGbkOptions {
/** 不可编码字符的替代字节,默认 '?'(63)。返回 -1 表示中止编码 */
onError?: (index: number, text: string) => number
}
/** 把字符串编码成 GBK 字节 */
export function encodeGBK(text: string, options: EncodeGbkOptions = {}): Uint8Array {
const map = table ?? initTable()
const onError = options.onError ?? (() => 63) // '?'
const out = new Uint8Array(text.length * 2)
let n = 0
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i)
if (code < 128) {
out[n++] = code
continue
}
const gbk = map[code]!
if (gbk !== 0xffff) {
out[n++] = gbk & 0xff
out[n++] = gbk >> 8
continue
}
// € 在 GBK 里还有个单字节表示 0x80。走到这里说明反查表里没有它
// (实际表里有 A2E3,所以这条基本不会命中),保留作兜底
if (code === 8364) {
out[n++] = 0x80
continue
}
const replacement = onError(i, text)
if (replacement === -1) break
if (replacement > 255) {
out[n++] = replacement & 0xff
out[n++] = replacement >> 8
} else {
out[n++] = replacement
}
}
return out.subarray(0, n)
}
+159
View File
@@ -0,0 +1,159 @@
// 下载入口。对应 web.html 的 downloadBook / downloadBookExecutor。
//
// 流程:书籍详情 + 目录 -> 批量抓正文 -> 组装 TXT/EPUB -> 保存。
// 同一时刻只允许一个任务(web.html 用一个隐藏 div 当锁,这里用模块变量)。
import { getBookInfoRaw } from '../api/book'
import { getCatalog } from '../api/catalog'
import type { BatchChapter } from '../api/content'
import type { ChapterItem } from '../types'
import type { DownloadFormat } from '../settings'
import { settings } from '../settings'
import { fetchChapters } from './chapters'
import { buildEpub } from './epub'
import { toBookMeta, type BookMeta } from './meta'
import { saveBlob, sanitizeFilename } from './save'
import { CancelledError, DownloadTask } from './task'
import { buildTxtContent, txtToBlob } from './txt'
export interface StartDownloadOptions {
/** 覆盖设置里的默认格式 */
format?: DownloadFormat
}
/** 当前任务,null 表示空闲 */
let activeTask: DownloadTask | null = null
/**
* 是否有下载在跑。
*
* 和 activeTask 分开记:用户可以在任务还没退出时关掉弹窗(此时 activeTask
* 被清成 null,界面不再显示),但任务本身还在收尾,这期间不能再开新的。
*/
let running = false
/** 任务变化通知,供进度界面挂载/卸载 */
const taskListeners = new Set<(task: DownloadTask | null) => void>()
export function onTaskChange(listener: (task: DownloadTask | null) => void): () => void {
taskListeners.add(listener)
listener(activeTask)
return () => taskListeners.delete(listener)
}
function setActiveTask(task: DownloadTask | null): void {
activeTask = task
for (const listener of taskListeners) {
try {
listener(task)
} catch (err) {
console.error('[fqa:download] 任务监听异常:', err)
}
}
}
export function getActiveTask(): DownloadTask | null {
return activeTask
}
/** 关闭进度弹窗时调用。任务还在跑就先取消,避免留下无人可见的后台任务 */
export function clearFinishedTask(): void {
if (!activeTask) return
if (!activeTask.snapshot.done) activeTask.cancel()
setActiveTask(null)
}
/** 从抓到的章节里找一份 css_map(同一本书各章相同) */
function findCssMap(chapters: Record<string, BatchChapter>): unknown {
for (const chapter of Object.values(chapters)) {
const cssMap = chapter.novel_data?.css_map
if (cssMap) return cssMap
}
return undefined
}
/** 元信息优先用详情接口,失败时退回正文里的 novel_data */
async function resolveMeta(
bookId: string,
chapters: Record<string, BatchChapter>,
): Promise<BookMeta> {
try {
const raw = await getBookInfoRaw(bookId)
if (raw?.book_id) return toBookMeta(raw)
} catch (err) {
console.warn('[fqa:download] 获取书籍详情失败,改用章节里的信息:', err)
}
const fallback = Object.values(chapters).find(c => c.novel_data?.book_id)?.novel_data
return toBookMeta(fallback ?? { book_id: bookId })
}
export async function startDownload(
bookId: string,
options: StartDownloadOptions = {},
): Promise<void> {
if (running) {
console.warn('[fqa:download] 已有下载任务在进行')
return
}
const format = options.format ?? settings.downloadFormat
const task = new DownloadTask()
running = true
setActiveTask(task)
try {
task.stage('获取目录…')
const catalogResult = await getCatalog(bookId)
const catalog: ChapterItem[] = catalogResult.chapter_list
if (catalog.length === 0) throw new Error('目录为空')
task.throwIfCancelled()
const titleMap = new Map(catalog.map(item => [item.item_id, item.title]))
const { chapters, failed } = await fetchChapters(
catalog.map(item => item.item_id),
bookId,
task,
{ titleOf: itemId => titleMap.get(itemId) },
)
task.throwIfCancelled()
if (Object.keys(chapters).length === 0) {
throw new Error('没有获取到任何章节正文')
}
if (failed.length > 0) {
console.warn(`[fqa:download] ${failed.length} 章缺失,仍然继续导出`)
}
const meta = await resolveMeta(bookId, chapters)
task.throwIfCancelled()
const base = sanitizeFilename(`${meta.title}_${meta.author}`, bookId)
if (format === 'epub') {
const blob = await buildEpub({
meta,
catalog,
chapters,
cssMap: findCssMap(chapters),
task,
})
task.stage('保存中…', 1)
saveBlob(blob, `${base}.epub`)
} else {
const content = await buildTxtContent({ meta, catalog, chapters, task })
task.stage('保存中…', 1)
saveBlob(txtToBlob(content), `${base}.txt`)
}
task.update(1)
task.finish(failed.length > 0 ? `完成(${failed.length} 章缺失)` : '下载完成')
} catch (err) {
if (err instanceof CancelledError) {
console.log('[fqa:download] 任务已取消')
task.finish('已取消')
return
}
console.error('[fqa:download] 下载失败:', err)
task.fail(err)
} finally {
running = false
}
}
+88
View File
@@ -0,0 +1,88 @@
// 书籍元信息整理。
//
// 番茄的 multi-detail 和 batch_full 里的 novel_data 字段名一致,但很多
// 字段是「JSON 字符串」而不是数组/对象(category_v2、roles),要先解析。
// 这里把 TXT 和 EPUB 都要用的东西集中处理一次。
export interface BookMeta {
book_id: string
title: string
author: string
/** 分类名,如「无限流」 */
categories: string[]
/** 主角名 */
roles: string[]
abstract: string
/** 版权声明,已去掉 APP 内的反馈引导语 */
copyright: string
cover_url: string
word_number: number
/** 原始 creation_status */
creation_status: string
}
/** APP 的版权声明里带一句只在客户端有意义的引导语 */
const FEEDBACK_HINT = /,如有任何疑问,请通过[“"]?我的-意见反馈[”"]?告知我们/
function parseJsonField<T>(raw: unknown, fallback: T): T {
if (raw == null) return fallback
if (typeof raw !== 'string') return (raw as T) ?? fallback
try {
const parsed = JSON.parse(raw)
return parsed == null ? fallback : (parsed as T)
} catch {
return fallback
}
}
/**
* 高清化封面。
*
* 列表接口给的是带缩放参数的地址(…~tplv-xxx:300:0.image),换成 origin
* 域名并去掉参数就能拿到原图。novel-pic-r 是已经处理过的,不动。
* 移植自 web.html 的 replaceCover。
*/
export function hdCover(url: string): string {
if (!url) return ''
if (url.includes('novel-pic-r')) return url
let u = url
if (u.startsWith('https://')) u = u.substring(8)
else if (u.startsWith('http://')) u = u.substring(7)
const parts = u.split('/')
parts[0] = 'https://p6-novel.byteimg.com/origin'
return parts
.map(part => (part.includes('?') || part.includes('~') ? part.split('~')[0]! : part))
.join('/')
}
/** 从 novel_data / multi-detail 的原始数据里提取元信息 */
export function toBookMeta(raw: any): BookMeta {
const categories = parseJsonField<Array<{ Name?: string }>>(raw?.category_v2, [])
.map(item => String(item?.Name ?? ''))
.filter(Boolean)
return {
book_id: String(raw?.book_id ?? ''),
title: String(raw?.book_name || raw?.original_book_name || '未命名'),
author: String(raw?.author ?? '未知作者'),
categories: categories.length ? categories : [String(raw?.category ?? '')].filter(Boolean),
roles: parseJsonField<string[]>(raw?.roles, []).map(String).filter(Boolean),
abstract: String(raw?.abstract ?? ''),
copyright: String(raw?.copyright_info ?? '').replace(FEEDBACK_HINT, ''),
cover_url: String(raw?.thumb_url ?? ''),
word_number: Number(raw?.word_number) || 0,
creation_status: String(raw?.creation_status ?? ''),
}
}
/** 简介段落,TXT 头部与 EPUB description 共用 */
export function describeBook(meta: BookMeta): string {
const lines: string[] = []
if (meta.categories.length) lines.push(`分类:${meta.categories.join('、')}`)
if (meta.roles.length) lines.push(`主角:${meta.roles.join('、')}`)
if (meta.abstract) lines.push(`简介:${meta.abstract}`)
if (meta.copyright) lines.push(`${meta.copyright}。`)
return lines.join('\n')
}
+33
View File
@@ -0,0 +1,33 @@
// 保存文件到本地。
//
// web.html 用的是 FileSaver.js,这里不引外部依赖:现代浏览器一个
// <a download> + Blob URL 就够了。脚本没申请 GM_download 权限。
/** 文件名里的非法字符 */
const ILLEGAL = /[\\/:*?"<>|\r\n\t]/g
/** 清理文件名,去掉路径分隔符等非法字符并限长 */
export function sanitizeFilename(name: string, fallback = 'download'): string {
const cleaned = name
.replace(ILLEGAL, '_')
// Windows 不允许文件名以点或空格结尾
.replace(/[. ]+$/, '')
.trim()
if (!cleaned) return fallback
// 留出扩展名的空间
return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned
}
export function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.rel = 'noopener'
a.style.display = 'none'
document.body.appendChild(a)
a.click()
a.remove()
// 立刻 revoke 会让部分浏览器来不及开始下载
setTimeout(() => URL.revokeObjectURL(url), 30_000)
}
+120
View File
@@ -0,0 +1,120 @@
// 下载任务的进度与取消。
//
// 抓取、组装、保存三个阶段共用一个 DownloadTask:调度层只管 update/updateTitle
// 和 isCancelled,界面层订阅快照。这样进度弹窗换实现也不用改下载逻辑。
/** 进度快照,界面直接渲染 */
export interface DownloadProgress {
/** 当前阶段标题,如「缓存章节…」 */
title: string
/** 已完成数量 */
current: number
/** 总量,0 表示未知 */
total: number
/** 副标题,一般是正在处理的章节名 */
subtitle: string
/** current/total 是百分比而非条目数,界面不显示「x/y」计数 */
percentOnly: boolean
/** 任务已结束(成功或失败) */
done: boolean
/** 失败原因,成功时为 null */
error: string | null
/** 已被取消 */
cancelled: boolean
}
export type ProgressListener = (progress: DownloadProgress) => void
export class CancelledError extends Error {
constructor() {
super('已取消')
this.name = 'CancelledError'
}
}
export class DownloadTask {
private state: DownloadProgress = {
title: '准备中…',
current: 0,
total: 0,
subtitle: '',
percentOnly: false,
done: false,
error: null,
cancelled: false,
}
private listeners = new Set<ProgressListener>()
get snapshot(): DownloadProgress {
return this.state
}
get isCancelled(): boolean {
return this.state.cancelled
}
subscribe(listener: ProgressListener): () => void {
this.listeners.add(listener)
listener(this.state)
return () => this.listeners.delete(listener)
}
private emit(patch: Partial<DownloadProgress>): void {
// 每次换一个新对象,Vue 只要 watch 引用就能感知变化
this.state = { ...this.state, ...patch }
for (const listener of this.listeners) {
try {
listener(this.state)
} catch (err) {
console.error('[fqa:download] 进度回调异常:', err)
}
}
}
/** 进入新阶段,重置计数 */
stage(title: string, total = 0): void {
this.emit({ title, total, current: 0, subtitle: '', percentOnly: false })
}
/** 进入一个按百分比汇报的阶段(如 zip 压缩) */
stagePercent(title: string): void {
this.emit({ title, total: 100, current: 0, subtitle: '', percentOnly: true })
}
update(current: number, subtitle?: string): void {
this.emit(subtitle === undefined ? { current } : { current, subtitle })
}
/** 在当前计数上累加,供并发/分批场景使用 */
advance(delta = 1, subtitle?: string): void {
this.update(this.state.current + delta, subtitle)
}
setTotal(total: number): void {
this.emit({ total })
}
setSubtitle(subtitle: string): void {
this.emit({ subtitle })
}
cancel(): void {
if (this.state.done) return
this.emit({ cancelled: true, title: '已取消', subtitle: '' })
}
finish(title = '完成'): void {
this.emit({ done: true, title, subtitle: '' })
}
fail(error: unknown): void {
const message = error instanceof Error ? error.message : String(error)
this.emit({ done: true, error: message, title: '下载失败' })
}
/** 取消时抛出,让调用栈直接退出 */
throwIfCancelled(): void {
if (this.state.cancelled) throw new CancelledError()
}
}
+71
View File
@@ -0,0 +1,71 @@
// TXT 导出。移植自 PyFQWeb web.html 的 downloadBookExecutor 的 TXT 分支。
//
// 正文转纯文本走 download/content 里的解析(不能直接 innerHTML + 收文本节点,
// APP 的正文是完整文档,<style> 里的 CSS 会被当成正文收进来)。
import type { BatchChapter } from '../api/content'
import type { ChapterItem } from '../types'
import { settings } from '../settings'
import { nextFrame } from '../utils'
import { chapterParagraphs } from './content'
import { encodeGBK } from './gbk'
import { describeBook, type BookMeta } from './meta'
import { DownloadTask } from './task'
/** 段落缩进,两个全角空格 */
const INDENT = '\u3000\u3000'
export interface BuildTxtOptions {
meta: BookMeta
catalog: ChapterItem[]
chapters: Record<string, BatchChapter>
task: DownloadTask
}
/** 拼出全书文本 */
export async function buildTxtContent({
meta,
catalog,
chapters,
task,
}: BuildTxtOptions): Promise<string> {
task.stage('处理章节…', catalog.length)
const parts: string[] = [meta.title, `作者:${meta.author}`, describeBook(meta), '', '']
let volumeName = ''
let cursor = 0
for (const item of catalog) {
task.throwIfCancelled()
const chapter = chapters[item.item_id]
const name = (chapter?.novel_data?.volume_name as string) || item.volume_title || ''
// 「第一卷:默认」这种自动生成的卷名,去掉后缀更干净
if (name && name !== volumeName) {
volumeName = name
parts.push(`\n${name.replace(':默认', '')}\n`)
}
const paragraphs = chapterParagraphs(chapter?.content, item.title)
const body = paragraphs.length
? INDENT + paragraphs.join(`\n${INDENT}`)
: `${INDENT}(本章内容缺失)`
parts.push(`${item.title}\n${body}\n\n`)
task.update(++cursor, item.title)
// 解析正文是同步的重活,中途让出几帧,否则进度条不动
if (cursor % 50 === 0) await nextFrame()
}
return parts.join('\n')
}
/** 按设置的编码打包成 Blob */
export function txtToBlob(content: string): Blob {
const charset = settings.downloadCharset
const encoded = charset === 'gbk'
? encodeGBK(content)
: new TextEncoder().encode(content)
return new Blob([encoded as BlobPart], { type: `text/plain;charset=${charset}` })
}