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
+3034 -66

No files matched your search

+5 -10
View File
@@ -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<any> {
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<Book> {
const bookInfo = await getBookInfoRaw(bookId)
if (!bookInfo) {
+43 -6
View File
@@ -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<any> {
export async function getCatalogRaw(bookId: string): Promise<Array<any>> {
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<any> {
return [items, items.map((it: any) => String(it.item_id))]
}
export async function webCatalog(bookId: string): Promise<Array<any>> {
// 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<string, Array<any>> = {}
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<CatalogResult> {
const r = await getCatalogRaw(bookId) as Array<unknown>
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<string, VolumeItem> = {} // title -> VolumeItem
const chapters: Array<ChapterItem> = []
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
+55 -2
View File
@@ -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<void> {
@@ -27,6 +28,24 @@ async function refreshKeyinfo(): Promise<void> {
}) // cache key info
}
/** 正在进行的注册。并发调用共享同一次,避免彼此覆盖刚拿到的密钥 */
let refreshInflight: Promise<void> | null = null
/**
* 强制重新注册密钥,绕过本地缓存。
*
* 设备长时间没用过,服务端的密钥注册会失效:此时 keyver 往往还和本地一致,
* 但正文一律返回 'Invalid'。这种情况只能重新注册,读缓存没有意义。
*/
export function refreshKey(): Promise<void> {
if (!refreshInflight) {
refreshInflight = refreshKeyinfo().finally(() => {
refreshInflight = null
})
}
return refreshInflight
}
async function ensureKeyinfo(expectedKeyVersion?: number): Promise<void> {
const keyinfo = config.currentConfig.key_info
const cachedKeyInfo = read('keyinfo')
@@ -43,11 +62,11 @@ async function ensureKeyinfo(expectedKeyVersion?: number): Promise<void> {
}
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<any>
}
if (j?.content === 'Invalid' || j?.key_version !== config.currentConfig.key_info?.keyver) { // keyreg expired
console.warn('Key reg expired, regster again and retrying...')
// 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<Record<string, BatchChapter>> {
if (itemIds.length === 0) return {}
if (!config.currentConfig.key_info) {
@@ -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<string, BatchChapter> = {}
for (const [id, item] of entries) {
if (!id) continue
+12
View File
@@ -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() {}
+13 -9
View File
@@ -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{
+167
View File
@@ -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);
}
}
+4
View File
@@ -230,3 +230,7 @@
border-color: rgba(255, 255, 255, 0.1);
}
}
.info {
width: 100%!important;
}
-4
View File
@@ -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<ArrayBuffer> {
const padLength = blockSize - (data.length % blockSize);
const padded = new Uint8Array(data.length + padLength);
+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}` })
}
+60
View File
@@ -0,0 +1,60 @@
// 下载进度弹窗的挂载/卸载。
// 与 settingsPanel 同构:全局单例,由下载任务的生命周期驱动。
import { createApp, h, type App } from 'vue'
import DownloadProgress from './views/DownloadProgress.vue'
import downloadcss from './assets/download.css?raw'
import { clearFinishedTask, onTaskChange } from './download'
import type { DownloadTask } from './download/task'
const CONTAINER_ID = 'fqa-download-root'
const STYLE_ID = 'fqa-download-style'
let app: App | null = null
let container: HTMLElement | 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)
}
function close(): void {
app?.unmount()
app = null
container?.remove()
container = null
}
/** 用户点「确定」:连同已结束的任务一起清掉,让下一次下载能开始 */
function dismiss(): void {
close()
clearFinishedTask()
}
function open(task: DownloadTask): void {
if (app) return
injectStyle()
container = document.createElement('div')
container.id = CONTAINER_ID
document.body.appendChild(container)
app = createApp({
render: () => h(DownloadProgress, { task, onClose: dismiss }),
})
app.config.errorHandler = (err, _instance, info) => {
console.error(`[fqa:download] Vue error (${info}):`, err)
}
app.mount(container)
}
/** 监听任务变化,有任务就弹窗。全程只需在启动时调用一次 */
export function initDownloadPanel(): void {
onTaskChange(task => {
if (task) open(task)
else close()
})
}
+1011
View File
File diff suppressed because it is too large. Load diff
+120 -28
View File
@@ -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-<id>"。
*
* 每套表按 code_st..code_ed 顺序排列,共 372 个字符。'?' 表示该码点在字体里
* 没有字形(编码区里的空位),不是「未知待补」——这些位置正文里不会出现,
* 命中时保持原样输出即可。番茄会不定期新增字体,加一套表就是加一个 key。
*/
const mapping: Record<string, string[]> = {
'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<string, string[]>()
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-<id> 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);
}
+159
View File
@@ -0,0 +1,159 @@
// 书籍详情页的下载入口。
//
// /page/<bookId> 上,登录后有「加入书架」按钮,未登录只有「开始阅读」。
// 两种情况都在最后一个按钮后面插一个「下载」。页面是 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(' ')
// 原按钮内部是 <span>文字</span>,照抄结构避免样式错位
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<HTMLElement>(ANCHOR_SELECTOR))
if (anchors.length === 0) return
const existing = document.querySelector<HTMLElement>(`.${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<void> {
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
+2
View File
@@ -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) {
+4
View File
@@ -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()
+54
View File
@@ -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<Settings>(normalize(read(STORE_KEY)))
+20
View File
@@ -2,6 +2,26 @@ export async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 让出一帧,给浏览器重绘的机会。
*
* 长时间同步计算(批量解析/序列化正文)中途要插几次,否则界面看起来是卡住的。
* requestAnimationFrame 在后台标签页里不触发,所以加一个 setTimeout 兜底。
*/
export function nextFrame(): Promise<void> {
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<T extends Element>(element: T): T {
return element.cloneNode(true) as T;
}
+84
View File
@@ -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<string, unknown>): boolean {
if (target[FLAG]) return false
type Task = { fn: (...args: unknown[]) => void; args: unknown[] }
const tasks = new Map<number, Task>()
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<string, unknown>)
try {
const real = unsafeWindow as unknown as Record<string, unknown>
if (real && real !== (globalThis as unknown as Record<string, unknown>)) {
installSetImmediate(real)
}
} catch {
// 取不到 unsafeWindow 就算了,沙箱那份已经够用
}
if (patched) console.debug('[fqa:zipfix] 已替换 setImmediate,修复 JSZip 异步调度')
}
+22 -3
View File
@@ -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<MenuItem[]>(() => {
const entry = menuEntry.value
@@ -279,7 +282,7 @@ const menuItems = computed<MenuItem[]>(() => {
// 已经在某个分组里时,额外给一个移出分组的选项
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<MenuItem[]>(() => {
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 {
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import type { DownloadProgress, DownloadTask } from '../download/task'
const props = defineProps<{ task: DownloadTask }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const progress = ref<DownloadProgress>(props.task.snapshot)
const stop = props.task.subscribe(p => (progress.value = p))
onBeforeUnmount(stop)
const percent = computed(() => {
const { current, total } = progress.value
if (total <= 0) return 0
return Math.min(100, Math.round((current / total) * 100))
})
/** 总量未知时走循环动画,别显示一个永远 0% 的条 */
const indeterminate = computed(() => progress.value.total <= 0 && !progress.value.done)
function cancel() {
props.task.cancel()
}
</script>
<template>
<div class="fqa-dl-mask">
<div class="fqa-dl-box" role="dialog" aria-modal="true" aria-label="下载进度">
<h3 class="fqa-dl-title">{{ progress.title }}</h3>
<p class="fqa-dl-sub">{{ progress.subtitle }}</p>
<div class="fqa-dl-bar">
<div
class="fqa-dl-fill"
:class="{ 'fqa-dl-fill-indeterminate': indeterminate }"
:style="{ width: `${percent}%` }"
></div>
</div>
<div class="fqa-dl-meta">
<span>{{ indeterminate ? '' : `${percent}%` }}</span>
<span v-if="progress.total > 0 && !progress.percentOnly">
{{ progress.current }}/{{ progress.total }}
</span>
</div>
<p v-if="progress.error" class="fqa-dl-error">{{ progress.error }}</p>
<div class="fqa-dl-actions">
<!-- 取消已请求但任务还没退出:按钮置灰提示正在停止,
同时给一个「关闭」让用户随时收起弹窗,不至于卡死在这里 -->
<button
v-if="!progress.done"
class="fqa-dl-btn"
:disabled="progress.cancelled"
@click="cancel"
>
{{ progress.cancelled ? '正在停止…' : '取消' }}
</button>
<button
v-if="progress.cancelled && !progress.done"
class="fqa-dl-btn"
@click="emit('close')"
>
关闭
</button>
<button v-if="progress.done" class="fqa-dl-btn fqa-dl-btn-primary" @click="emit('close')">
确定
</button>
</div>
</div>
</div>
</template>
+23 -2
View File
@@ -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,7 +123,8 @@ function showToast(msg: string) {
}, 2600)
}
const menuItems = computed<MenuItem[]>(() => [
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{ key: 'detail', label: '查看详情' },
{
key: 'shelf',
@@ -128,7 +132,19 @@ const menuItems = computed<MenuItem[]>(() => [
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)
+118 -2
View File
@@ -1,18 +1,27 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { settings, flushSettings } from '../settings'
import { settings, flushSettings, DEFAULT_SETTINGS } from '../settings'
import { provisionDevice } from '../api/provision'
import config from '../config'
import { version } from '../../package.json'
const emit = defineEmits<{ (e: 'close'): void }>()
type SectionKey = 'general' | 'ui' | 'search' | 'protocol' | 'about'
/** 请求节奏恢复默认,其余下载设置不动 */
function resetDownloadTuning() {
settings.downloadBatchSize = DEFAULT_SETTINGS.downloadBatchSize
settings.downloadInterval = DEFAULT_SETTINGS.downloadInterval
settings.downloadRetries = DEFAULT_SETTINGS.downloadRetries
flushSettings()
}
type SectionKey = 'general' | 'ui' | 'search' | 'download' | 'protocol' | 'about'
const SECTIONS: Array<{ key: SectionKey; label: string }> = [
{ key: 'general', label: '常规' },
{ key: 'ui', label: '界面' },
{ key: 'search', label: '搜索' },
{ key: 'download', label: '下载' },
{ key: 'protocol', label: '协议' },
{ key: 'about', label: '关于' },
]
@@ -169,6 +178,113 @@ const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant'
</div>
</template>
<!-- 下载 -->
<template v-else-if="active === 'download'">
<h3 class="fqa-set-h">下载</h3>
<label class="fqa-set-row">
<span class="fqa-set-label">显示下载入口</span>
<input v-model="settings.enableDownload" type="checkbox" class="fqa-set-switch" />
</label>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">默认格式</span>
<div class="fqa-set-radios">
<label class="fqa-set-radio">
<input v-model="settings.downloadFormat" type="radio" value="epub" />
<span>EPUB</span>
</label>
<label class="fqa-set-radio">
<input v-model="settings.downloadFormat" type="radio" value="txt" />
<span>TXT</span>
</label>
</div>
<p class="fqa-set-note">
EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。
</p>
</div>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">TXT 编码</span>
<div class="fqa-set-radios">
<label class="fqa-set-radio">
<input v-model="settings.downloadCharset" type="radio" value="utf-8" />
<span>UTF-8</span>
</label>
<label class="fqa-set-radio">
<input v-model="settings.downloadCharset" type="radio" value="gbk" />
<span>GBK</span>
</label>
</div>
<p class="fqa-set-note">EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。</p>
</div>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">EPUB 选项</span>
<label class="fqa-set-row" style="padding-top: 0; border-bottom: none">
<span class="fqa-set-label">下载正文插图</span>
<input v-model="settings.downloadImages" type="checkbox" class="fqa-set-switch" />
</label>
<label class="fqa-set-row" style="padding-top: 0; border-bottom: none">
<span class="fqa-set-label">保留书籍排版样式</span>
<input v-model="settings.downloadBookCss" type="checkbox" class="fqa-set-switch" />
</label>
<label class="fqa-set-row" style="padding-top: 0; border-bottom: none">
<span class="fqa-set-label">为每卷生成卷页</span>
<input v-model="settings.downloadVolumePage" type="checkbox" class="fqa-set-switch" />
</label>
<p class="fqa-set-note">
插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。
</p>
</div>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">请求节奏</span>
<p class="fqa-set-warn">
接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。
</p>
<label class="fqa-set-field">
<span>每批章节数(1-30)</span>
<input
v-model.number="settings.downloadBatchSize"
class="fqa-set-input"
type="number"
min="1"
max="30"
/>
</label>
<label class="fqa-set-field">
<span>批次间隔 (ms)</span>
<input
v-model.number="settings.downloadInterval"
class="fqa-set-input"
type="number"
min="0"
max="10000"
step="50"
/>
</label>
<label class="fqa-set-field">
<span>重试轮数</span>
<input
v-model.number="settings.downloadRetries"
class="fqa-set-input"
type="number"
min="0"
max="10"
/>
</label>
<p class="fqa-set-note">
实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。
</p>
<div class="fqa-set-actions">
<button class="fqa-set-btn" @click="resetDownloadTuning">恢复推荐值</button>
</div>
</div>
</template>
<!-- 协议 -->
<template v-else-if="active === 'protocol'">
<h3 class="fqa-set-h">协议</h3>