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
+3043
-75
No files matched your search
+5
-10
@@ -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> {
|
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()
|
const j: any = response.json()
|
||||||
console.log('Book Info:', j)
|
console.log('Book Info:', j)
|
||||||
if (
|
return j.data
|
||||||
typeof j === 'object' &&
|
|
||||||
j !== null &&
|
|
||||||
'data' in j &&
|
|
||||||
Array.isArray(j.data) &&
|
|
||||||
j.data.length > 0
|
|
||||||
) return j.data[0]
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function multiDetailRaw() {}
|
||||||
|
|
||||||
export async function getBookInfo(bookId: string): Promise<Book> {
|
export async function getBookInfo(bookId: string): Promise<Book> {
|
||||||
const bookInfo = await getBookInfoRaw(bookId)
|
const bookInfo = await getBookInfoRaw(bookId)
|
||||||
if (!bookInfo) {
|
if (!bookInfo) {
|
||||||
|
|||||||
+43
-6
@@ -1,5 +1,6 @@
|
|||||||
import type { Book, CatalogResult, ChapterItem, VolumeItem } from '../types'
|
import type { Book, CatalogResult, ChapterItem, VolumeItem } from '../types'
|
||||||
import { appGet } from './app'
|
import { appGet } from './app'
|
||||||
|
import apiFetch from '../utils/request'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,7 +9,7 @@ import moment from 'moment'
|
|||||||
* 章节明细在 data.item_data_list(含 volume_name / title / first_pass_time /
|
* 章节明细在 data.item_data_list(含 volume_name / title / first_pass_time /
|
||||||
* chapter_word_number);data.catalog_data 只有 item_id 和标题,信息不全。
|
* 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 response = await appGet('/bookapi/directory/all_items/v', { book_id: bookId })
|
||||||
const j: any = response.json()
|
const j: any = response.json()
|
||||||
const items = j?.data?.item_data_list
|
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))]
|
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> {
|
export async function getCatalog(bookId: string): Promise<CatalogResult> {
|
||||||
const r = await getCatalogRaw(bookId) as Array<unknown>
|
const r = await getCatalogRaw(bookId)
|
||||||
const catalogRaw = r[0] as any[] // item_data_list
|
let catalogRaw = r[0] as any[] // item_data_list
|
||||||
const allItemIds = r[1] as string[] // all item ids
|
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 vmap: Record<string, VolumeItem> = {} // title -> VolumeItem
|
||||||
const chapters: Array<ChapterItem> = []
|
const chapters: Array<ChapterItem> = []
|
||||||
|
|
||||||
catalogRaw.forEach((item: any): void => {
|
catalogRaw.forEach((item: any): void => {
|
||||||
const volumeName = item.volume_name ?? ''
|
const volumeName = item.volume_name ?? ''
|
||||||
const chapterItem = {
|
const chapterItem = {
|
||||||
item_id: String(item.item_id),
|
item_id: String(item.item_id || item.itemId),
|
||||||
title: item.title,
|
title: item.title,
|
||||||
// YYYY-MM-DD HH:mm:ss
|
// 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,
|
char_count: item.chapter_word_number || 0,
|
||||||
volume_title: volumeName,
|
volume_title: volumeName,
|
||||||
} as ChapterItem
|
} as ChapterItem
|
||||||
|
|||||||
+56
-3
@@ -4,6 +4,7 @@ import { b64decode, b64encode } from '../crypto'
|
|||||||
import { read, write } from '../localStorage'
|
import { read, write } from '../localStorage'
|
||||||
import { decryptChapter } from '../crypto/content'
|
import { decryptChapter } from '../crypto/content'
|
||||||
import { encryptKeyinfoBody, decryptKeyinfoResponse } from '../crypto/registerkey'
|
import { encryptKeyinfoBody, decryptKeyinfoResponse } from '../crypto/registerkey'
|
||||||
|
import { sleep } from '../utils'
|
||||||
|
|
||||||
|
|
||||||
async function refreshKeyinfo(): Promise<void> {
|
async function refreshKeyinfo(): Promise<void> {
|
||||||
@@ -27,6 +28,24 @@ async function refreshKeyinfo(): Promise<void> {
|
|||||||
}) // cache key info
|
}) // 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> {
|
async function ensureKeyinfo(expectedKeyVersion?: number): Promise<void> {
|
||||||
const keyinfo = config.currentConfig.key_info
|
const keyinfo = config.currentConfig.key_info
|
||||||
const cachedKeyInfo = read('keyinfo')
|
const cachedKeyInfo = read('keyinfo')
|
||||||
@@ -43,11 +62,11 @@ async function ensureKeyinfo(expectedKeyVersion?: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (!keyinfo) {
|
if (!keyinfo) {
|
||||||
// get from zero
|
// get from zero
|
||||||
return await refreshKeyinfo()
|
return await refreshKey()
|
||||||
}
|
}
|
||||||
if (keyinfo?.keyver !== expectedKeyVersion) {
|
if (keyinfo?.keyver !== expectedKeyVersion) {
|
||||||
// refresh from server
|
// 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
|
if (j?.content === 'Invalid' || j?.key_version !== config.currentConfig.key_info?.keyver) { // keyreg expired
|
||||||
console.warn('Key reg expired, regster again and retrying...')
|
console.warn('Key reg expired, regster again and retrying...')
|
||||||
await ensureKeyinfo(parseInt(j?.key_version))
|
// content 是 'Invalid' 时说明注册本身失效了,keyver 可能还和本地一样,
|
||||||
|
// 这时候走 ensureKeyinfo 会命中缓存直接返回,密钥还是坏的,必须强制重注册
|
||||||
|
if (j?.content === 'Invalid') {
|
||||||
|
await refreshKey()
|
||||||
|
} else {
|
||||||
|
await ensureKeyinfo(parseInt(j?.key_version))
|
||||||
|
}
|
||||||
return await getChapter(itemId, _retry + 1)
|
return await getChapter(itemId, _retry + 1)
|
||||||
}
|
}
|
||||||
j.content = await decryptChapter(j?.content, j, config.currentConfig)
|
j.content = await decryptChapter(j?.content, j, config.currentConfig)
|
||||||
@@ -90,6 +115,7 @@ export interface BatchChapter {
|
|||||||
export async function getChapters(
|
export async function getChapters(
|
||||||
itemIds: string[],
|
itemIds: string[],
|
||||||
bookId = '0',
|
bookId = '0',
|
||||||
|
_retry = 0,
|
||||||
): Promise<Record<string, BatchChapter>> {
|
): Promise<Record<string, BatchChapter>> {
|
||||||
if (itemIds.length === 0) return {}
|
if (itemIds.length === 0) return {}
|
||||||
if (!config.currentConfig.key_info) {
|
if (!config.currentConfig.key_info) {
|
||||||
@@ -114,6 +140,33 @@ export async function getChapters(
|
|||||||
throw new Error(`Failed to batch get chapters: ${res.responseText}`)
|
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> = {}
|
const results: Record<string, BatchChapter> = {}
|
||||||
for (const [id, item] of entries) {
|
for (const [id, item] of entries) {
|
||||||
if (!id) continue
|
if (!id) continue
|
||||||
|
|||||||
@@ -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
@@ -2,6 +2,10 @@
|
|||||||
* 番茄小说APP内置CSS
|
* 番茄小说APP内置CSS
|
||||||
* 版权归属为番茄小说APP官方
|
* 版权归属为番茄小说APP官方
|
||||||
* 修改以兼容网页环境
|
* 修改以兼容网页环境
|
||||||
|
*
|
||||||
|
* line-space / theme-color 是 APP 排版引擎的私有属性,浏览器和 EPUB 阅读器都不认,
|
||||||
|
* 已注释保留以便和原始样式对照。theme-color 的值 color1#0.7 还会让 CSS 解析器跑偏,
|
||||||
|
* 把后面几条声明一起吃掉,必须注释而不能留着。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
html{
|
html{
|
||||||
@@ -30,7 +34,7 @@ h1{
|
|||||||
font-weight:bold;
|
font-weight:bold;
|
||||||
margin:22px 0em 3em 0em;
|
margin:22px 0em 3em 0em;
|
||||||
text-align:left;
|
text-align:left;
|
||||||
line-space:0.5em;
|
/* line-space:0.5em; */
|
||||||
}
|
}
|
||||||
|
|
||||||
h2{
|
h2{
|
||||||
@@ -57,15 +61,15 @@ sup{
|
|||||||
text-indent:0em;
|
text-indent:0em;
|
||||||
text-align:center;
|
text-align:center;
|
||||||
margin:0em 0em 0em 0em;
|
margin:0em 0em 0em 0em;
|
||||||
line-space:0em;
|
/* line-space:0em; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.pictureDesc{
|
.pictureDesc{
|
||||||
font-size:0.73em;
|
font-size:0.73em;
|
||||||
text-indent:0em;
|
text-indent:0em;
|
||||||
margin:0.4em 0em 1em 0em;
|
margin:0.4em 0em 1em 0em;
|
||||||
theme-color:color1#0.7;
|
/* theme-color:color1#0.7; */
|
||||||
line-space:0.3em;
|
/* line-space:0.3em; */
|
||||||
text-align:left;
|
text-align:left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +77,9 @@ sup{
|
|||||||
font-size:0.73em;
|
font-size:0.73em;
|
||||||
text-indent:0em;
|
text-indent:0em;
|
||||||
margin:1em 0em 0.3em 0em;
|
margin:1em 0em 0.3em 0em;
|
||||||
theme-color:color1#0.7;
|
/* theme-color:color1#0.7; */
|
||||||
text-align:left;
|
text-align:left;
|
||||||
line-space:0em;
|
/* line-space:0em; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.collectTitle{
|
.collectTitle{
|
||||||
@@ -107,7 +111,7 @@ sup{
|
|||||||
font-size:1em;
|
font-size:1em;
|
||||||
margin:1.5em 0em 1.5em 2em;
|
margin:1.5em 0em 1.5em 2em;
|
||||||
font-family:'FZShengShiKaiShuS-M-GB';
|
font-family:'FZShengShiKaiShuS-M-GB';
|
||||||
line-space:0.5em;
|
/* line-space:0.5em; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.quoteStyle1{
|
.quoteStyle1{
|
||||||
@@ -124,7 +128,7 @@ sup{
|
|||||||
margin:0.6em 0em 1.5em 32px;
|
margin:0.6em 0em 1.5em 32px;
|
||||||
font-family:'FZShengShiKaiShuS-M-GB';
|
font-family:'FZShengShiKaiShuS-M-GB';
|
||||||
text-align:right;
|
text-align:right;
|
||||||
line-space:0.5em;
|
/* line-space:0.5em; */
|
||||||
}
|
}
|
||||||
.chapterTitle1{
|
.chapterTitle1{
|
||||||
display:block;
|
display:block;
|
||||||
@@ -132,7 +136,7 @@ sup{
|
|||||||
font-weight:bold;
|
font-weight:bold;
|
||||||
margin:22px 0em 3em 0em;
|
margin:22px 0em 3em 0em;
|
||||||
text-align:left;
|
text-align:left;
|
||||||
line-space:0.5em;
|
/* line-space:0.5em; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapterTitle2{
|
.chapterTitle2{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -230,3 +230,7 @@
|
|||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
width: 100%!important;
|
||||||
|
}
|
||||||
@@ -59,10 +59,6 @@ export function hex(buffer: ArrayBuffer): string {
|
|||||||
return hexString;
|
return hexString;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PKCS#7 填充。注意 WebCrypto 的 AES-CBC 会自动补 PKCS#7,
|
|
||||||
* 只有在需要手动分块(如 Simon/Speck)时才要调用本函数。
|
|
||||||
*/
|
|
||||||
export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array<ArrayBuffer> {
|
export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array<ArrayBuffer> {
|
||||||
const padLength = blockSize - (data.length % blockSize);
|
const padLength = blockSize - (data.length % blockSize);
|
||||||
const padded = new Uint8Array(data.length + padLength);
|
const padded = new Uint8Array(data.length + padLength);
|
||||||
|
|||||||
@@ -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}` })
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+121
-29
@@ -1,39 +1,117 @@
|
|||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { settings } from './settings'
|
import { settings } from './settings'
|
||||||
|
|
||||||
const enTag = '.font-DNMrHsV173Pd4pgy' // 这个目前从存在解密算法到现在没改过。暂且认为它在加解密算法不变时长时不变。
|
// 加密区间。两套字体共用同一段私有区码点,只是映射表不同
|
||||||
const code_ed = 58715
|
const code_ed = 58715
|
||||||
const code_st = 58344
|
const code_st = 58344
|
||||||
const mapping = [
|
|
||||||
"D", "在", "主", "特", "家", "军", "然", "表", "场", "4", "要", "只", "v", "和", "?", "6", "别", "还", "g",
|
/**
|
||||||
"现", "儿", "岁", "?", "?", "此", "象", "月", "3", "出", "战", "工", "相", "o", "男", "直", "失", "世", "F",
|
* 码表。key 是字体 id,对应页面上的 class="font-<id>"。
|
||||||
"都", "平", "文", "什", "V", "O", "将", "真", "T", "那", "当", "?", "会", "立", "些", "u", "是", "十", "张",
|
*
|
||||||
"学", "气", "大", "爱", "两", "命", "全", "后", "东", "性", "通", "被", "1", "它", "乐", "接", "而", "感",
|
* 每套表按 code_st..code_ed 顺序排列,共 372 个字符。'?' 表示该码点在字体里
|
||||||
"车", "山", "公", "了", "常", "以", "何", "可", "话", "先", "p", "i", "叫", "轻", "M", "士", "w", "着", "变",
|
* 没有字形(编码区里的空位),不是「未知待补」——这些位置正文里不会出现,
|
||||||
"尔", "快", "l", "个", "说", "少", "色", "里", "安", "花", "远", "7", "难", "师", "放", "t", "报", "认",
|
* 命中时保持原样输出即可。番茄会不定期新增字体,加一套表就是加一个 key。
|
||||||
"面", "道", "S", "?", "克", "地", "度", "I", "好", "机", "U", "民", "写", "把", "万", "同", "水", "新", "没",
|
*/
|
||||||
"书", "电", "吃", "像", "斯", "5", "为", "y", "白", "几", "日", "教", "看", "但", "第", "加", "候", "作",
|
const mapping: Record<string, string[]> = {
|
||||||
"上", "拉", "住", "有", "法", "r", "事", "应", "位", "利", "你", "声", "身", "国", "问", "马", "女", "他",
|
'DNMrHsV173Pd4pgy': [
|
||||||
"Y", "比", "父", "x", "A", "H", "N", "s", "X", "边", "美", "对", "所", "金", "活", "回", "意", "到", "z",
|
'D在主特家军然表场4要只v和?6别还g现儿岁??此象月3出战工相',
|
||||||
"从", "j", "知", "又", "内", "因", "点", "Q", "三", "定", "8", "R", "b", "正", "或", "夫", "向", "德", "听",
|
'o男直失世F都平文什VO将真T那当?会立些u是十张学气大爱两命全',
|
||||||
"更", "?", "得", "告", "并", "本", "q", "过", "记", "L", "让", "打", "f", "人", "就", "者", "去", "原", "满",
|
'后东性通被1它乐接而感车山公了常以何可话先pi叫轻M士w着变尔快',
|
||||||
"体", "做", "经", "K", "走", "如", "孩", "c", "G", "给", "使", "物", "?", "最", "笑", "部", "?", "员", "等",
|
'l个说少色里安花远7难师放t报认面道S?克地度I好机U民写把万同',
|
||||||
"受", "k", "行", "一", "条", "果", "动", "光", "门", "头", "见", "往", "自", "解", "成", "处", "天", "能",
|
'水新没书电吃像斯5为y白几日教看但第加候作上拉住有法r事应位利你',
|
||||||
"于", "名", "其", "发", "总", "母", "的", "死", "手", "入", "路", "进", "心", "来", "h", "时", "力", "多",
|
'声身国问马女他Y比父xAHNsX边美对所金活回意到z从j知又内因',
|
||||||
"开", "已", "许", "d", "至", "由", "很", "界", "n", "小", "与", "Z", "想", "代", "么", "分", "生", "口",
|
'点Q三定8Rb正或夫向德听更?得告并本q过记L让打f人就者去原满',
|
||||||
"再", "妈", "望", "次", "西", "风", "种", "带", "J", "?", "实", "情", "才", "这", "?", "E", "我", "神", "格",
|
'体做经K走如孩cG给使物?最笑部?员等受k行一条果动光门头见往自',
|
||||||
"长", "觉", "间", "年", "眼", "无", "不", "亲", "关", "结", "0", "友", "信", "下", "却", "重", "己", "老",
|
'解成处天能于名其发总母的死手入路进心来h时力多开已许d至由很界n',
|
||||||
"2", "音", "字", "m", "呢", "明", "之", "前", "高", "P", "B", "目", "太", "e", "9", "起", "稜", "她", "也",
|
'小与Z想代么分生口再妈望次西风种带J?实情才这?E我神格长觉间年',
|
||||||
"W", "用", "方", "子", "英", "每", "理", "便", "四", "数", "期", "中", "C", "外", "样", "a", "海", "们",
|
'眼无不亲关结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 text 解密前的文本
|
||||||
|
* @param fontId 字体 id,决定用哪套码表
|
||||||
* @returns 解密后的文本
|
* @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 result = "";
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
@@ -45,8 +123,8 @@ export function decryptText(text: string): string {
|
|||||||
result += char;
|
result += char;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const mapped = mapping[codePoint - code_st];
|
const mapped = table[codePoint - code_st];
|
||||||
if (mapped && mapped !== "?") {
|
if (mapped && mapped !== NO_GLYPH) {
|
||||||
result += mapped;
|
result += mapped;
|
||||||
changed = true;
|
changed = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -58,13 +136,26 @@ export function decryptText(text: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密一个 DOM 元素内的所有文本节点
|
* 解密一个 DOM 元素内的所有文本节点
|
||||||
* @param element 要解密的 DOM 元素
|
* @param element 要解密的 DOM 元素,需带 font-<id> class
|
||||||
*/
|
*/
|
||||||
export function decryptElement(element: Element) {
|
export function decryptElement(element: Element) {
|
||||||
if (!settings.decryptFont) return
|
if (!settings.decryptFont) return
|
||||||
|
const fontId = fontIdOf(element)
|
||||||
|
if (!fontId) return
|
||||||
|
|
||||||
const walker = document.createTreeWalker(
|
const walker = document.createTreeWalker(
|
||||||
element,
|
element,
|
||||||
NodeFilter.SHOW_TEXT,
|
NodeFilter.SHOW_TEXT,
|
||||||
|
{
|
||||||
|
// 后代里可能嵌着另一套字体的节点,那部分要用它自己的码表,
|
||||||
|
// 交给针对它的 decryptElement 处理,这里跳过整棵子树
|
||||||
|
acceptNode(node) {
|
||||||
|
const owner = node.parentElement?.closest(enTag)
|
||||||
|
return owner && owner !== element
|
||||||
|
? NodeFilter.FILTER_REJECT
|
||||||
|
: NodeFilter.FILTER_ACCEPT
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let textNode;
|
let textNode;
|
||||||
while ((textNode = walker.nextNode())) {
|
while ((textNode = walker.nextNode())) {
|
||||||
@@ -72,7 +163,7 @@ export function decryptElement(element: Element) {
|
|||||||
if (!oldText) {
|
if (!oldText) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const newText = decryptText(oldText);
|
const newText = decryptText(oldText, fontId);
|
||||||
if (newText !== oldText) {
|
if (newText !== oldText) {
|
||||||
textNode.nodeValue = newText;
|
textNode.nodeValue = newText;
|
||||||
}
|
}
|
||||||
@@ -82,6 +173,7 @@ export function decryptElement(element: Element) {
|
|||||||
function decryptPage(root: Document | Element): void {
|
function decryptPage(root: Document | Element): void {
|
||||||
// 元素自身可能就是加密节点,其后代同样需要扫描:
|
// 元素自身可能就是加密节点,其后代同样需要扫描:
|
||||||
// MutationObserver 收到的新增节点通常是容器,加密节点在其内部。
|
// MutationObserver 收到的新增节点通常是容器,加密节点在其内部。
|
||||||
|
// enTag 是所有已知字体的选择器列表,matches / querySelectorAll 都支持逗号分隔。
|
||||||
if (root instanceof Element && root.matches(enTag)) {
|
if (root instanceof Element && root.matches(enTag)) {
|
||||||
decryptElement(root);
|
decryptElement(root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,6 +4,7 @@ import fetchHook from "./fetchHook";
|
|||||||
import userHook from "./userHook";
|
import userHook from "./userHook";
|
||||||
import bookshelfHook from "./bookshelfHook";
|
import bookshelfHook from "./bookshelfHook";
|
||||||
import searchHook from "./searchHook";
|
import searchHook from "./searchHook";
|
||||||
|
import downloadHook from "./downloadHook";
|
||||||
|
|
||||||
const hooks: HookConfig[] = [
|
const hooks: HookConfig[] = [
|
||||||
...readerHook,
|
...readerHook,
|
||||||
@@ -11,6 +12,7 @@ const hooks: HookConfig[] = [
|
|||||||
...userHook,
|
...userHook,
|
||||||
...bookshelfHook,
|
...bookshelfHook,
|
||||||
...searchHook,
|
...searchHook,
|
||||||
|
...downloadHook,
|
||||||
];
|
];
|
||||||
|
|
||||||
async function onEvent(event: HookEvent, previous?: string) {
|
async function onEvent(event: HookEvent, previous?: string) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { onLoad, onUrlChange, onHashChange, onEnter } from './hooks'
|
|||||||
import { version, name } from '../package.json'
|
import { version, name } from '../package.json'
|
||||||
import initUser from './api/user'
|
import initUser from './api/user'
|
||||||
import { ensureDevice } from './api/provision'
|
import { ensureDevice } from './api/provision'
|
||||||
|
import { initDownloadPanel } from './downloadPanel'
|
||||||
|
|
||||||
const win = unsafeWindow
|
const win = unsafeWindow
|
||||||
|
|
||||||
@@ -54,6 +55,9 @@ async function mainInit() {
|
|||||||
// 用户自定义样式(阅读器字体 / 自定义 CSS)
|
// 用户自定义样式(阅读器字体 / 自定义 CSS)
|
||||||
initUserStyle()
|
initUserStyle()
|
||||||
|
|
||||||
|
// 下载进度弹窗随任务自动显示
|
||||||
|
initDownloadPanel()
|
||||||
|
|
||||||
// APP 接口需要已注册的设备(首次会注册并激活会员,之后走本地缓存)
|
// APP 接口需要已注册的设备(首次会注册并激活会员,之后走本地缓存)
|
||||||
await ensureDevice()
|
await ensureDevice()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ const STORE_KEY = 'settings'
|
|||||||
/** API 偏好 */
|
/** API 偏好 */
|
||||||
export type ApiPreference = 'app' | 'redcandle'
|
export type ApiPreference = 'app' | 'redcandle'
|
||||||
|
|
||||||
|
/** 下载格式 */
|
||||||
|
export type DownloadFormat = 'epub' | 'txt'
|
||||||
|
|
||||||
|
/** TXT 编码 */
|
||||||
|
export type DownloadCharset = 'utf-8' | 'gbk'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
/* --- 常规 --- */
|
/* --- 常规 --- */
|
||||||
/** 解密网页端混淆字体 */
|
/** 解密网页端混淆字体 */
|
||||||
@@ -29,6 +35,26 @@ export interface Settings {
|
|||||||
/** 搜索时携带登录态以获取个人化推荐 */
|
/** 搜索时携带登录态以获取个人化推荐 */
|
||||||
searchPersonalized: boolean
|
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
|
apiPreference: ApiPreference
|
||||||
/** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */
|
/** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */
|
||||||
@@ -50,6 +76,18 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
// 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启
|
// 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启
|
||||||
searchPersonalized: false,
|
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',
|
apiPreference: 'app',
|
||||||
deviceId: '',
|
deviceId: '',
|
||||||
installId: '',
|
installId: '',
|
||||||
@@ -72,9 +110,25 @@ function normalize(raw: unknown): Settings {
|
|||||||
if (s.apiPreference !== 'app' && s.apiPreference !== 'redcandle') {
|
if (s.apiPreference !== 'app' && s.apiPreference !== 'redcandle') {
|
||||||
s.apiPreference = DEFAULT_SETTINGS.apiPreference
|
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
|
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)))
|
export const settings = reactive<Settings>(normalize(read(STORE_KEY)))
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,26 @@ export async function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
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 {
|
export function cloneElement<T extends Element>(element: T): T {
|
||||||
return element.cloneNode(true) as T;
|
return element.cloneNode(true) as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 异步调度')
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ import BookHoverCard from './BookHoverCard.vue'
|
|||||||
import ContextMenu from './ContextMenu.vue'
|
import ContextMenu from './ContextMenu.vue'
|
||||||
import { TABS, useBookshelf } from './useBookshelf'
|
import { TABS, useBookshelf } from './useBookshelf'
|
||||||
import { moveToGroup, removeFromBookshelf } from '../api/bookshelf'
|
import { moveToGroup, removeFromBookshelf } from '../api/bookshelf'
|
||||||
|
import { startDownload } from '../download'
|
||||||
|
import { settings } from '../settings'
|
||||||
import type { BookShelfEntry, BookShelfGroup, BookShelfTabKey, MenuItem } from '../types'
|
import type { BookShelfEntry, BookShelfGroup, BookShelfTabKey, MenuItem } from '../types'
|
||||||
|
|
||||||
/** 持续悬停多久后展示详情 */
|
/** 持续悬停多久后展示详情 */
|
||||||
@@ -266,6 +268,7 @@ function showToast(msg: string) {
|
|||||||
|
|
||||||
const MOVE_PREFIX = 'move:'
|
const MOVE_PREFIX = 'move:'
|
||||||
const NO_GROUP_KEY = `${MOVE_PREFIX}`
|
const NO_GROUP_KEY = `${MOVE_PREFIX}`
|
||||||
|
const DOWNLOAD_PREFIX = 'download:'
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
const entry = menuEntry.value
|
const entry = menuEntry.value
|
||||||
@@ -279,7 +282,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
// 已经在某个分组里时,额外给一个移出分组的选项
|
// 已经在某个分组里时,额外给一个移出分组的选项
|
||||||
if (current) targets.push({ key: NO_GROUP_KEY, label: '无分组' })
|
if (current) targets.push({ key: NO_GROUP_KEY, label: '无分组' })
|
||||||
|
|
||||||
return [
|
const items: MenuItem[] = [
|
||||||
{ key: 'open', label: '打开' },
|
{ key: 'open', label: '打开' },
|
||||||
{ key: 'detail', label: '查看详情' },
|
{ key: 'detail', label: '查看详情' },
|
||||||
{
|
{
|
||||||
@@ -287,9 +290,20 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
label: '移动到分组',
|
label: '移动到分组',
|
||||||
disabled: targets.length === 0,
|
disabled: targets.length === 0,
|
||||||
children: targets
|
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 }) {
|
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}`
|
unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`
|
||||||
return
|
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 (key === 'remove') {
|
||||||
if (!unsafeWindow.confirm(`确定要把《${entry.detail?.title ?? bookId}》从书架删除吗?`)) return
|
if (!unsafeWindow.confirm(`确定要把《${entry.detail?.title ?? bookId}》从书架删除吗?`)) return
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -5,6 +5,7 @@ import SearchLanding from './SearchLanding.vue'
|
|||||||
import ContextMenu from './ContextMenu.vue'
|
import ContextMenu from './ContextMenu.vue'
|
||||||
import { useSearch } from './useSearch'
|
import { useSearch } from './useSearch'
|
||||||
import { addToBookshelf } from '../api/bookshelf'
|
import { addToBookshelf } from '../api/bookshelf'
|
||||||
|
import { startDownload } from '../download'
|
||||||
import { settings } from '../settings'
|
import { settings } from '../settings'
|
||||||
import { userState } from '../api/user'
|
import { userState } from '../api/user'
|
||||||
import { pushQuery, routeQuery } from './searchRoute'
|
import { pushQuery, routeQuery } from './searchRoute'
|
||||||
@@ -36,6 +37,8 @@ const {
|
|||||||
const input = ref(routeQuery.value)
|
const input = ref(routeQuery.value)
|
||||||
const filterOpen = ref(false)
|
const filterOpen = ref(false)
|
||||||
|
|
||||||
|
const DOWNLOAD_PREFIX = 'download:'
|
||||||
|
|
||||||
/** 无搜索词时展示落地页 */
|
/** 无搜索词时展示落地页 */
|
||||||
const showLanding = computed(() => !query.value.trim())
|
const showLanding = computed(() => !query.value.trim())
|
||||||
|
|
||||||
@@ -120,15 +123,28 @@ function showToast(msg: string) {
|
|||||||
}, 2600)
|
}, 2600)
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => [
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
{ key: 'detail', label: '查看详情' },
|
const items: MenuItem[] = [
|
||||||
{
|
{ key: 'detail', label: '查看详情' },
|
||||||
key: 'shelf',
|
{
|
||||||
label: menuBook.value?.in_bookshelf ? '已在书架' : '加入书架',
|
key: 'shelf',
|
||||||
disabled: !userState.isLogin || Boolean(menuBook.value?.in_bookshelf),
|
label: menuBook.value?.in_bookshelf ? '已在书架' : '加入书架',
|
||||||
},
|
disabled: !userState.isLogin || Boolean(menuBook.value?.in_bookshelf),
|
||||||
{ key: 'author', label: '搜索该作者', disabled: !menuBook.value?.author },
|
},
|
||||||
])
|
{ 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 }) {
|
function onCardContextMenu({ book, x, y }: { book: SearchBook; x: number; y: number }) {
|
||||||
menuBook.value = book
|
menuBook.value = book
|
||||||
@@ -152,6 +168,11 @@ async function onMenuSelect(key: string) {
|
|||||||
void doSearch(book.author)
|
void doSearch(book.author)
|
||||||
return
|
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') {
|
if (key === 'shelf') {
|
||||||
try {
|
try {
|
||||||
await addToBookshelf(book.book_id)
|
await addToBookshelf(book.book_id)
|
||||||
|
|||||||
+118
-2
@@ -1,18 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
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 { provisionDevice } from '../api/provision'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { version } from '../../package.json'
|
import { version } from '../../package.json'
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
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 }> = [
|
const SECTIONS: Array<{ key: SectionKey; label: string }> = [
|
||||||
{ key: 'general', label: '常规' },
|
{ key: 'general', label: '常规' },
|
||||||
{ key: 'ui', label: '界面' },
|
{ key: 'ui', label: '界面' },
|
||||||
{ key: 'search', label: '搜索' },
|
{ key: 'search', label: '搜索' },
|
||||||
|
{ key: 'download', label: '下载' },
|
||||||
{ key: 'protocol', label: '协议' },
|
{ key: 'protocol', label: '协议' },
|
||||||
{ key: 'about', label: '关于' },
|
{ key: 'about', label: '关于' },
|
||||||
]
|
]
|
||||||
@@ -169,6 +178,113 @@ const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant'
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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'">
|
<template v-else-if="active === 'protocol'">
|
||||||
<h3 class="fqa-set-h">协议</h3>
|
<h3 class="fqa-set-h">协议</h3>
|
||||||
|
|||||||
Reference in new issue
Block a user