mirror of
https://github.com/naiyQAQ/fanqie-assistant.git
synced 2026-10-21 10:24:47 +00:00
feat: txt/epub download && new encrypted font decryption table
This commit is contained in:
1 parent
57f7a8ca8d
commit
7b10fada5d
30 files changed
+3044
-76
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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}` })
|
||||
}
|
||||
Reference in new issue
Block a user