feat & refactor: 支持app端签名的请求,支持右键操作书架书籍,逻辑大幅重构,更改默认的设备逻辑为自动注册,支持载入某些书籍内置的CSS、支持跳转正文注释等等

This commit is contained in:
naiy committed 2026-08-03 16:17:49 +08:00
1 parent 2f2ee606c0
commit 3586b01dbb
47 files changed
+3590 -239

No files matched your search

+2
View File
@@ -23,3 +23,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
temp
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fanqie-assistant",
"version": "0.0.3",
"version": "0.0.4",
"type": "module",
"scripts": {
"dev": "vite",
+103
View File
@@ -0,0 +1,103 @@
import apiFetch from '../utils/request'
import { signRequest } from '../crypto/sign'
import config from '../config'
import { settings } from '../settings'
export const appBaseUrl = 'https://reading.snssdk.com/reading'
export const redcandleBaseUrl = 'https://api5-sinfonlinec.jxbhmy.com/reading'
export const appUserAgent = 'com.dragon.read'
export function buildAppQuery(extra?: Record<string, string>): URLSearchParams {
const c = config.currentConfig
return new URLSearchParams({
iid: c.install_id,
device_id: c.device_id,
ac: 'wifi',
channel: '43536163a',
aid: '1967',
app_name: 'novelapp',
version_code: '70132',
version_name: '7.0.1.32',
device_platform: 'android',
os: 'android',
ssmix: 'a',
os_version: '10',
device_type: c.device_type || 'P30',
device_brand: c.device_brand || 'realme',
...extra,
})
}
function isUsable(res: any): boolean {
if (!res || res.status !== 200) return false
try {
const j = res.json()
return !j || j.code === undefined || j.code === 0
} catch {
return false
}
}
async function requestApp(path: string, query?: Record<string, string>, headers?: Record<string, string>) {
const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}`
const signed = await signRequest(url)
return apiFetch(url, {
method: 'GET',
headers: { ...signed, 'User-Agent': appUserAgent, ...headers },
})
}
async function requestRedcandle(path: string, query?: Record<string, string>, headers?: Record<string, string>) {
const url = `${redcandleBaseUrl}${path}?${buildAppQuery(query).toString()}`
return apiFetch(url, {
method: 'GET',
headers: { 'User-Agent': appUserAgent, ...headers },
})
}
export async function appGet(
path: string,
query?: Record<string, string>,
headers?: Record<string, string>,
): Promise<any> {
if (settings.apiPreference === 'redcandle') {
try {
const res = await requestRedcandle(path, query, headers)
if (isUsable(res)) return res
console.warn(`[fqa:api] 红烛接口数据不全,回落到番茄 APP: ${path}`)
} catch (e) {
console.warn(`[fqa:api] 红烛接口请求失败,回落到番茄 APP: ${path}`, e)
}
}
return requestApp(path, query, headers)
}
export async function appPost(
path: string,
body: string,
query?: Record<string, string>,
headers?: Record<string, string>,
): Promise<any> {
const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}`
const signed = await signRequest(url, body)
console.log('---start--- APP POST ', url)
const res = await apiFetch(url, {
method: 'POST',
headers: {
...signed,
'User-Agent': appUserAgent,
'Content-Type': 'application/json; charset=utf-8',
...headers,
},
body,
})
console.log('---complete--- APP POST ', url, res)
return res
}
+6 -2
View File
@@ -1,5 +1,5 @@
import apiFetch from '../utils/request'
import type { Book } from '../types'
import { appGet } from './app'
import { getCatalog } from './catalog'
import moment from 'moment'
@@ -16,8 +16,12 @@ export function mappingCreationStatus(status: string): string {
}
}
/**
* 书籍详情,对应 APP 的 /bookapi/multi-detail/v。
* 响应字段与红烛接口一致,签名通用。
*/
export async function getBookInfoRaw(bookId: string): Promise<any> {
const response = await apiFetch(`https://api5-sinfonlinec.jxbhmy.com/reading/bookapi/multi-detail/v?book_id=${bookId}&aid=1967`)
const response = await appGet('/bookapi/multi-detail/v', { book_id: bookId })
const j: any = response.json()
console.log('Book Info:', j)
if (
+207
View File
@@ -0,0 +1,207 @@
// 书籍随正文下发的 CSS 处理。
//
// 番茄的正文 XHTML 会 <link> 到 Styles/main.css 之类的样式表,真实地址在
// novel_data.css_map(JSON 字符串,形如 {"Styles/main.css":"novel-static/xxx"})。
// 这些样式表是给 EPUB 阅读器用的,直接注入页面会有两个问题:
// 1. 里面有 `body{font-family:宋体;color:#000000}` 这类元素级规则,会命中页面的
// <body>,把整站字体和颜色改掉,深色模式直接失效;
// 2. `a{text-decoration:none}` 这类裸标签选择器会污染正文以外的元素。
// 所以统一做作用域重写,把所有选择器限制在正文容器内。
/** css_uri 的 CDN 前缀 */
const CDN_PREFIX = 'https://p3-novel.byteimg.com/origin/'
/** uri -> css 文本 */
const cssCache = new Map<string, string>()
/** 去掉 CSS 注释 */
function stripComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, '')
}
/**
* 把单条选择器限定到容器内。
* body / html 映射为容器自身,其余作为后代选择器。
*
* 容器选择器包在 :where() 里,特异性归零——否则 `#fqa-reader-content .body`
* 是 (1,1,0),会压过页面控制字号的 `.muye-reader-content-16 p` (0,1,1),
* 导致网页的字体大小调节(16/20/24/28/32)失效。包上之后书籍样式只在
* 页面没有对应规则时生效,字号调节始终归页面管。
*/
function scopeSelector(selector: string, scope: string): string {
const s = selector.trim()
if (!s) return ''
const where = `:where(${scope})`
if (/^(body|html|:root)$/i.test(s)) return where
// body.foo / html>p 这类以 body/html 开头的,替换掉开头部分
const m = s.match(/^(body|html)\b([\s\S]*)$/i)
if (m) return `${where}${m[2]}`
return `${where} ${s}`
}
/**
* 容器自身的规则需要额外去掉两类声明:
* - color / background:会压过页面主题,深色模式下变成黑字
* - font-size:书籍样式里是 `font-size:100%` 这种绝对基准,会把 em 的
* 计算基准钉死在默认字号上,页面调大字号后正文不跟着变
*/
function stripRootDecls(body: string): string {
return body
.split(';')
.filter((decl) => !/^\s*(color|background|background-color|font-size)\s*:/i.test(decl))
.join(';')
}
/**
* 将一段 CSS 的所有选择器限定到 scope 内。
*
* 逐字符扫描而不是单条正则:@font-face / @media 的 prelude 不是选择器,
* 不能被加上前缀,@media 内部的规则则仍需要处理。
*/
export function scopeCss(css: string, scope: string): string {
const src = stripComments(css)
let out = ''
let buf = ''
let i = 0
while (i < src.length) {
const ch = src[i]!
if (ch === '{') {
const prelude = buf.trim()
buf = ''
i++
if (prelude.startsWith('@')) {
if (/^@(media|supports|document)\b/i.test(prelude)) {
// 条件组:prelude 原样保留,内部递归处理
const inner = readBlock(src, i)
out += `${prelude}{${scopeCss(inner.text, scope)}}`
i = inner.end
} else {
// @font-face / @keyframes 等:整块原样保留
const inner = readBlock(src, i)
out += `${prelude}{${inner.text}}`
i = inner.end
}
continue
}
// 普通规则
const inner = readBlock(src, i)
const selectors = prelude
.split(',')
.map((s) => scopeSelector(s, scope))
.filter(Boolean)
const isRoot = selectors.length === 1 && selectors[0] === `:where(${scope})`
const declarations = isRoot ? stripRootDecls(inner.text) : inner.text
if (selectors.length && declarations.trim()) {
out += `${selectors.join(',')}{${declarations}}`
}
i = inner.end
continue
}
// @import 之类的独立语句直接丢弃,避免把外部样式拉进页面
if (ch === ';' && buf.trim().startsWith('@')) {
buf = ''
i++
continue
}
buf += ch
i++
}
return out
}
/** 从 start('{' 之后)读到配对的 '}',返回块内文本与结束位置 */
function readBlock(src: string, start: number): { text: string; end: number } {
let depth = 1
let i = start
while (i < src.length && depth > 0) {
const c = src[i]!
if (c === '{') depth++
else if (c === '}') depth--
if (depth === 0) break
i++
}
return { text: src.slice(start, i), end: Math.min(i + 1, src.length) }
}
/** 解析 css_map(JSON 字符串)为 { 文件名: uri } */
export function parseCssMap(cssMap: unknown): Record<string, string> {
if (!cssMap || typeof cssMap !== 'string') return {}
try {
const parsed = JSON.parse(cssMap)
return parsed && typeof parsed === 'object' ? parsed as Record<string, string> : {}
} catch {
return {}
}
}
/** 按 uri 拉取样式表,带内存缓存 */
async function fetchCss(uri: string): Promise<string> {
const cached = cssCache.get(uri)
if (cached !== undefined) return cached
try {
// CDN 自带 Access-Control-Allow-Origin: *,直接用原生 fetch 即可,
// 不需要 GM_xmlhttpRequest,也就不必在 @connect 里声明这个域名
const res = await fetch(CDN_PREFIX + uri)
const text = res.ok ? await res.text() : ''
cssCache.set(uri, text)
return text
} catch (e) {
console.warn('获取书籍样式表失败:', uri, e)
cssCache.set(uri, '')
return ''
}
}
/**
* 取出某本书的正文样式,并限定到 scope 选择器内。
*
* 注意 css_map 只覆盖了一部分样式表:正文里常见的 Styles/bd_stylesheet.css
* 通常不在 map 里,服务端也没给地址,只能忽略——它对应的是番茄自带的
* 基础排版,缺失时靠页面本身的样式兜底即可。
*
* @param cssMap novel_data.css_map 原始值
* @param scope 正文容器选择器,如 '#fqa-reader-content'
*/
export async function getScopedBookCss(cssMap: unknown, scope: string): Promise<string> {
const map = parseCssMap(cssMap)
const uris = Object.values(map).filter(Boolean)
if (uris.length === 0) return ''
const sheets = await Promise.all(uris.map(fetchCss))
return sheets
.filter(Boolean)
.map((css) => scopeCss(css, scope))
.join('\n')
}
/**
* 把书籍样式注入页面(同一 book 只注入一次,切书时替换)。
*
* @returns 是否成功注入了非空样式
*/
export async function applyBookCss(
cssMap: unknown,
scope: string,
styleId = 'fqa-book-style',
): Promise<boolean> {
const css = await getScopedBookCss(cssMap, scope)
let el = document.getElementById(styleId) as HTMLStyleElement | null
if (!css) {
el?.remove()
return false
}
if (!el) {
el = document.createElement('style')
el.id = styleId
document.head.appendChild(el)
}
el.textContent = css
return true
}
+66 -1
View File
@@ -1,9 +1,11 @@
import { fetch } from '../config'
import type { BookShelfBookInfo, BookShelfItem } from '../types'
const SHELF_BASE = 'https://fanqienovel.com/reading/bookapi/bookshelf'
export async function getBookshelf(): Promise<BookShelfItem[]> {
const responses = await Promise.all([
fetch('https://fanqienovel.com/reading/bookapi/bookshelf/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700'),
fetch(SHELF_BASE + '/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700'),
fetch('https://fanqienovel.com/api/reader/book/progress')
])
const response = responses[0]
@@ -83,3 +85,66 @@ export async function multidetail(books: BookShelfItem[]): Promise<BookShelfBook
}
return results
}
interface ShelfIdentify {
asterisked: boolean
book_id: string
book_type: number
modify_time: number
}
function identify(bookId: string, modifyTime = 0): ShelfIdentify {
return {
asterisked: false,
book_id: bookId,
book_type: 0,
modify_time: modifyTime,
}
}
async function shelfPost(path: string, body: unknown): Promise<any> {
const res = await fetch(`${SHELF_BASE}${path}/v?aid=1967`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok || (json && json.code !== 0 && json.code !== undefined)) {
throw new Error(`书架操作失败(${path}): ${json?.message ?? res.status}`)
}
return json
}
/** 加入书架 */
export async function addToBookshelf(bookId: string): Promise<void> {
await shelfPost('/add', {
add_book_source: 0,
identify_data: [identify(bookId)],
})
}
/** 从书架移除 */
export async function removeFromBookshelf(bookId: string): Promise<void> {
await shelfPost('/delete', {
identify_data: [identify(bookId, Date.now())],
})
}
/** 分组间移动 */
export async function moveToGroup(bookId: string, groupName: string): Promise<void> {
await shelfPost('/update', {
book_data: [
{
asterisked: false,
book_id: bookId,
book_type: 0,
group_name: groupName,
has_shown: false,
is_pin: false,
modify_time: Date.now(),
},
],
})
}
+24 -16
View File
@@ -1,43 +1,51 @@
import apiFetch from '../utils/request'
import type { Book, CatalogResult, ChapterItem, VolumeItem } from '../types'
import { appGet } from './app'
import moment from 'moment'
/**
* 获取目录原始数据,对应 APP 的 /bookapi/directory/all_items/v。
*
* 章节明细在 data.item_data_list(含 volume_name / title / first_pass_time /
* chapter_word_number);data.catalog_data 只有 item_id 和标题,信息不全。
*/
export async function getCatalogRaw(bookId: string): Promise<any> {
const response = await apiFetch(`https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId}`)
const response = await appGet('/bookapi/directory/all_items/v', { book_id: bookId })
const j: any = response.json()
if (j.code !== 0 || j.data.chapterListWithVolume.length === 0) {
const items = j?.data?.item_data_list
if (j?.code !== 0 || !Array.isArray(items) || items.length === 0) {
throw new Error('Empty catalog')
}
return [j.data.chapterListWithVolume, j.data.allItemIds]
return [items, items.map((it: any) => String(it.item_id))]
}
export async function getCatalog(bookId: string): Promise<CatalogResult> {
const r = await getCatalogRaw(bookId) as Array<unknown>
const catalogRaw: any = r[0] // chapter list with volume
const catalogRaw = r[0] as any[] // item_data_list
const allItemIds = r[1] as string[] // all item ids
const vmap: Record<string, VolumeItem> = {} // title -> VolumeItem
const chapters: Array<ChapterItem> = []
// const str = ''
catalogRaw[0].map((item: any): void => {
// console.debug(item)
catalogRaw.forEach((item: any): void => {
const volumeName = item.volume_name ?? ''
const chapterItem = {
item_id: item.itemId,
item_id: String(item.item_id),
title: item.title,
// YYYY-MM-DD HH:mm:ss
update_time: moment(item.firstPassTime * 1000).format('YYYY-MM-DD HH:mm:ss') as string,
char_count: item.charCount || 0,
volume_title: item.volume_name,
update_time: moment(item.first_pass_time * 1000).format('YYYY-MM-DD HH:mm:ss') as string,
char_count: item.chapter_word_number || 0,
volume_title: volumeName,
} as ChapterItem
chapters.push(chapterItem)
if (!vmap[item.volume_name]) {
vmap[item.volume_name] = {
title: item.volume_name,
if (!vmap[volumeName]) {
vmap[volumeName] = {
title: volumeName,
book_id: bookId,
chapter_list: [],
} as VolumeItem
}
(vmap[item.volume_name] as VolumeItem).chapter_list.push(chapterItem)
(vmap[volumeName] as VolumeItem).chapter_list.push(chapterItem)
})
return {
book_id: bookId,
volume_list: Object.values(vmap),
+73 -16
View File
@@ -1,17 +1,18 @@
import config, { type KeyInfo } from '../config'
import { get, post } from '.'
import { encryptKeyinfoBody, decryptKeyinfoResponse, decryptChapter, b64decode, b64encode } from '../utils/crypto'
import { appGet, appPost } from './app'
import { b64decode, b64encode } from '../crypto'
import { read, write } from '../localStorage'
import { decryptChapter } from '../crypto/content'
import { encryptKeyinfoBody, decryptKeyinfoResponse } from '../crypto/registerkey'
async function refreshKeyinfo(): Promise<void> {
const b = await encryptKeyinfoBody(config.currentConfig)
const res = await post('/reader/crypt/registerkey/v1', false, {}, b)
console.log(res)
const res = await appPost('/crypt/registerkey', b)
const j = res.json()
console.log('response: ', j)
const ek = j?.data?.key
if (!ek) {
throw new Error('Failed to get key info')
throw new Error(`Failed to get key info: ${res.responseText}`)
}
const key = await decryptKeyinfoResponse(ek)
const keyinfo: KeyInfo = {
@@ -35,13 +36,6 @@ async function ensureKeyinfo(expectedKeyVersion?: number): Promise<void> {
key: b64decode(cachedKeyInfo.key),
keyver: cachedKeyInfo.keyver,
} as KeyInfo
/*
console.log({
expectedKeyVersion,
cki,
assert: cki.keyver === expectedKeyVersion, // assert key version
})
*/
if (typeof expectedKeyVersion === 'undefined' || cki.keyver === expectedKeyVersion) {
config.currentConfig.key_info = cki
return
@@ -57,6 +51,7 @@ async function ensureKeyinfo(expectedKeyVersion?: number): Promise<void> {
}
}
export async function getChapter(itemId: string, _retry?: number): Promise<any> {
if (typeof _retry === 'undefined') _retry = 0
if (_retry > 5) {
@@ -65,12 +60,11 @@ export async function getChapter(itemId: string, _retry?: number): Promise<any>
if (!config.currentConfig.key_info) {
await ensureKeyinfo()
}
const res = await get(`/reader/full/v1`, false, { item_id: itemId })
const res = await appGet('/reader/full/v', { item_id: itemId, req_type: '1' })
const j = res.json()?.data
if (!j) {
console.warn('Failed to get chapter: ', itemId, ', response: ', j)
console.warn('Failed to get chapter: ', itemId, ', response: ', res.responseText)
return await getChapter(itemId, _retry + 1)
// throw new Error('Failed to get chapter')
}
if (j?.content === 'Invalid' || j?.key_version !== config.currentConfig.key_info?.keyver) { // keyreg expired
console.warn('Key reg expired, regster again and retrying...')
@@ -81,6 +75,69 @@ export async function getChapter(itemId: string, _retry?: number): Promise<any>
return j
}
export interface BatchChapter {
item_id: string;
content?: unknown;
key_version?: number;
novel_data?: any; /* 章节对应的书籍数据,结构比较复杂,所以直接用any */
/** 解密失败或章节不可用时的原因 */
error?: string;
[key: string]: unknown;
}
export async function getChapters(
itemIds: string[],
bookId = '0',
): Promise<Record<string, BatchChapter>> {
if (itemIds.length === 0) return {}
if (!config.currentConfig.key_info) {
await ensureKeyinfo()
}
const res = await appGet('/reader/batch_full/v', {
item_ids: itemIds.join(','),
book_id: bookId,
novel_text_type: '1',
req_type: '1',
})
const raw = res.json()?.data
const entries: Array<[string, any]> = raw && typeof raw === 'object'
? (Array.isArray(raw)
? raw.map((it: any) => [String(it?.item_id ?? it?.novel_data?.item_id ?? ''), it])
: Object.entries(raw))
: []
if (entries.length === 0) {
throw new Error(`Failed to batch get chapters: ${res.responseText}`)
}
const results: Record<string, BatchChapter> = {}
for (const [id, item] of entries) {
if (!id) continue
if (item?.code !== undefined && item.code !== 0) {
results[id] = { ...item, item_id: id, error: `code ${item.code}` }
continue
}
if (!item?.content || item.content === 'Invalid') {
results[id] = { ...item, item_id: id, error: 'Invalid content' }
continue
}
try {
results[id] = {
...item,
item_id: id,
content: await decryptChapter(item.content, config.currentConfig),
}
} catch (e) {
results[id] = { ...item, item_id: id, error: String(e) }
}
}
return results
}
export async function getChapterInfo(itemId: string): Promise<unknown> {
// use page fetch (includes bytedance security sdk. we reuse it)
// same origin
+218
View File
@@ -0,0 +1,218 @@
// 设备注册、会员激活、密钥注册。
// 移植自 fq-go-api/fanqie/device/register.go
import apiFetch from '../utils/request'
import { b64encode, b64decode, getCrypto, getSubtle, hex } from '../crypto'
import { encrypt as encryptTT } from '../crypto/ttencrypt'
import { signRequest } from '../crypto/sign'
import { gzip } from '../utils/compress'
import { shared_key, type DeviceConfig, type KeyInfo } from '../config'
import { deviceValue, generateRequestBody } from './devicebody'
const REGISTER_URL = 'https://i.snssdk.com/service/2/device_register/?tt_data=a'
const READING_BASE = 'https://reading.snssdk.com'
const USER_AGENT = 'com.dragon.read'
/** 设备注册结果 */
export interface RegisteredDevice {
device_id: string;
install_id: string;
device_type: string;
}
/**
* 公共查询参数,对应 Go 里 maps.Clone(header) 之后 Set 的那一组。
*
* @param versionCode registerkey 用 "6.3.9.32",privilege/add 用 "63932"
*/
function buildQuery(
device: RegisteredDevice,
versionCode: string,
extra?: Record<string, string>,
): URLSearchParams {
return new URLSearchParams({
device_id: device.device_id,
iid: device.install_id,
device_type: device.device_type,
aid: deviceValue.aid,
app_name: deviceValue.appName,
channel: deviceValue.channel,
device_platform: deviceValue.platform,
os_version: deviceValue.osVersion,
version_code: versionCode,
...extra,
})
}
/**
* 注册新设备。
*
* 请求体经 TT 算法加密(gzip + SHA512 校验 + AES-CBC),该接口不需要签名。
*/
export async function registerDevice(): Promise<RegisteredDevice> {
const body = generateRequestBody()
const encrypted = await encryptTT(
new TextEncoder().encode(JSON.stringify(body)).buffer as ArrayBuffer,
)
const res = await apiFetch(REGISTER_URL, {
method: 'POST',
headers: {
'User-Agent': 'okhttp/4.10.0',
'Content-Type': 'application/octet-stream; tt-data=a',
},
body: encrypted,
})
if (res.status !== 200) {
throw new Error(`设备注册失败: HTTP ${res.status} ${res.statusText}`)
}
const json = res.json<{
device_id?: number;
device_id_str?: string;
install_id_str?: string;
}>()
if (!json?.device_id || !json.device_id_str || !json.install_id_str) {
throw new Error(`设备注册失败: device_id 无效, 响应=${res.responseText}`)
}
const device: RegisteredDevice = {
device_id: json.device_id_str,
install_id: json.install_id_str,
device_type: body.header.device_model,
}
console.log('设备注册成功!', device)
return device
}
/**
* 尝试激活设备会员,返回过期时间;失败返回空字符串(与 Go 一致,不抛异常)。
*
* 注意 privilege_id 超出 Number.MAX_SAFE_INTEGER,必须手工拼 JSON 字符串,
* 否则 JSON.stringify 会把它写成 7210376203117532000。
*/
export async function activatePremium(device: RegisteredDevice): Promise<string> {
const url = `${READING_BASE}/reading/user/privilege/add/v?`
+ buildQuery(device, deviceValue.versionCode.val, {
manifest_version_code: deviceValue.versionCode.val,
update_version_code: deviceValue.versionCode.val,
}).toString()
const body = '{"add_count_daily":0,"amount":2592000,'
+ '"privilege_id":7210376203117531962,"from":8,'
+ `"unique_key":"${Date.now()}"}`
try {
const headers = await signRequest(url, body)
const res = await apiFetch(url, {
method: 'POST',
headers: {
...headers,
'User-Agent': USER_AGENT,
'Content-Type': 'application/json; charset=utf-8',
},
body,
})
const json = res.json<{ code?: number; data?: { expire_time?: string } }>()
if (json?.code !== 0) {
console.warn('设备会员激活失败:', res.responseText)
return ''
}
const expireTime = json.data?.expire_time ?? ''
console.log('设备会员已成功激活!过期时间:', expireTime)
return expireTime
} catch (e) {
console.warn('设备会员激活失败:', e)
return ''
}
}
/**
* 注册并获取内容解密密钥。
*
* 请求体是 device_id 转 16 字节大端后反转为小端,用静态密钥 AES-CBC 加密,
* 整体 gzip 压缩后带签名发送。
*/
export async function registerKey(device: RegisteredDevice): Promise<KeyInfo> {
const url = `${READING_BASE}/reading/crypt/registerkey?`
+ buildQuery(device, deviceValue.versionCode.str).toString()
// device_id -> 16 字节大端 -> 反转为小端
const idBytes = new Uint8Array(16)
let id = BigInt(device.device_id)
for (let i = 15; i >= 0; i--) {
idBytes[i] = Number(id & 0xffn)
id >>= 8n
}
idBytes.reverse()
const subtle = getSubtle()
const iv = getCrypto().getRandomValues(new Uint8Array(16))
const key = await subtle.importKey('raw', shared_key, { name: 'AES-CBC' }, false, ['encrypt'])
const encrypted = new Uint8Array(
await subtle.encrypt({ name: 'AES-CBC', iv }, key, idBytes),
)
const content = new Uint8Array(iv.length + encrypted.length)
content.set(iv)
content.set(encrypted, iv.length)
const plainBody = JSON.stringify({ content: b64encode(content.buffer) })
const gzipped = await gzip(plainBody)
// 签名针对 gzip 之后的字节,与 Go 里 SignedRequest 收到压缩流的行为一致
const headers = await signRequest(url, gzipped)
const res = await apiFetch(url, {
method: 'POST',
headers: {
...headers,
'User-Agent': USER_AGENT,
'Content-Type': 'application/json; charset=utf-8',
'Content-Encoding': 'gzip',
},
body: gzipped,
})
if (res.status !== 200) {
throw new Error(`密钥注册失败: HTTP ${res.status} ${res.statusText}`)
}
const json = res.json<{ data?: { key?: string; keyver?: number } }>()
const encryptedKey = json?.data?.key
if (!encryptedKey) {
throw new Error(`密钥注册失败: 响应缺少 key, 响应=${res.responseText}`)
}
// 响应里的密钥同样是 IV(16) + 密文,用静态密钥解开
const buf = b64decode(encryptedKey)
const decryptKey = await subtle.importKey(
'raw', shared_key, { name: 'AES-CBC' }, false, ['decrypt'],
)
const finalKey = await subtle.decrypt(
{ name: 'AES-CBC', iv: buf.slice(0, 16) },
decryptKey,
buf.slice(16),
)
const keyInfo: KeyInfo = { key: finalKey, keyver: json.data?.keyver }
console.log('密钥获取成功,版本:', keyInfo.keyver, 'key:', hex(finalKey))
return keyInfo
}
export async function registerFullDevice(): Promise<DeviceConfig & { vip_expire_time: string }> {
const device = await registerDevice()
const vipExpireTime = await activatePremium(device)
const keyInfo = await registerKey(device)
return {
device_id: device.device_id,
install_id: device.install_id,
device_type: device.device_type,
key_info: keyInfo,
vip_expire_time: vipExpireTime,
}
}
+181
View File
@@ -0,0 +1,181 @@
// 设备注册请求体构造。
// 移植自 fq-go-api/fanqie/device/register.go 的 generateFullRequestBody
import { getCrypto } from "../crypto";
/** Android 版本与 API 级别 */
const ANDROID_VERSIONS: ReadonlyArray<{ version: string; api: number }> = [
{ version: "9", api: 29 },
{ version: "10", api: 30 },
{ version: "11", api: 31 },
{ version: "12", api: 32 },
{ version: "13", api: 33 },
{ version: "14", api: 34 },
];
const DEVICE_MODELS = [
"RMX1931", "MI8", "Honor10", "P30", "V1921A",
"Redmi Note 7", "Redmi K20 Pro", "MI 9", "Mi 10 Pro",
"SM-G9750", "Pixel 6", "HD1910", "M2011K2C",
"LIO-AN00", "VOG-TL00", "PCLM10", "GM1900",
"Pixel 7 Pro", "Pixel 6a", "SM-N9760", "POCO F1",
] as const;
const DEVICE_BRANDS = [
"realme", "Xiaomi", "Huawei", "OPPO", "vivo",
"samsung", "OnePlus", "google", "Redmi", "HONOR", "motorola", "POCO",
] as const;
const HEX_LOW = "0123456789abcdef";
/** 默认请求参数值,对应 Go 的 device.Value */
export const deviceValue = {
aid: "1967",
appName: "novelapp",
channel: "0",
platform: "android",
osVersion: "0",
versionCode: {
str: "6.3.9.32",
val: "63932",
},
} as const;
/** [0, max) 区间的随机整数 */
function randomInt(max: number): number {
return getCrypto().getRandomValues(new Uint32Array(1))[0]! % max;
}
function randomItem<T>(list: readonly T[]): T {
return list[randomInt(list.length)]!;
}
/** 长度为 k 的随机小写十六进制字符串 */
export function randomHex(k: number): string {
if (k <= 0) return "";
const bytes = getCrypto().getRandomValues(new Uint8Array(k));
let result = "";
for (let i = 0; i < k; i++) {
result += HEX_LOW[bytes[i]! % 16];
}
return result;
}
/** 生成 UUID v4 */
function uuid(): string {
const c = getCrypto();
if (typeof c.randomUUID === "function") {
return c.randomUUID();
}
// 兜底:手工组装 v4
const b = c.getRandomValues(new Uint8Array(16));
b[6] = (b[6]! & 0x0f) | 0x40;
b[8] = (b[8]! & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
/** 链路本地 IPv6 地址。浏览器拿不到网卡地址,与 Go 的失败分支一致返回 ::1 */
function ipv6LinkLocal(): string {
return "::1";
}
/** 唯一本地 IPv6 地址 fdxx:xxxx:... */
function ipv6UniqueLocal(): string {
const x = randomInt(1 << 8);
let result = `fd${HEX_LOW[x >> 4]}${HEX_LOW[x & 0x0f]}`;
for (let i = 0; i < 7; i++) {
const v = randomInt(1 << 16);
result += ":"
+ HEX_LOW[(v >> 12) & 0x0f]
+ HEX_LOW[(v >> 8) & 0x0f]
+ HEX_LOW[(v >> 4) & 0x0f]
+ HEX_LOW[v & 0x0f];
}
return result;
}
export interface RegisterRequestBody {
magic_tag: string;
header: Record<string, unknown> & { device_model: string };
_gen_time: number;
}
/**
* 构造设备注册请求体。
*
* Go 的 config.RequestBody 内嵌了 Header 且带 `json:"header"` 标签,
* 序列化后 header 各字段是嵌套在 "header" 对象里的(已用 Go 实测确认)。
*/
export function generateRequestBody(): RegisterRequestBody {
const osInfo = randomItem(ANDROID_VERSIONS);
const deviceBrand = randomItem(DEVICE_BRANDS);
const genTime = Date.now();
const romVersion = "coloros__"
+ randomHex(4).toUpperCase()
+ "." + String(randomInt(1_000_000)).padStart(6, "0")
+ "." + String(randomInt(100_000_000)).padStart(8, "0")
+ " release-keys";
return {
magic_tag: "ss_app_log",
header: {
display_name: "番茄免费小说",
aid: 1967,
channel: "43536163a",
package: "com.dragon.read",
sdk_version: "3.7.0-rc.25-fanqie-xiaoshuo",
sdk_target_version: 29,
git_hash: "711d1a7",
density_dpi: 240,
display_density: "hdpi",
resolution: "720x1280",
language: "zh",
timezone: 8,
access: "wifi",
not_request_sender: 0,
carrier: "CHINA MOBILE",
mcc_mnc: "46000",
region: "CN",
tz_name: "Asia/Shanghai",
tz_offset: 28800,
sim_region: "cn",
sim_serial_number: [],
oaid_may_support: false,
device_platform: "android",
custom: { host_bit: 32, dragon_device_type: 0 },
pre_installed_channel: "",
is_system_app: 0,
sdk_flavor: "china",
guest_mode: 0,
// 设备硬件与系统信息
os: "Android",
os_version: osInfo.version,
os_api: osInfo.api,
device_model: randomItem(DEVICE_MODELS),
device_brand: deviceBrand,
device_manufacturer: deviceBrand,
cpu_abi: "arm64-v8a",
release_build: randomHex(7),
cdid: uuid(),
sig_hash: "a4a27c2633195374c15651ffc3c4a497",
openudid: randomHex(20),
clientudid: uuid(),
req_id: uuid(),
// 可选字段
rom: randomHex(14).toUpperCase(),
rom_version: romVersion,
apk_first_install_time: genTime - randomInt(365) * 86_400_000,
ipv6_list: [
{ type: "client_anpi", value: ipv6LinkLocal() },
{ type: "client_anpi", value: ipv6UniqueLocal() },
{ type: "client_anpi", value: ipv6UniqueLocal() },
],
},
_gen_time: genTime,
};
}
+103
View File
@@ -0,0 +1,103 @@
// 设备的持久化与初始化。
// 首次使用时注册新设备并激活会员,之后从本地缓存复用。
import config, { defaultConfig, type DeviceConfig } from '../config'
import { b64decode, b64encode } from '../crypto'
import { read, write } from '../localStorage'
import { registerDevice, activatePremium, registerKey } from './device'
import { settings } from '../settings'
const STORE_KEY = 'device'
interface StoredDevice {
device_id: string;
install_id: string;
device_type?: string;
device_brand?: string;
vip_expire_time?: string;
key?: string; // base64
keyver?: number;
}
function load(): DeviceConfig | null {
const s = read(STORE_KEY) as StoredDevice | null
if (!s?.device_id || !s?.install_id) return null
return {
device_id: s.device_id,
install_id: s.install_id,
device_type: s.device_type,
device_brand: s.device_brand,
key_info: s.key ? { key: b64decode(s.key), keyver: s.keyver } : undefined,
}
}
function save(c: DeviceConfig, vipExpireTime?: string): void {
const s: StoredDevice = {
device_id: c.device_id,
install_id: c.install_id,
device_type: c.device_type,
device_brand: c.device_brand,
vip_expire_time: vipExpireTime,
}
if (c.key_info?.key) {
s.key = b64encode(c.key_info.key)
s.keyver = c.key_info.keyver
}
write(STORE_KEY, s)
}
/**
* 注册一台新设备(含会员激活与密钥注册),并设为当前设备。
* 对应 Go 的 registerSingleDevice。
*/
export async function provisionDevice(): Promise<DeviceConfig> {
const dev = await registerDevice()
const vipExpireTime = await activatePremium(dev)
const keyInfo = await registerKey(dev)
const c: DeviceConfig = {
device_id: dev.device_id,
install_id: dev.install_id,
device_type: dev.device_type,
key_info: keyInfo,
}
config.currentConfig = c
save(c, vipExpireTime)
return c
}
/**
* 确保当前有可用设备。优先级:
* 设置里手填的设备 > 本地缓存的已注册设备 > 新注册
* 注册失败时退回内置匿名设备,保证功能可降级使用。
*/
export async function ensureDevice(): Promise<DeviceConfig> {
// 手填的设备信息优先,三项都填了才算有效
const { deviceId, installId, deviceType } = settings
if (deviceId.trim() && installId.trim()) {
const manual: DeviceConfig = {
device_id: deviceId.trim(),
install_id: installId.trim(),
device_type: deviceType.trim() || undefined,
// 手填设备没有密钥,正文接口会按需自行注册
key_info: undefined,
}
config.currentConfig = manual
console.log('使用设置里手填的设备:', manual.device_id)
return manual
}
const cached = load()
if (cached) {
config.currentConfig = cached
console.log('复用已缓存设备:', cached.device_id)
return cached
}
try {
return await provisionDevice()
} catch (e) {
console.warn('设备注册失败,回退到内置匿名设备:', e)
config.currentConfig = defaultConfig
return defaultConfig
}
}
+107
View File
@@ -645,3 +645,110 @@
border-left-color: rgba(255, 255, 255, 0.1);
}
}
/* ----------------------------- 右键菜单 ----------------------------- */
/*
* 菜单 Teleport 到 body,拿不到 #fqa-bookshelf 上的变量,
* 这里重新声明一份(与书架同色板),并给出深色模式回退。
*/
.fqa-menu {
--fqa-menu-bg: #fff;
--fqa-menu-text: #1f2329;
--fqa-menu-sub: #8f959e;
--fqa-menu-hover: rgba(31, 35, 41, 0.06);
--fqa-menu-danger: #f5222d;
position: fixed;
z-index: 2147483001;
min-width: 132px;
max-width: 240px;
padding: 4px;
box-sizing: border-box;
background: var(--fqa-menu-bg);
border: 1px solid rgba(31, 35, 41, 0.08);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(31, 35, 41, 0.16);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,
sans-serif;
font-size: 13px;
color: var(--fqa-menu-text);
user-select: none;
}
/* 二级面板:分组数量多时可滚动 */
.fqa-menu-sub {
max-height: 320px;
overflow-y: auto;
}
.fqa-menu-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 10px;
border-radius: 5px;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
}
.fqa-menu-row > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.fqa-menu-row:hover,
.fqa-menu-row.fqa-menu-open {
background: var(--fqa-menu-hover);
}
.fqa-menu-arrow {
color: var(--fqa-menu-sub);
font-size: 15px;
line-height: 1;
}
.fqa-menu-danger {
color: var(--fqa-menu-danger);
}
.fqa-menu-disabled {
color: var(--fqa-menu-sub);
cursor: not-allowed;
}
.fqa-menu-disabled:hover {
background: transparent;
}
/* 操作结果提示 */
.fqa-toast {
position: fixed;
left: 50%;
bottom: 48px;
transform: translateX(-50%);
z-index: 2147483002;
max-width: 80vw;
padding: 10px 18px;
box-sizing: border-box;
background: rgba(31, 35, 41, 0.88);
color: #fff;
border-radius: 8px;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 6px 24px rgba(31, 35, 41, 0.24);
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.fqa-menu {
--fqa-menu-bg: #23272e;
--fqa-menu-text: #e5e6eb;
--fqa-menu-sub: #8f959e;
--fqa-menu-hover: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.1);
}
}
+69
View File
@@ -47,3 +47,72 @@
#dynamic-el {
display: none!important;
}
.fqa-footnote-ref {
display: inline-block;
margin: 0 0.15em;
padding: 0 0.25em;
font-size: 0.7em;
line-height: 1.4;
vertical-align: super;
color: var(--web-brand_normal, #f14646);
cursor: pointer;
user-select: none;
border-radius: 3px;
text-indent: 0;
}
.fqa-footnote-ref:hover,
.fqa-footnote-ref:focus-visible {
background: var(--web-brand_light, rgba(241, 70, 70, 0.12));
outline: none;
}
.fqa-footnote {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid var(--web-gray_20, rgba(128, 128, 128, 0.25));
font-size: var(--fqa-body-size, 1.6rem);
}
.muye-reader-content-16 .fqa-footnote { font-size: var(--fqa-body-size, 1.6rem); }
.muye-reader-content-20 .fqa-footnote { font-size: var(--fqa-body-size, 2rem); }
.muye-reader-content-24 .fqa-footnote { font-size: var(--fqa-body-size, 2.4rem); }
.muye-reader-content-28 .fqa-footnote { font-size: var(--fqa-body-size, 2.8rem); }
.muye-reader-content-32 .fqa-footnote { font-size: var(--fqa-body-size, 3.2rem); }
.fqa-footnote-title {
margin-bottom: 0.6em;
font-size: 0.85em;
font-weight: 600;
color: var(--web-gray_40, #8a8a8a);
text-indent: 0;
}
.fqa-footnote-list {
margin: 0;
padding-left: 1.6em;
font-size: 0.85em;
line-height: 1.7;
color: var(--web-gray_40, #8a8a8a);
}
.fqa-footnote-list li {
margin-bottom: 0.5em;
text-indent: 0;
transition: background-color 0.3s ease;
}
.fqa-footnote-list li.fqa-footnote-active {
background: var(--web-brand_light, rgba(241, 70, 70, 0.12));
border-radius: 4px;
}
.muye-reader-content > body {
background-color: var(--web-bg)!important;
}
.fqa-icon-dark {
color: #B3B3B3
}
+326
View File
@@ -0,0 +1,326 @@
/* 助手设置面板 */
.fqa-set-mask {
position: fixed;
inset: 0;
z-index: 2147483200;
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;
color: var(--fqa-set-text, #1f2329);
}
.fqa-set-dialog {
--fqa-set-bg: #fff;
--fqa-set-side-bg: #f7f8fa;
--fqa-set-text: #1f2329;
--fqa-set-sub: #8f959e;
--fqa-set-border: rgba(31, 35, 41, 0.1);
--fqa-set-accent: #ff6f3d;
--fqa-set-hover: rgba(31, 35, 41, 0.05);
position: relative;
display: flex;
width: min(760px, 92vw);
height: min(520px, 84vh);
background: var(--fqa-set-bg);
color: var(--fqa-set-text);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.24);
}
.fqa-set-close {
position: absolute;
top: 10px;
right: 12px;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fqa-set-sub);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.fqa-set-close:hover {
background: var(--fqa-set-hover);
color: var(--fqa-set-text);
}
/* 左侧栏 */
.fqa-set-side {
flex: 0 0 148px;
padding: 16px 8px;
box-sizing: border-box;
background: var(--fqa-set-side-bg);
border-right: 1px solid var(--fqa-set-border);
overflow-y: auto;
}
.fqa-set-side-title {
padding: 0 10px 12px;
font-size: 15px;
font-weight: 600;
}
.fqa-set-nav {
padding: 8px 10px;
margin-bottom: 2px;
border-radius: 6px;
cursor: pointer;
user-select: none;
}
.fqa-set-nav:hover {
background: var(--fqa-set-hover);
}
.fqa-set-nav-active {
background: var(--fqa-set-hover);
color: var(--fqa-set-accent);
font-weight: 600;
}
/* 右侧内容 */
.fqa-set-main {
flex: 1 1 auto;
padding: 20px 24px;
box-sizing: border-box;
overflow-y: auto;
}
.fqa-set-h {
margin: 0 0 14px;
font-size: 16px;
font-weight: 600;
}
.fqa-set-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--fqa-set-border);
}
.fqa-set-row:last-child {
border-bottom: none;
}
.fqa-set-row-col {
display: block;
}
.fqa-set-label {
font-size: 14px;
}
.fqa-set-note {
margin: 6px 0 0;
font-size: 12px;
color: var(--fqa-set-sub);
}
.fqa-set-warn {
margin: 6px 0 10px;
padding: 8px 10px;
font-size: 12px;
color: #a8371f;
background: rgba(255, 111, 61, 0.1);
border-left: 3px solid var(--fqa-set-accent);
border-radius: 0 4px 4px 0;
}
/* 开关:用原生 checkbox 改造,避免额外依赖 */
.fqa-set-switch {
appearance: none;
flex: 0 0 auto;
position: relative;
width: 38px;
height: 22px;
margin: 0;
border-radius: 11px;
background: rgba(31, 35, 41, 0.18);
cursor: pointer;
transition: background 0.18s ease;
}
.fqa-set-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.18s ease;
}
.fqa-set-switch:checked {
background: var(--fqa-set-accent);
}
.fqa-set-switch:checked::after {
transform: translateX(16px);
}
.fqa-set-input {
width: 100%;
margin-top: 8px;
padding: 7px 10px;
box-sizing: border-box;
border: 1px solid var(--fqa-set-border);
border-radius: 6px;
background: var(--fqa-set-bg);
color: var(--fqa-set-text);
font-size: 13px;
font-family: inherit;
}
.fqa-set-input:focus {
outline: none;
border-color: var(--fqa-set-accent);
}
.fqa-set-textarea {
width: 100%;
min-height: 150px;
margin-top: 10px;
padding: 10px;
box-sizing: border-box;
border: 1px solid var(--fqa-set-border);
border-radius: 6px;
background: var(--fqa-set-bg);
color: var(--fqa-set-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
resize: vertical;
}
.fqa-set-textarea:disabled {
background: var(--fqa-set-side-bg);
color: var(--fqa-set-sub);
cursor: not-allowed;
}
.fqa-set-textarea:focus {
outline: none;
border-color: var(--fqa-set-accent);
}
.fqa-set-radios {
display: flex;
gap: 20px;
margin-top: 8px;
}
.fqa-set-radio {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.fqa-set-radio input {
accent-color: var(--fqa-set-accent);
}
.fqa-set-field {
display: block;
margin-top: 10px;
font-size: 12px;
color: var(--fqa-set-sub);
}
.fqa-set-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.fqa-set-btn {
padding: 7px 16px;
border: 1px solid var(--fqa-set-border);
border-radius: 6px;
background: var(--fqa-set-bg);
color: var(--fqa-set-text);
font-size: 13px;
font-family: inherit;
cursor: pointer;
}
.fqa-set-btn:hover:not(:disabled) {
border-color: var(--fqa-set-accent);
color: var(--fqa-set-accent);
}
.fqa-set-btn:disabled {
color: var(--fqa-set-sub);
cursor: not-allowed;
}
/* 关于 */
.fqa-set-links {
margin-top: 12px;
}
.fqa-set-link-row {
margin-bottom: 10px;
font-size: 13px;
word-break: break-all;
}
.fqa-set-link-row a {
color: var(--fqa-set-accent);
text-decoration: none;
}
.fqa-set-link-row a:hover {
text-decoration: underline;
}
.fqa-set-license {
margin-top: 20px;
padding-top: 14px;
border-top: 1px solid var(--fqa-set-border);
font-size: 12px;
color: var(--fqa-set-sub);
}
.fqa-set-license p {
margin: 0 0 6px;
}
/* 深色 */
@media (prefers-color-scheme: dark) {
.fqa-set-dialog {
--fqa-set-bg: #23272e;
--fqa-set-side-bg: #1c2026;
--fqa-set-text: #e5e6eb;
--fqa-set-sub: #8f959e;
--fqa-set-border: rgba(255, 255, 255, 0.12);
--fqa-set-hover: rgba(255, 255, 255, 0.08);
}
.fqa-set-switch {
background: rgba(255, 255, 255, 0.2);
}
.fqa-set-warn {
color: #ffb59b;
}
}
+1
View File
@@ -9,6 +9,7 @@ export interface DeviceConfig {
install_id: string; // iid
device_id: string;
device_type?: string;
device_brand?: string;
key_info?: KeyInfo;
}
+143
View File
@@ -0,0 +1,143 @@
import { b64encode, getCrypto, getSubtle, hash, pkcs7Pad } from ".";
import { ProtobufWriter } from "./protobuf";
import { simonEncrypt } from "./simon";
import { sm3 } from "./sm3";
import type { UnidbgConfig } from "./unidbgconfig";
const LOW_RAND = new Uint8Array([0xf2, 0x81]);
const HIGH_RAND = new Uint8Array([0x61, 0x6f]); // 'a', 'o'
const XOR_PREFIX = new Uint8Array([0xf2, 0xf7, 0xfc, 0xff, 0xf2, 0xf7, 0xfc, 0xff]);
function sm3Prefix6(data: Uint8Array): Uint8Array<ArrayBuffer> {
return sm3(data).slice(0, 6);
}
function decodeStub(xssStub: string): Uint8Array<ArrayBuffer> {
const bytes = new Uint8Array(16);
if (xssStub.length >= 32) {
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(xssStub.slice(i * 2, i * 2 + 2), 16) & 0xff;
}
}
return bytes;
}
function buildProtobuf(
query: string,
xssStub: string,
timestamp: number,
config: UnidbgConfig,
): Uint8Array<ArrayBuffer> {
const params = new URLSearchParams(query);
const deviceId = params.get("device_id") ?? "";
const versionName = params.get("version_name") ?? "";
// 空 stub / 空 query 时都对 16 字节全零求 SM3
const bodyHash = sm3Prefix6(xssStub === "" ? new Uint8Array(16) : decodeStub(xssStub));
const queryHash = sm3Prefix6(
query === "" ? new Uint8Array(16) : new TextEncoder().encode(query),
);
const rand = getCrypto().getRandomValues(new Uint32Array(1))[0]! % 0x7fffffff;
return new ProtobufWriter()
.varint(1, 0x20200929 * 2) // magic
.varint(2, 2) // version
.varint(3, rand)
.string(4, config.aid)
.string(5, deviceId)
.string(6, config.licenseId)
.string(7, versionName)
.string(8, config.sdkVersion)
.varint(9, config.sdkVersionInt)
.bytes(10, new Uint8Array(8)) // envcode
.varint(11, 0) // platform
.varint(12, timestamp * 2) // createTime
.bytes(13, bodyHash)
.bytes(14, queryHash)
.message(15, (sub) => {
sub.varint(1, 1).varint(2, 1).varint(3, 1).varint(7, 3348294860);
})
.string(16, "") // sec_device_id
.string(20, "none") // pskVersion
.varint(21, config.callType)
.message(23, (sub) => {
sub.string(1, "NX551J").varint(2, 8196).varint(4, 2162219008);
})
.varint(25, 2)
.toBytes();
}
export async function getArgus(
query: string,
xssStub: string,
timestamp: number,
config: UnidbgConfig,
): Promise<string> {
const { signKey } = config;
if (signKey.length !== 32) {
throw new Error(`Sign key must be 32 bytes, got ${signKey.length}`);
}
const protobuf = pkcs7Pad(buildProtobuf(query, xssStub, timestamp, config), 16);
const sm3Input = new Uint8Array(signKey.length * 2 + LOW_RAND.length + HIGH_RAND.length);
sm3Input.set(signKey, 0);
sm3Input.set(LOW_RAND, signKey.length);
sm3Input.set(HIGH_RAND, signKey.length + LOW_RAND.length);
sm3Input.set(signKey, signKey.length + LOW_RAND.length + HIGH_RAND.length);
const sm3Output = sm3(sm3Input);
const keyView = new DataView(sm3Output.buffer, sm3Output.byteOffset, sm3Output.byteLength);
const simonKey: [bigint, bigint, bigint, bigint] = [
keyView.getBigUint64(0, true),
keyView.getBigUint64(8, true),
keyView.getBigUint64(16, true),
keyView.getBigUint64(24, true),
];
const encrypted = new Uint8Array(protobuf.length);
const pbView = new DataView(protobuf.buffer, protobuf.byteOffset, protobuf.byteLength);
const encView = new DataView(encrypted.buffer);
for (let offset = 0; offset < protobuf.length; offset += 16) {
const [low, high] = simonEncrypt(
[pbView.getBigUint64(offset, true), pbView.getBigUint64(offset + 8, true)],
simonKey,
);
encView.setBigUint64(offset, low, true);
encView.setBigUint64(offset + 8, high, true);
}
const data = new Uint8Array(XOR_PREFIX.length + encrypted.length);
data.set(XOR_PREFIX);
data.set(encrypted, XOR_PREFIX.length);
for (let i = XOR_PREFIX.length; i < data.length; i++) {
data[i]! ^= data[i % 8]!;
}
data.reverse();
const header = new Uint8Array([0xa6, 0x6e, 0xad, 0x9f, 0x77, 0x01, 0xd0, 0x0c, 0x18]);
const plaintext = new Uint8Array(header.length + data.length + HIGH_RAND.length);
plaintext.set(header);
plaintext.set(data, header.length);
plaintext.set(HIGH_RAND, header.length + data.length);
const subtle = getSubtle();
const aesKey = await subtle.importKey(
"raw",
await hash.md5bytes(signKey.slice(0, 16)),
{ name: "AES-CBC" },
false,
["encrypt"],
);
const iv = await hash.md5bytes(signKey.slice(16));
const ciphertext = new Uint8Array(
await subtle.encrypt({ name: "AES-CBC", iv }, aesKey, plaintext),
);
const result = new Uint8Array(LOW_RAND.length + ciphertext.length);
result.set(LOW_RAND);
result.set(ciphertext, LOW_RAND.length);
return b64encode(result.buffer);
}
+65
View File
@@ -0,0 +1,65 @@
import { defaultConfig, type DeviceConfig } from "../config";
import { b64decode, getSubtle, unhex } from ".";
// 正文算法: AES/CBC/PKCS5Padding
// 返回 string(xhtml/html) | 章节 JSON 对象 | undefined(解析失败)
export async function decryptChapter(
encrypted: string,
config: DeviceConfig = defaultConfig,
): Promise<string | unknown> {
if (!encrypted) {
throw new Error("Invalid encrypted chapter");
};
const buf = b64decode(encrypted);
const iv = buf.slice(0, 16);
const data = buf.slice(16);
const key = config.key_info?.key;
if (!key) {
throw new Error("Missing decrypt key")
}
const subtle = getSubtle();
const cryptoKey = await subtle.importKey(
"raw",
key,
{ name: "AES-CBC" },
false,
["decrypt"],
);
return subtle.decrypt(
{ name: "AES-CBC", iv },
cryptoKey,
data,
).then((decrypted) => {
const decoder = new TextDecoder();
const plain = decoder.decode(decrypted);
if (plain.trim().startsWith("<")) { // xhtml || html
return plain;
}
try {
return JSON.parse(plain); // mostly JSONObject
} catch (e: any) {
console.warn('Invalid chapter content: ', plain, e);
return undefined;
}
});
}
export async function decryptComicImage(image: ArrayBuffer, key: string /* hex */): Promise<ArrayBuffer> {
const subtle = getSubtle();
// AESGCM
const cryptoKey = await subtle.importKey(
"raw",
unhex(key),
{ name: "AES-GCM" },
false,
["decrypt"],
);
const iv = image.slice(0, 12);
const data = image.slice(12);
return await subtle.decrypt(
{ name: "AES-GCM", iv },
cryptoKey,
data,
);
}
+124
View File
@@ -0,0 +1,124 @@
import md5 from "./md5";
import { sm3 } from "./sm3";
export function getCrypto(): Crypto {
const c = globalThis.crypto ?? (unsafeWindow.crypto as Crypto | undefined);
if (!c?.subtle) {
throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API");
}
return c;
}
export function getSubtle(): SubtleCrypto {
return getCrypto().subtle;
}
export function b64decode(b64: string): ArrayBuffer {
const binaryString = atob(b64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export function b64encode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
const chunks: string[] = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
chunks.push(
String.fromCharCode(...bytes.subarray(i, i + chunkSize)),
);
}
return btoa(chunks.join(""));
}
export function unhex(hex: string): ArrayBuffer {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
const byte = parseInt(hex.slice(i, i + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex string");
}
bytes[i / 2] = byte;
}
return bytes.buffer;
}
export function hex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let hexString = "";
for (let i = 0; i < bytes.length; i++) {
hexString += bytes[i]!.toString(16).padStart(2, "0");
}
return hexString;
}
/**
* PKCS#7 填充。注意 WebCrypto 的 AES-CBC 会自动补 PKCS#7,
* 只有在需要手动分块(如 Simon/Speck)时才要调用本函数。
*/
export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array<ArrayBuffer> {
const padLength = blockSize - (data.length % blockSize);
const padded = new Uint8Array(data.length + padLength);
padded.set(data);
padded.fill(padLength, data.length);
return padded;
}
export function randomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const array = new Uint8Array(length);
getCrypto().getRandomValues(array);
for (let i = 0; i < length; i++) {
result += chars.charAt(array[i]! % chars.length);
}
return result;
}
type HashInput = ArrayBuffer | Uint8Array<ArrayBuffer> | string;
function toBytes(input: HashInput): Uint8Array<ArrayBuffer> {
if (typeof input === "string") {
return new TextEncoder().encode(input);
}
if (input instanceof Uint8Array) {
return new Uint8Array(input);
}
return new Uint8Array(input);
}
export const hash = {
sha256: async (input: HashInput): Promise<string> => {
const subtle = getSubtle();
const digest = await subtle.digest("SHA-256", toBytes(input));
return hex(digest);
},
sha256bytes: async (input: HashInput): Promise<ArrayBuffer> => {
const subtle = getSubtle();
return subtle.digest("SHA-256", toBytes(input));
},
sha512: async (input: HashInput): Promise<string> => {
const subtle = getSubtle();
const digest = await subtle.digest("SHA-512", toBytes(input));
return hex(digest);
},
sha512bytes: async (input: HashInput): Promise<ArrayBuffer> => {
const subtle = getSubtle();
return subtle.digest("SHA-512", toBytes(input));
},
md5: async (input: HashInput): Promise<string> => md5(
typeof input === "string" ? input : toBytes(input).buffer,
),
md5bytes: async (input: HashInput): Promise<ArrayBuffer> => unhex(md5(
typeof input === "string" ? input : toBytes(input).buffer,
)),
sm3: async (input: HashInput): Promise<string> => hex(sm3(toBytes(input)).buffer),
sm3bytes: async (input: HashInput): Promise<ArrayBuffer> => sm3(toBytes(input)).buffer,
};
+18
View File
@@ -0,0 +1,18 @@
import { b64encode, getCrypto } from ".";
import { generateLadonKey, speckEncrypt } from "./speck";
import type { UnidbgConfig } from "./unidbgconfig";
export async function getLadon(timestamp: number, config: UnidbgConfig): Promise<string> {
const randomBytes = getCrypto().getRandomValues(new Uint8Array(4));
const plaintext = new TextEncoder().encode(
`${timestamp}-${config.licenseId}-${config.aid}`,
);
const key = await generateLadonKey(randomBytes, config.aid);
const encrypted = speckEncrypt(key, plaintext);
const result = new Uint8Array(randomBytes.length + encrypted.length);
result.set(randomBytes);
result.set(encrypted, randomBytes.length);
return b64encode(result.buffer);
}
+161
View File
@@ -0,0 +1,161 @@
function rotateLeft(value: number, shiftBits: number): number {
return (value << shiftBits) | (value >>> (32 - shiftBits));
}
function addUnsigned(left: number, right: number): number {
return (left + right) >>> 0;
}
function f(x: number, y: number, z: number): number {
return (x & y) | (~x & z);
}
function g(x: number, y: number, z: number): number {
return (x & z) | (y & ~z);
}
function h(x: number, y: number, z: number): number {
return x ^ y ^ z;
}
function i(x: number, y: number, z: number): number {
return y ^ (x | ~z);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b);
}
function convertToWordArray(bytes: Uint8Array): number[] {
const wordCount = Math.ceil((bytes.length + 9) / 64) * 16;
const words = new Array<number>(wordCount).fill(0);
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
const wordIndex = Math.floor(byteIndex / 4);
const bytePosition = (byteIndex % 4) * 8;
words[wordIndex] = words[wordIndex]! | (bytes[byteIndex]! << bytePosition);
}
const paddingWordIndex = Math.floor(bytes.length / 4);
const paddingBytePosition = (bytes.length % 4) * 8;
words[paddingWordIndex] = words[paddingWordIndex]! | (0x80 << paddingBytePosition);
words[wordCount - 2] = bytes.length << 3;
words[wordCount - 1] = bytes.length >>> 29;
return words;
}
function wordToHex(value: number): string {
let hex = "";
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
const byte = (value >>> (byteIndex * 8)) & 0xff;
hex += byte.toString(16).padStart(2, "0");
}
return hex;
}
/**
* 计算 MD5 摘要。
*/
export default function md5(input: string | ArrayBuffer): string {
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
const words = convertToWordArray(bytes);
let a = 0x67452301;
let b = 0xefcdab89;
let c = 0x98badcfe;
let d = 0x10325476;
for (let k = 0; k < words.length; k += 16) {
const aa = a;
const bb = b;
const cc = c;
const dd = d;
a = ff(a, b, c, d, words[k]!, 7, 0xd76aa478);
d = ff(d, a, b, c, words[k + 1]!, 12, 0xe8c7b756);
c = ff(c, d, a, b, words[k + 2]!, 17, 0x242070db);
b = ff(b, c, d, a, words[k + 3]!, 22, 0xc1bdceee);
a = ff(a, b, c, d, words[k + 4]!, 7, 0xf57c0faf);
d = ff(d, a, b, c, words[k + 5]!, 12, 0x4787c62a);
c = ff(c, d, a, b, words[k + 6]!, 17, 0xa8304613);
b = ff(b, c, d, a, words[k + 7]!, 22, 0xfd469501);
a = ff(a, b, c, d, words[k + 8]!, 7, 0x698098d8);
d = ff(d, a, b, c, words[k + 9]!, 12, 0x8b44f7af);
c = ff(c, d, a, b, words[k + 10]!, 17, 0xffff5bb1);
b = ff(b, c, d, a, words[k + 11]!, 22, 0x895cd7be);
a = ff(a, b, c, d, words[k + 12]!, 7, 0x6b901122);
d = ff(d, a, b, c, words[k + 13]!, 12, 0xfd987193);
c = ff(c, d, a, b, words[k + 14]!, 17, 0xa679438e);
b = ff(b, c, d, a, words[k + 15]!, 22, 0x49b40821);
a = gg(a, b, c, d, words[k + 1]!, 5, 0xf61e2562);
d = gg(d, a, b, c, words[k + 6]!, 9, 0xc040b340);
c = gg(c, d, a, b, words[k + 11]!, 14, 0x265e5a51);
b = gg(b, c, d, a, words[k]!, 20, 0xe9b6c7aa);
a = gg(a, b, c, d, words[k + 5]!, 5, 0xd62f105d);
d = gg(d, a, b, c, words[k + 10]!, 9, 0x02441453);
c = gg(c, d, a, b, words[k + 15]!, 14, 0xd8a1e681);
b = gg(b, c, d, a, words[k + 4]!, 20, 0xe7d3fbc8);
a = gg(a, b, c, d, words[k + 9]!, 5, 0x21e1cde6);
d = gg(d, a, b, c, words[k + 14]!, 9, 0xc33707d6);
c = gg(c, d, a, b, words[k + 3]!, 14, 0xf4d50d87);
b = gg(b, c, d, a, words[k + 8]!, 20, 0x455a14ed);
a = gg(a, b, c, d, words[k + 13]!, 5, 0xa9e3e905);
d = gg(d, a, b, c, words[k + 2]!, 9, 0xfcefa3f8);
c = gg(c, d, a, b, words[k + 7]!, 14, 0x676f02d9);
b = gg(b, c, d, a, words[k + 12]!, 20, 0x8d2a4c8a);
a = hh(a, b, c, d, words[k + 5]!, 4, 0xfffa3942);
d = hh(d, a, b, c, words[k + 8]!, 11, 0x8771f681);
c = hh(c, d, a, b, words[k + 11]!, 16, 0x6d9d6122);
b = hh(b, c, d, a, words[k + 14]!, 23, 0xfde5380c);
a = hh(a, b, c, d, words[k + 1]!, 4, 0xa4beea44);
d = hh(d, a, b, c, words[k + 4]!, 11, 0x4bdecfa9);
c = hh(c, d, a, b, words[k + 7]!, 16, 0xf6bb4b60);
b = hh(b, c, d, a, words[k + 10]!, 23, 0xbebfbc70);
a = hh(a, b, c, d, words[k + 13]!, 4, 0x289b7ec6);
d = hh(d, a, b, c, words[k]!, 11, 0xeaa127fa);
c = hh(c, d, a, b, words[k + 3]!, 16, 0xd4ef3085);
b = hh(b, c, d, a, words[k + 6]!, 23, 0x04881d05);
a = hh(a, b, c, d, words[k + 9]!, 4, 0xd9d4d039);
d = hh(d, a, b, c, words[k + 12]!, 11, 0xe6db99e5);
c = hh(c, d, a, b, words[k + 15]!, 16, 0x1fa27cf8);
b = hh(b, c, d, a, words[k + 2]!, 23, 0xc4ac5665);
a = ii(a, b, c, d, words[k]!, 6, 0xf4292244);
d = ii(d, a, b, c, words[k + 7]!, 10, 0x432aff97);
c = ii(c, d, a, b, words[k + 14]!, 15, 0xab9423a7);
b = ii(b, c, d, a, words[k + 5]!, 21, 0xfc93a039);
a = ii(a, b, c, d, words[k + 12]!, 6, 0x655b59c3);
d = ii(d, a, b, c, words[k + 3]!, 10, 0x8f0ccc92);
c = ii(c, d, a, b, words[k + 10]!, 15, 0xffeff47d);
b = ii(b, c, d, a, words[k + 1]!, 21, 0x85845dd1);
a = ii(a, b, c, d, words[k + 8]!, 6, 0x6fa87e4f);
d = ii(d, a, b, c, words[k + 15]!, 10, 0xfe2ce6e0);
c = ii(c, d, a, b, words[k + 6]!, 15, 0xa3014314);
b = ii(b, c, d, a, words[k + 13]!, 21, 0x4e0811a1);
a = ii(a, b, c, d, words[k + 4]!, 6, 0xf7537e82);
d = ii(d, a, b, c, words[k + 11]!, 10, 0xbd3af235);
c = ii(c, d, a, b, words[k + 2]!, 15, 0x2ad7d2bb);
b = ii(b, c, d, a, words[k + 9]!, 21, 0xeb86d391);
a = addUnsigned(a, aa);
b = addUnsigned(b, bb);
c = addUnsigned(c, cc);
d = addUnsigned(d, dd);
}
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
}
+54
View File
@@ -0,0 +1,54 @@
const WIRE_VARINT = 0;
const WIRE_BYTES = 2;
const WIRE_FIXED32 = 5;
export class ProtobufWriter {
private readonly buf: number[] = [];
private writeVarint(value: number): this {
let v = value >>> 0;
while (v >= 0x80) {
this.buf.push((v & 0x7f) | 0x80);
v >>>= 7;
}
this.buf.push(v & 0x7f);
return this;
}
private writeKey(fieldNumber: number, wireType: number): this {
return this.writeVarint((fieldNumber << 3) | wireType);
}
varint(fieldNumber: number, value: number): this {
return this.writeKey(fieldNumber, WIRE_VARINT).writeVarint(value);
}
fixed32(fieldNumber: number, value: number): this {
this.writeKey(fieldNumber, WIRE_FIXED32);
const v = value >>> 0;
this.buf.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
return this;
}
bytes(fieldNumber: number, data: ArrayLike<number>): this {
this.writeKey(fieldNumber, WIRE_BYTES).writeVarint(data.length);
for (let i = 0; i < data.length; i++) {
this.buf.push(data[i]! & 0xff);
}
return this;
}
string(fieldNumber: number, value: string): this {
return this.bytes(fieldNumber, new TextEncoder().encode(value));
}
message(fieldNumber: number, build: (writer: ProtobufWriter) => void): this {
const sub = new ProtobufWriter();
build(sub);
return this.bytes(fieldNumber, sub.toBytes());
}
toBytes(): Uint8Array<ArrayBuffer> {
return Uint8Array.from(this.buf);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { shared_key, type DeviceConfig } from "../config";
import { b64decode, b64encode, getSubtle, randomString, unhex } from ".";
function reverseHex(value: string): string {
const be = BigInt(value).toString(16).padStart(32, "0");
let result = "";
for (let i = be.length; i > 0; i -= 2) result += be.slice(i - 2, i);
return result;
}
export async function encryptKeyinfoBody(config: DeviceConfig): Promise<string> {
const deviceId = config.device_id;
const iv = new TextEncoder().encode(randomString(16));
// 如果是i32类型(返回8字节)则啥也不用管。但是如果返回16以上(i64+),则需要取前八位,后面是0,删了
// BigInt按i64返回,所以取前八位
const data = new Uint8Array(unhex(reverseHex(deviceId))).slice(0, 8);
console.log(data)
const subtle = getSubtle();
const k = await subtle.importKey(
"raw",
shared_key,
{ name: "AES-CBC" },
false,
["encrypt"],
)
const encrypted = await subtle.encrypt(
{ name: "AES-CBC", iv },
k,
data
);
const final = new Uint8Array(iv.length + encrypted.byteLength);
console.log(final)
final.set(iv, 0);
final.set(new Uint8Array(encrypted), iv.length);
return JSON.stringify({
content: b64encode(final.buffer),
});
}
export async function decryptKeyinfoResponse(encrypted: string): Promise<ArrayBuffer> {
const buf = b64decode(encrypted);
const iv = buf.slice(0, 16);
const data = buf.slice(16);
const subtle = getSubtle();
const k = await subtle.importKey(
"raw",
shared_key,
{ name: "AES-CBC" },
false,
["decrypt"]
);
return subtle.decrypt(
{ name: "AES-CBC", iv },
k,
data
);
}
+56
View File
@@ -0,0 +1,56 @@
import { hash } from ".";
import { getArgus } from "./argus";
import { getLadon } from "./ladon";
import { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig";
export interface UnidbgHeaders extends Record<string, string> {
"x-argus": string;
"x-ladon": string;
"x-khronos": string;
}
export default async function generateHeaders(
rawQuery: string,
xssStub = "",
timestamp: number = Math.floor(Date.now() / 1000),
config: UnidbgConfig = defaultUnidbgConfig,
): Promise<UnidbgHeaders> {
const [argus, ladon] = await Promise.all([
getArgus(rawQuery, xssStub, timestamp, config),
getLadon(timestamp, config),
]);
return {
"x-argus": argus,
"x-ladon": ladon,
"x-khronos": String(timestamp),
};
}
export async function signRequest(
url: string,
body?: ArrayBuffer | Uint8Array<ArrayBuffer> | string,
config: UnidbgConfig = defaultUnidbgConfig,
): Promise<Record<string, string>> {
const rawQuery = new URL(url).search.replace(/^\?/, "");
const hasBody = typeof body === "string" ? body.length > 0 : (body?.byteLength ?? 0) > 0;
const xssStub = hasBody ? await hash.md5(body!) : "";
const now = Date.now();
const headers: Record<string, string> = await generateHeaders(
rawQuery,
xssStub,
Math.floor(now / 1000),
config,
);
headers["x-ss-req-ticket"] = String(now);
if (hasBody) {
headers["X-SS-STUB"] = xssStub;
}
return headers;
}
export { getArgus } from "./argus";
export { getLadon } from "./ladon";
export { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig";
+47
View File
@@ -0,0 +1,47 @@
const ROUNDS = 72;
const MASK64 = 0xffffffffffffffffn;
const Z4 = 0x3dc94c3a046d678bn;
function getBit(value: bigint, position: number): bigint {
return (value >> BigInt(position)) & 1n;
}
function rotateLeft64(v: bigint, n: bigint): bigint {
return ((v << n) | (v >> (64n - n))) & MASK64;
}
function rotateRight64(v: bigint, n: bigint): bigint {
return ((v << (64n - n)) | (v >> n)) & MASK64;
}
function keyExpansion(key: readonly [bigint, bigint, bigint, bigint]): bigint[] {
const k: bigint[] = [key[0] & MASK64, key[1] & MASK64, key[2] & MASK64, key[3] & MASK64];
for (let i = 4; i < ROUNDS; i++) {
let tmp = rotateRight64(k[i - 1]!, 3n);
tmp ^= k[i - 3]!;
tmp ^= rotateRight64(tmp, 1n);
k.push((~k[i - 4]! ^ tmp ^ getBit(Z4, (i - 4) % 62) ^ 3n) & MASK64);
}
return k;
}
export function simonEncrypt(
plaintext: readonly [bigint, bigint],
key: readonly [bigint, bigint, bigint, bigint],
): [bigint, bigint] {
const k = keyExpansion(key);
let x = plaintext[0] & MASK64;
let y = plaintext[1] & MASK64;
for (let i = 0; i < ROUNDS; i++) {
const tmp = y;
const f = rotateLeft64(y, 1n) & rotateLeft64(y, 8n);
y = (x ^ f ^ rotateLeft64(y, 2n) ^ k[i]!) & MASK64;
x = tmp;
}
return [x, y];
}
+158
View File
@@ -0,0 +1,158 @@
// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js
type Bytes = ArrayLike<number>;
/**
* 循环左移
*/
function rotl(value: number, shift: number): number {
const normalizedShift = shift & 31;
return (value << normalizedShift) | (value >>> (32 - normalizedShift));
}
/**
* 二进制异或运算
*/
function xor(left: Bytes, right: Bytes): Uint8Array {
if (left.length !== right.length) {
throw new Error("XOR inputs must have the same length");
}
const result = new Uint8Array(left.length);
for (let i = 0; i < left.length; i++) {
result[i] = (left[i]! ^ right[i]!) & 0xff;
}
return result;
}
/**
* 压缩函数中的置换函数 P0(X) = X xor (X <<< 9) xor (X <<< 17)
*/
function p0(value: number): number {
return (value ^ rotl(value, 9)) ^ rotl(value, 17);
}
/**
* 消息扩展中的置换函数 P1(X) = X xor (X <<< 15) xor (X <<< 23)
*/
function p1(value: number): number {
return (value ^ rotl(value, 15)) ^ rotl(value, 23);
}
/**
* 计算 SM3 摘要。
*/
export function sm3(input: Bytes): Uint8Array<ArrayBuffer> {
const inputLength = input.length;
const paddedLength = Math.ceil((inputLength + 9) / 64) * 64;
const message = new Uint8Array(paddedLength);
message.set(input);
message[inputLength] = 0x80;
const bitLength = BigInt(inputLength) * 8n;
for (let i = 0; i < 8; i++) {
message[paddedLength - 1 - i] = Number((bitLength >> BigInt(i * 8)) & 0xffn);
}
const dataView = new DataView(message.buffer);
const state = new Uint32Array([
0x7380166f,
0x4914b2b9,
0x172442d7,
0xda8a0600,
0xa96f30bc,
0x163138aa,
0xe38dee4d,
0xb0fb0e4e,
]);
const words = new Uint32Array(68);
const expandedWords = new Uint32Array(64);
for (let block = 0; block < message.length / 64; block++) {
const start = block * 64;
for (let i = 0; i < 16; i++) {
words[i] = dataView.getUint32(start + i * 4, false);
}
for (let i = 16; i < 68; i++) {
words[i] = (p1((words[i - 16]! ^ words[i - 9]!) ^ rotl(words[i - 3]!, 15))
^ rotl(words[i - 13]!, 7))
^ words[i - 6]!;
}
for (let i = 0; i < 64; i++) {
expandedWords[i] = words[i]! ^ words[i + 4]!;
}
let a = state[0]!;
let b = state[1]!;
let c = state[2]!;
let d = state[3]!;
let e = state[4]!;
let f = state[5]!;
let g = state[6]!;
let h = state[7]!;
for (let i = 0; i < 64; i++) {
const t = i <= 15 ? 0x79cc4519 : 0x7a879d8a;
const ss1 = rotl(rotl(a, 12) + e + rotl(t, i), 7);
const ss2 = ss1 ^ rotl(a, 12);
const tt1 = (i <= 15 ? (a ^ b) ^ c : ((a & b) | (a & c)) | (b & c))
+ d + ss2 + expandedWords[i]!;
const tt2 = (i <= 15 ? (e ^ f) ^ g : (e & f) | (~e & g))
+ h + ss1 + words[i]!;
d = c;
c = rotl(b, 9);
b = a;
a = tt1;
h = g;
g = rotl(f, 19);
f = e;
e = p0(tt2);
}
state[0] = state[0]! ^ a;
state[1] = state[1]! ^ b;
state[2] = state[2]! ^ c;
state[3] = state[3]! ^ d;
state[4] = state[4]! ^ e;
state[5] = state[5]! ^ f;
state[6] = state[6]! ^ g;
state[7] = state[7]! ^ h;
}
const result = new Uint8Array(32);
for (let i = 0; i < state.length; i++) {
const word = state[i]!;
result[i * 4] = word >>> 24;
result[i * 4 + 1] = word >>> 16;
result[i * 4 + 2] = word >>> 8;
result[i * 4 + 3] = word;
}
return result;
}
/**
* 计算 HMAC-SM3。
*/
export function hmac(input: Bytes, key: Bytes): Uint8Array<ArrayBuffer> {
const blockLength = 64;
const normalizedKey = key.length > blockLength ? sm3(key) : Uint8Array.from(key);
const paddedKey = new Uint8Array(blockLength);
paddedKey.set(normalizedKey);
const innerPad = new Uint8Array(blockLength).fill(0x36);
const outerPad = new Uint8Array(blockLength).fill(0x5c);
const innerKey = xor(paddedKey, innerPad);
const outerKey = xor(paddedKey, outerPad);
const innerMessage = new Uint8Array(blockLength + input.length);
innerMessage.set(innerKey);
innerMessage.set(input, blockLength);
const outerMessage = new Uint8Array(blockLength + 32);
outerMessage.set(outerKey);
outerMessage.set(sm3(innerMessage), blockLength);
return sm3(outerMessage);
}
+79
View File
@@ -0,0 +1,79 @@
import { hash, pkcs7Pad } from ".";
const ROUNDS = 34;
const MASK64 = 0xffffffffffffffffn;
const WORD_SIZE = 64n;
const ALPHA = 8n;
const BETA = 3n;
function readUint64LE(view: DataView, offset: number): bigint {
return view.getBigUint64(offset, true);
}
function keySchedule(key: Uint8Array): bigint[] {
const view = new DataView(key.buffer, key.byteOffset, key.byteLength);
const ks: bigint[] = [readUint64LE(view, 0) & MASK64];
const numWords = (key.length * 8) / Number(WORD_SIZE);
const ls: bigint[] = [];
for (let i = 1; i < numWords; i++) {
ls.push(readUint64LE(view, i * 8) & MASK64);
}
for (let x = 0; x < ROUNDS - 1; x++) {
const rsX = (((ls[x]! << (WORD_SIZE - ALPHA)) + (ls[x]! >> ALPHA)) & MASK64);
const addSxy = (rsX + ks[x]!) & MASK64;
const newX = BigInt(x) ^ addSxy;
const lsY = (((ks[x]! >> (WORD_SIZE - BETA)) + (ks[x]! << BETA)) & MASK64);
ls.push(newX);
ks.push(newX ^ lsY);
}
return ks;
}
function encryptBlock(ks: readonly bigint[], block: Uint8Array, out: Uint8Array, outOffset: number): void {
const view = new DataView(block.buffer, block.byteOffset, block.byteLength);
let y = readUint64LE(view, 0);
let x = readUint64LE(view, 8);
for (const k of ks) {
const rsX = (((x << (WORD_SIZE - ALPHA)) + (x >> ALPHA)) & MASK64);
const addSxy = (rsX + y) & MASK64;
x = k ^ addSxy;
const lsY = (((y >> (WORD_SIZE - BETA)) + (y << BETA)) & MASK64);
y = x ^ lsY;
}
const outView = new DataView(out.buffer, out.byteOffset, out.byteLength);
outView.setBigUint64(outOffset, y & MASK64, true);
outView.setBigUint64(outOffset + 8, x & MASK64, true);
}
export function speckEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array<ArrayBuffer> {
if (key.length !== 32) {
throw new Error(`Speck key must be 32 bytes, got ${key.length}`);
}
const padded = pkcs7Pad(plaintext, 16);
const ks = keySchedule(key);
const out = new Uint8Array(padded.length);
for (let i = 0; i < padded.length; i += 16) {
encryptBlock(ks, padded.subarray(i, i + 16), out, i);
}
return out;
}
export async function generateLadonKey(randomBytes: Uint8Array, aid: string): Promise<Uint8Array<ArrayBuffer>> {
const aidBytes = new TextEncoder().encode(aid);
const input = new Uint8Array(randomBytes.length + aidBytes.length);
input.set(randomBytes);
input.set(aidBytes, randomBytes.length);
const hex = await hash.md5(input);
return new TextEncoder().encode(hex);
}
+31
View File
@@ -0,0 +1,31 @@
import { getSubtle, b64decode, hash, getCrypto } from '.'
import { concatArrayBuffers } from '../utils'
import { gzip } from '../utils/compress'
export const FIXED_STRING = b64decode(
"TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA=="
)
export async function encrypt(data: ArrayBuffer): Promise<ArrayBuffer> {
const crypto = getCrypto()
const subtle = getSubtle()
const randomBytes = crypto.getRandomValues(new Uint8Array(32))
const hashValue = await hash.sha512bytes(
concatArrayBuffers(await hash.sha512bytes(randomBytes), FIXED_STRING)
)
const k = hashValue.slice(0, 16)
const iv = hashValue.slice(16, 32)
const compressedData = await gzip(data)
const hashedData = concatArrayBuffers(
await hash.sha512bytes(compressedData),
compressedData
)
const key = await subtle.importKey("raw", k, { name: "AES-CBC", length: 128 }, false, ["encrypt"])
const encryptedData = await subtle.encrypt({ name: "AES-CBC", iv }, key, hashedData)
return concatArrayBuffers(
new Uint8Array([116, 99, 5, 16, 0, 0]).buffer,
randomBytes.buffer,
encryptedData
)
}
+21
View File
@@ -0,0 +1,21 @@
import { unhex } from ".";
export interface UnidbgConfig {
signKey: Uint8Array;
aid: string;
licenseId: string;
sdkVersion: string;
sdkVersionInt: number;
callType: number;
}
export const defaultUnidbgConfig: UnidbgConfig = {
signKey: new Uint8Array(
unhex("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c"),
),
aid: "1967",
licenseId: "1611921764",
sdkVersion: "v04.04.05-ov-android",
sdkVersionInt: 134744640,
callType: 738,
};
+12
View File
@@ -1,3 +1,6 @@
import { watch } from 'vue'
import { settings } from './settings'
const enTag = '.font-DNMrHsV173Pd4pgy' // 这个目前从存在解密算法到现在没改过。暂且认为它在加解密算法不变时长时不变。
const code_ed = 58715
const code_st = 58344
@@ -58,6 +61,7 @@ export function decryptText(text: string): string {
* @param element 要解密的 DOM 元素
*/
export function decryptElement(element: Element) {
if (!settings.decryptFont) return
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
@@ -124,4 +128,12 @@ export default function initFontDecrypt() {
// observer 挂载前解析出的节点不会产生 mutation,需补扫一次。
decryptPage(document);
// 设置里重新打开时,之前跳过的节点需要补解密一次
watch(
() => settings.decryptFont,
(on) => {
if (on) decryptPage(document);
}
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import type { HookConfig } from '../config'
import BookshelfView from '../views/BookshelfView.vue'
import bookshelfcss from '../assets/bookshelf.css?raw'
import { waitForElement } from '../utils'
import { userState } from '../api/user'
const CONTAINER_ID = 'fqa-bookshelf-root'
const STYLE_ID = 'fqa-bookshelf-style'
@@ -102,7 +103,7 @@ async function mainHook(_previous?: string): Promise<void> {
}
function filter(path: string, _query: URLSearchParams, _hash: string) {
return isBookshelfPath(path) || !!app
return (isBookshelfPath(path) || !!app) && userState.isLogin
}
const _exports: HookConfig[] = [
+3
View File
@@ -1,4 +1,5 @@
import type { HookConfig } from "../config"
import { settings } from "../settings"
// disable report
const blackList = [
@@ -16,6 +17,8 @@ const BLOCKED_BODY = JSON.stringify({
})
function checkBlack(url: string): boolean {
// 设置里关掉后即时放行,无需刷新页面
if (!settings.blockReport) return false
return blackList.some(black => url.includes(black))
}
+27 -9
View File
@@ -1,11 +1,14 @@
import { type HookConfig } from '../config'
import { getChapter } from '../api/content'
import { getBookInfoAndCatalog } from '../api/book'
import { decryptComicImage } from '../utils/crypto'
import { applyBookCss } from '../api/bookcss'
import { processFootnotes, bindFootnoteInteraction } from '../utils/footnote'
import { settings } from '../settings'
import { fetch } from '../config'
// import defaultcss from '../assets/default.css?raw';
import { cloneElement } from '../utils';
import { type Book } from '../types'
import { decryptComicImage } from '../crypto/content';
// import moment from 'moment'
let currentBook: Book | null = null
@@ -36,20 +39,34 @@ async function insertContent() {
}).__INITIAL_STATE__
const chapterTitle = chapter.novel_data?.title || pageState?.reader?.chapterData?.title
if (typeof chapter.content === 'string') {
// TODO: 支持部分书籍的CSS内容
// 书籍自带的排版样式(css_map),作用域限定在正文容器内后注入
void applyBookCss(chapter.novel_data?.css_map, '#fqa-reader-content')
const dp = new DOMParser()
const doc = dp.parseFromString(chapter.content, 'text/html')
const body = doc.body
const article = body.querySelector('article')
const toProcess = article || body
// XHTML 里的 <link href="Styles/xxx.css"> 是 EPUB 相对路径,页面里解析不到,
// 留着只会产生 404 请求,样式已由 applyBookCss 处理
body.querySelectorAll('link[rel="stylesheet"]').forEach((el) => el.remove())
let article = body.querySelector('article')
let toProcess = article || body
// 章内注释:把裂图标记换成可点的上标序号,并重排文末注释列表
processFootnotes(toProcess)
for (let i = 0; i < toProcess.childNodes.length; i++) {
if (i < 1 && (toProcess.childNodes[i] as HTMLElement)?.innerHTML.includes(chapterTitle)) {
// console.log('Child node:', toProcess.childNodes[i] as HTMLElement)
if (i < 2 && (toProcess.childNodes[i] as HTMLElement)?.innerHTML?.includes(chapterTitle)) {
// remove duplicate title
toProcess.removeChild(toProcess.childNodes[i] as HTMLElement)
break
}
}
if (!article) {
article = document.createElement('article')
article.innerHTML = toProcess.innerHTML
toProcess = article
}
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)")
if (readerContainer) {
// 防止插入多次
@@ -58,13 +75,14 @@ async function insertContent() {
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
scriptContainer.id = 'fqa-reader-content'
scriptContainer.classList.add('fqa')
scriptContainer.classList.remove('noselect') // allow text selection
if (settings.allowCopy) scriptContainer.classList.remove('noselect')
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
}
scriptContainer.innerHTML = ''
// 隐藏原来的内容div
readerContainer.classList.add('fqa-hide')
scriptContainer.appendChild(toProcess)
bindFootnoteInteraction(scriptContainer)
}
} else if (chapter.content.picInfos) { // comic
if (chapter.content.encrypt) {
@@ -92,7 +110,7 @@ async function insertContent() {
scriptContainer.id = 'fqa-reader-content'
scriptContainer.classList.add('fqa')
scriptContainer.classList.add('fqa-comic-reader')
scriptContainer.classList.remove('noselect')
if (settings.allowCopy) scriptContainer.classList.remove('noselect')
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
}
scriptContainer.innerHTML = ''
@@ -156,7 +174,7 @@ async function insertContent() {
scriptContainer.id = 'fqa-reader-content'
scriptContainer.classList.add('fqa')
scriptContainer.classList.add('fqa-comic-reader')
scriptContainer.classList.remove('noselect')
if (settings.allowCopy) scriptContainer.classList.remove('noselect')
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
}
scriptContainer.innerHTML = ''
@@ -170,7 +188,7 @@ async function insertContent() {
let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle")
document.querySelector('#fqa-subtitle')?.remove() // prevent duplicate subtitle
if (muyeReaderSubtitle) {
// 脱离原页面 Vue tree ,防止被二次修改
// 脱离原页面防止被二次修改
let _cloned = cloneElement(muyeReaderSubtitle)
muyeReaderSubtitle.classList.add('fqa-hide')
_cloned.id = 'fqa-subtitle'
+63
View File
@@ -1,6 +1,8 @@
import { getDetailedUserInfo, userState } from '../api/user'
import type { HookConfig } from '../config'
import bookshelf from '../assets/bookshelf.svg?raw'
import { openSettings } from '../settingsPanel'
import { cloneElement } from '../utils'
function formatReadingTime(readBookTime: bigint): string {
let minutes = readBookTime / 60_000n
@@ -26,6 +28,12 @@ function createMenuItem(text: string, icon?: string, onclick?: () => void): HTML
if (icon) {
const iconDiv = document.createElement('div')
iconDiv.classList.add('slogin-user-avatar__menu-item__icon')
if (document.querySelector('div.muye-reader-dark')) {
console.log('isdark')
iconDiv.classList.add('fqa-icon-dark')
} else {
console.log('islight')
}
iconDiv.innerHTML = icon
inner1.appendChild(iconDiv)
}
@@ -62,6 +70,8 @@ async function mainHook(_previous?: string): Promise<void> {
window.open('https://fanqienovel.com/bookshelf', '_blank')
})
secondDiv.insertAdjacentElement('afterend', thirdDiv)
// 助手设置固定放在菜单最后
menuInner.appendChild(createMenuItem('助手设置', undefined, openSettings))
}
const scan = (root: HTMLElement) => {
@@ -89,16 +99,69 @@ async function mainHook(_previous?: string): Promise<void> {
scan(document.body)
}
/**
* 未登录时没有用户菜单,改为复制「注册」按钮做一个「助手设置」入口。
*/
async function guestHook(_previous?: string): Promise<void> {
const injected = new WeakSet<HTMLElement>()
const inject = (registerBtn: HTMLElement) => {
const parent = registerBtn.parentElement
if (!parent || injected.has(parent)) return
if (parent.querySelector('.fqa-settings-entry')) return
injected.add(parent)
const entry = cloneElement(registerBtn)
entry.classList.add('fqa-settings-entry')
entry.textContent = '助手设置'
entry.addEventListener('click', event => {
// 原按钮带跳转逻辑,别让它继续冒泡
event.preventDefault()
event.stopPropagation()
openSettings()
})
registerBtn.insertAdjacentElement('afterend', entry)
}
const scan = (root: ParentNode) => {
root.querySelectorAll<HTMLElement>('.slogin-user-avatar__buttons__item').forEach(el => {
if (el.classList.contains('fqa-settings-entry')) return
if (el.textContent?.trim() === '注册') inject(el)
})
}
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) scan(node)
}
}
})
observer.observe(document.body, { childList: true, subtree: true })
scan(document)
}
function filter(path: string, _query: URLSearchParams, _hash: string): boolean {
return userState.isLogin && !path.startsWith('/writer') && !path.startsWith('/welfare')
}
function guestFilter(path: string, _query: URLSearchParams, _hash: string): boolean {
return !userState.isLogin && !path.startsWith('/writer') && !path.startsWith('/welfare')
}
const _exports: HookConfig[] = [
{
id: 'userHook',
event: 'load',
filter,
handler: mainHook
},
{
id: 'userHook_guest',
event: 'load',
filter: guestFilter,
handler: guestHook
}
]
+8
View File
@@ -1,9 +1,11 @@
import _config from './config'
import injectCSS from './cssInject'
import initFontDecrypt from './fontDecrypt'
import initUserStyle from './userStyle'
import { onLoad, onUrlChange, onHashChange } from './hooks'
import { version, name } from '../package.json'
import initUser from './api/user'
import { ensureDevice } from './api/provision'
const win = unsafeWindow
@@ -47,6 +49,12 @@ async function mainInit() {
await injectCSS()
// 用户自定义样式(阅读器字体 / 自定义 CSS)
initUserStyle()
// APP 接口需要已注册的设备(首次会注册并激活会员,之后走本地缓存)
await ensureDevice()
await initUser()
void onLoad()
+99
View File
@@ -0,0 +1,99 @@
import { reactive, watch } from 'vue'
import { read, write } from './localStorage'
const STORE_KEY = 'settings'
/** API 偏好 */
export type ApiPreference = 'app' | 'redcandle'
export interface Settings {
/* --- 常规 --- */
/** 解密网页端混淆字体 */
decryptFont: boolean
/** 拦截网页事件上报 */
blockReport: boolean
/** 允许阅读器复制文本 */
allowCopy: boolean
/* --- 界面 --- */
/** 阅读器字体,空字符串表示跟随页面默认 */
readerFont: string
/** 是否应用自定义 CSS */
customCssEnabled: boolean
/** 自定义 CSS 内容。关闭开关时仍然保留,只是不应用 */
customCss: string
/* --- 协议 --- */
apiPreference: ApiPreference
/** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */
deviceId: string
installId: string
deviceType: string
}
export const DEFAULT_SETTINGS: Settings = {
decryptFont: true,
blockReport: true,
allowCopy: true,
readerFont: '',
customCssEnabled: false,
customCss: '',
apiPreference: 'app',
deviceId: '',
installId: '',
deviceType: '',
}
/** 只取已知字段,避免旧版本残留的键污染 */
function normalize(raw: unknown): Settings {
const s = { ...DEFAULT_SETTINGS }
if (!raw || typeof raw !== 'object') return s
const o = raw as Record<string, unknown>
for (const key of Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>) {
const v = o[key]
if (v === undefined || v === null) continue
if (typeof DEFAULT_SETTINGS[key] === typeof v) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as any)[key] = v
}
}
if (s.apiPreference !== 'app' && s.apiPreference !== 'redcandle') {
s.apiPreference = DEFAULT_SETTINGS.apiPreference
}
return s
}
/** 全局设置对象。直接改字段即可,会自动持久化 */
export const settings = reactive<Settings>(normalize(read(STORE_KEY)))
let saveTimer: ReturnType<typeof setTimeout> | undefined
watch(
settings,
() => {
// 输入框逐字符触发,节流后再写盘
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = undefined
write(STORE_KEY, { ...settings })
}, 200)
},
{ deep: true }
)
/** 立即落盘(用于关闭面板等需要确保写入的场合) */
export function flushSettings(): void {
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = undefined
}
write(STORE_KEY, { ...settings })
}
/** 恢复默认值 */
export function resetSettings(): void {
Object.assign(settings, DEFAULT_SETTINGS)
flushSettings()
}
+49
View File
@@ -0,0 +1,49 @@
// 设置面板的挂载/卸载。
// 面板是全局单例,从用户菜单里唤起。
import { createApp, h, type App } from 'vue'
import SettingsView from './views/SettingsView.vue'
import settingscss from './assets/settings.css?raw'
const CONTAINER_ID = 'fqa-settings-root'
const STYLE_ID = 'fqa-settings-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 = settingscss
document.head.appendChild(style)
}
export function closeSettings(): void {
app?.unmount()
app = null
container?.remove()
container = null
}
export function openSettings(): void {
if (app) return
injectStyle()
container = document.createElement('div')
container.id = CONTAINER_ID
document.body.appendChild(container)
app = createApp({
render: () => h(SettingsView, { onClose: closeSettings }),
})
app.config.errorHandler = (err, _instance, info) => {
console.error(`[fqa:settings] Vue error (${info}):`, err)
}
app.mount(container)
}
export function toggleSettings(): void {
if (app) closeSettings()
else openSettings()
}
+9
View File
@@ -111,3 +111,12 @@ export type BookShelfTabKey = 'all' | 'group' | 'publish'
export type BookShelfCell =
| { kind: 'book'; key: string; entry: BookShelfEntry }
| { kind: 'group'; key: string; group: BookShelfGroup }
/** 右键菜单项。有 children 时展开二级面板 */
export interface MenuItem {
key: string
label: string
/** 危险操作,标红 */
danger?: boolean
disabled?: boolean
children?: MenuItem[]
}
+49
View File
@@ -0,0 +1,49 @@
import { watch } from 'vue'
import { settings } from './settings'
const STYLE_ID = 'fqa-user-style'
/** 阅读器正文容器(含脚本接管的克隆容器) */
const READER_SCOPE = '#fqa-reader-content, .muye-reader-content'
function buildCss(): string {
const parts: string[] = []
const font = settings.readerFont.trim()
if (font) {
// 字体名可能含空格,统一加引号;末尾保留 inherit 兜底
const family = /^["']|,/.test(font) ? font : `"${font}"`
parts.push(`${READER_SCOPE}, ${READER_SCOPE} p { font-family: ${family}, inherit !important; }`)
}
if (settings.customCssEnabled && settings.customCss.trim()) {
parts.push(settings.customCss)
}
return parts.join('\n')
}
function apply(): void {
const css = buildCss()
let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null
if (!css) {
el?.remove()
return
}
if (!el) {
el = document.createElement('style')
el.id = STYLE_ID
document.head.appendChild(el)
}
el.textContent = css
}
/** 启动时应用一次,并监听后续改动 */
export default function initUserStyle(): void {
apply()
watch(
() => [settings.readerFont, settings.customCssEnabled, settings.customCss],
apply
)
}
+16
View File
@@ -0,0 +1,16 @@
export async function gzip(data: ArrayBuffer | string): Promise<ArrayBuffer> {
if (typeof data === 'string') {
data = new TextEncoder().encode(data).buffer;
}
const encoder = new CompressionStream('gzip');
const stream = new Blob([data]).stream().pipeThrough(encoder);
const compressed = new Response(stream).arrayBuffer();
return compressed;
}
export async function gunzip(data: ArrayBuffer): Promise<ArrayBuffer> {
const decoder = new DecompressionStream('gzip');
const stream = new Blob([data]).stream().pipeThrough(decoder);
const decompressed = new Response(stream).arrayBuffer();
return decompressed;
}
-190
View File
@@ -1,190 +0,0 @@
import { defaultConfig, shared_key, type DeviceConfig } from "../config";
// 番茄小说始终运行在安全上下文,所以 unsafeWindow 一定能兜底获取到 Crypto API 对象。
// 如果没有,更大可能是浏览器版本过低,或者被某些扩展破坏了安全上下文。
// 惰性求值:模块顶层不抛错,否则会连带炸掉 CSS 注入、字体解密等不依赖本模块的功能。
function getCrypto(): Crypto {
const c = globalThis.crypto ?? (unsafeWindow.crypto as Crypto | undefined);
if (!c?.subtle) {
throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API");
}
return c;
}
function getSubtle(): SubtleCrypto {
return getCrypto().subtle;
}
export function b64decode(b64: string): ArrayBuffer {
const binaryString = atob(b64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export function b64encode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
const chunks: string[] = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
chunks.push(
String.fromCharCode(...bytes.subarray(i, i + chunkSize)),
);
}
return btoa(chunks.join(""));
}
export function unhex(hex: string): ArrayBuffer {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
const byte = parseInt(hex.slice(i, i + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex string");
}
bytes[i / 2] = byte;
}
return bytes.buffer;
}
export function randomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const array = new Uint8Array(length);
getCrypto().getRandomValues(array);
for (let i = 0; i < length; i++) {
result += chars.charAt(array[i]! % chars.length);
}
return result;
}
// 正文算法: AES/CBC/PKCS5Padding
// 返回 string(xhtml/html) | 章节 JSON 对象 | undefined(解析失败)
export async function decryptChapter(
encrypted: string,
config: DeviceConfig = defaultConfig,
): Promise<string | unknown> {
if (!encrypted) {
throw new Error("Invalid encrypted chapter");
};
const buf = b64decode(encrypted);
const iv = buf.slice(0, 16);
const data = buf.slice(16);
const key = config.key_info?.key;
if (!key) {
throw new Error("Missing decrypt key")
}
const subtle = getSubtle();
const cryptoKey = await subtle.importKey(
"raw",
key,
{ name: "AES-CBC" },
false,
["decrypt"],
);
return subtle.decrypt(
{ name: "AES-CBC", iv },
cryptoKey,
data,
).then((decrypted) => {
const decoder = new TextDecoder();
const plain = decoder.decode(decrypted);
if (plain.trim().startsWith("<")) { // xhtml || html
return plain;
}
try {
return JSON.parse(plain); // mostly JSONObject
} catch (e: any) {
console.warn('Invalid chapter content: ', plain, e);
return undefined;
}
});
}
function reverseHex(value: string): string {
const be = BigInt(value).toString(16).padStart(32, "0");
let result = "";
for (let i = be.length; i > 0; i -= 2) result += be.slice(i - 2, i);
return result;
}
export async function encryptKeyinfoBody(config: DeviceConfig): Promise<string> {
const deviceId = config.device_id;
const iv = new TextEncoder().encode(randomString(16));
// 如果是i32类型(返回8字节)则啥也不用管。但是如果返回16以上(i64+),则需要取前八位,后面是0,删了
// BigInt按i64返回,所以取前八位
const data = new Uint8Array(unhex(reverseHex(deviceId))).slice(0, 8);
console.log(data)
const subtle = getSubtle();
const k = await subtle.importKey(
"raw",
shared_key,
{ name: "AES-CBC" },
false,
["encrypt"],
)
const encrypted = await subtle.encrypt(
{ name: "AES-CBC", iv },
k,
data
);
const final = new Uint8Array(iv.length + encrypted.byteLength);
console.log(final)
final.set(iv, 0);
final.set(new Uint8Array(encrypted), iv.length);
return JSON.stringify({
content: b64encode(final.buffer),
});
}
export async function decryptKeyinfoResponse(encrypted: string): Promise<ArrayBuffer> {
const buf = b64decode(encrypted);
const iv = buf.slice(0, 16);
const data = buf.slice(16);
const subtle = getSubtle();
const k = await subtle.importKey(
"raw",
shared_key,
{ name: "AES-CBC" },
false,
["decrypt"]
);
return subtle.decrypt(
{ name: "AES-CBC", iv },
k,
data
);
}
export async function decryptComicImage(image: ArrayBuffer, key: string /* hex */): Promise<ArrayBuffer> {
const subtle = getSubtle();
// AESGCM
const cryptoKey = await subtle.importKey(
"raw",
unhex(key),
{ name: "AES-GCM" },
false,
["decrypt"],
);
const iv = image.slice(0, 12);
const data = image.slice(12);
return await subtle.decrypt(
{ name: "AES-GCM", iv },
cryptoKey,
data,
);
}
+235
View File
@@ -0,0 +1,235 @@
// 章内注释(EPUB footnote)处理。
//
// 番茄的正文 XHTML 里,注释引用长这样:
// <a epub:type="noteref" href="#a1"><img class="bdFootnote" src="$bdFootnote"/></a>
// 注释内容集中放在正文末尾:
// <section epub:type="footnotes"><aside epub:type="footnote" id="a1">…</aside></section>
//
// 直接渲染有几个问题:
// 1. src="$bdFootnote" 是占位符不是真实图片,会变成裂图;页面自带的
// `.bdFootnote{display:none}` 又会把它整个藏掉,导致注释标记看不见;
// 2. <aside> 全堆在文末,且顺序和正文引用顺序不一致(实测某章引用顺序是
// a1,2,3,4,a2,a3,5,而 aside 顺序是 a1,a2,a3,2,3,4,5);
// 3. 点击 href="#a1" 会改 URL hash,触发脚本自身的 hashchange 路由。
//
// 这里把标记替换成可见的上标序号,按正文引用顺序重新编号,并支持点击/悬浮
// 查看,同时保留文末的注释列表作为兜底。
/** 注释区块与标记的类名 */
export const FOOTNOTE_CLASS = 'fqa-footnote'
export const FOOTNOTE_REF_CLASS = 'fqa-footnote-ref'
export const FOOTNOTE_LIST_CLASS = 'fqa-footnote-list'
/**
* 按属性名 + 属性值筛选元素。
*
* 不能用 `[epub\:type="footnote"]` 这类选择器:HTML 解析下 `epub:type` 是一个
* 含冒号的普通属性名,CSS 无法表达,浏览器会直接抛 SyntaxError(已实测)。
* 所以退化成遍历 + getAttribute。
*/
function byAttr(root: ParentNode, tag: string, attr: string, value?: string): Element[] {
return [...root.querySelectorAll(tag)].filter((el) => {
const v = el.getAttribute(attr)
if (v === null) return false
return value === undefined || v === value
})
}
/**
* 处理容器内的章内注释。就地修改传入的元素。
*
* @returns 处理到的注释条数
*/
export function processFootnotes(root: ParentNode): number {
const notes = new Map<string, string>() // id -> 注释 HTML
const sections = byAttr(root, 'section', 'epub:type', 'footnotes')
for (const section of sections) {
for (const aside of section.querySelectorAll('aside')) {
const id = aside.getAttribute('id')
if (id) notes.set(id, aside.innerHTML.trim())
}
}
// 没有 <section> 包裹时,兜一遍散落的 aside
if (notes.size === 0) {
for (const aside of byAttr(root, 'aside', 'epub:type', 'footnote')) {
const id = aside.getAttribute('id')
if (id) notes.set(id, aside.innerHTML.trim())
}
}
// 标记:优先按 epub:type=noteref,退回到"内含 bdFootnote 图片的锚点"
const collectRefs = (scope: ParentNode): Element[] => {
const set = new Set<Element>(byAttr(scope, 'a', 'epub:type', 'noteref'))
for (const img of scope.querySelectorAll('img.bdFootnote')) {
const a = img.closest('a[href^="#"]')
if (a && scope.contains(a)) set.add(a)
}
// 保持文档顺序
return [...scope.querySelectorAll('a')].filter((a) => set.has(a))
}
// 只处理正文里的标记:注释区内部的引用(注释套注释)留到后面单独处理,
// 否则它们会随 <section> 一起被删掉而丢失编号
const inSection = (el: Element) => sections.some((s) => s.contains(el))
const refs = collectRefs(root).filter((a) => !inSection(a))
if (refs.length === 0 && notes.size === 0) return 0
// 按正文中出现的顺序重新编号
const ordered: Array<{ num: number; text: string }> = []
const numberOf = new Map<string, number>() // note id -> 序号
let counter = 0
const makeSup = (num: number, text: string): HTMLElement => {
const sup = document.createElement('sup')
sup.className = FOOTNOTE_REF_CLASS
sup.textContent = String(num)
sup.setAttribute('role', 'button')
sup.setAttribute('tabindex', '0')
// title 作为无脚本兜底:原生 tooltip
sup.title = stripTags(text)
return sup
}
refs.forEach((ref) => {
const href = ref.getAttribute('href') ?? ''
const id = href.startsWith('#') ? href.slice(1) : ''
const text = notes.get(id)
if (text === undefined) return
counter += 1
numberOf.set(id, counter)
ordered.push({ num: counter, text })
ref.replaceWith(makeSup(counter, text))
})
// 未被正文直接引用的注释(例如注释里套的注释)也要收录,序号接在后面
for (const [id, text] of notes) {
if (numberOf.has(id)) continue
counter += 1
numberOf.set(id, counter)
ordered.push({ num: counter, text })
}
// 重建文末注释列表(按新序号排序,去掉 epub 语义标签)
for (const section of sections) section.remove()
if (ordered.length > 0) {
const list = document.createElement('ol')
list.className = FOOTNOTE_LIST_CLASS
for (const { num, text } of ordered) {
const li = document.createElement('li')
li.id = `fqa-fn-${num}`
li.innerHTML = text
// 注释文本里可能又引用了别的注释,同样替换成上标序号
for (const inner of collectRefs(li)) {
const innerId = (inner.getAttribute('href') ?? '').replace(/^#/, '')
const innerNum = numberOf.get(innerId)
const innerText = notes.get(innerId)
if (innerNum && innerText !== undefined) {
inner.replaceWith(makeSup(innerNum, innerText))
} else {
inner.remove()
}
}
list.appendChild(li)
}
const wrapper = document.createElement('section')
wrapper.className = FOOTNOTE_CLASS
const heading = document.createElement('div')
heading.className = 'fqa-footnote-title'
heading.textContent = '注释'
wrapper.appendChild(heading)
wrapper.appendChild(list)
root.appendChild(wrapper)
}
// 清理没有配对上的残留标记(避免留下裂图)
for (const img of root.querySelectorAll('img.bdFootnote')) img.remove()
return ordered.length
}
/** 取纯文本,供 title 属性使用 */
function stripTags(html: string): string {
const el = document.createElement('div')
el.innerHTML = html
return (el.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/**
* 兜底:让注释区跟随网页的字号设置。
*
* 页面通过外层 .muye-reader-content-N 控制阅读字号,但规则只作用在 `... p` 上
* (`.muye-reader-content-32 p{font-size:3.2rem}`)。注释区是 <p> 的兄弟节点,
* 拿不到这个字号,em 会退回根字号——实测 16 档和 32 档都停在 8.5px。
*
* 已知的 5 个档位由 script.css 里的规则直接覆盖(纯 CSS,切换即时生效)。
* 这里只处理"页面新增了未知档位"的情况:此时 CSS 命中不到,
* 才用正文实际字号写入 --fqa-body-size 兜底。
*/
const KNOWN_TIERS = /muye-reader-content-(16|20|24|28|32)\b/
export function syncFootnoteFontSize(container: HTMLElement): void {
const box = container.closest('[class*="muye-reader-content-"]')
const apply = () => {
// 已知档位交给 CSS,内联变量会盖掉档位规则并在切换时残留旧值,
// 所以必须清掉
container.style.removeProperty('--fqa-body-size')
if (!box || KNOWN_TIERS.test(box.className)) return
const p = container.querySelector('p')
if (!p) return
const size = getComputedStyle(p).fontSize
if (size) container.style.setProperty('--fqa-body-size', size)
}
apply()
if (!box) return
const holder = container as HTMLElement & { fqaFontObserver?: MutationObserver }
if (holder.fqaFontObserver) return
// 用户改字号时页面会换掉外层容器的 class,监听后重新判断
const observer = new MutationObserver(apply)
observer.observe(box, { attributes: true, attributeFilter: ['class'] })
holder.fqaFontObserver = observer
}
/**
* 绑定注释的点击交互:点上标滚动到对应注释并高亮。
* 用事件委托,容器内容更新后无需重新绑定。
*/
export function bindFootnoteInteraction(container: HTMLElement): void {
syncFootnoteFontSize(container)
if (container.dataset.fqaFootnoteBound === '1') return
container.dataset.fqaFootnoteBound = '1'
const activate = (sup: HTMLElement) => {
const num = sup.textContent?.trim()
if (!num) return
const target = container.querySelector(`#fqa-fn-${num}`)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('fqa-footnote-active')
setTimeout(() => target.classList.remove('fqa-footnote-active'), 1600)
}
container.addEventListener('click', (e) => {
const sup = (e.target as HTMLElement)?.closest?.(`.${FOOTNOTE_REF_CLASS}`)
if (sup) {
e.preventDefault()
activate(sup as HTMLElement)
}
})
container.addEventListener('keydown', (e) => {
const ke = e as KeyboardEvent
if (ke.key !== 'Enter' && ke.key !== ' ') return
const sup = (ke.target as HTMLElement)?.closest?.(`.${FOOTNOTE_REF_CLASS}`)
if (sup) {
ke.preventDefault()
activate(sup as HTMLElement)
}
})
}
+11
View File
@@ -48,3 +48,14 @@ export function chunk<T>(list: T[], size: number): T[][] {
}
return result;
}
export function concatArrayBuffers(...buffers: ArrayBuffer[]): ArrayBuffer {
const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const buf of buffers) {
result.set(new Uint8Array(buf), offset)
offset += buf.byteLength
}
return result.buffer
}
+7
View File
@@ -9,6 +9,7 @@ const emit = defineEmits<{
(e: 'leave'): void
(e: 'open', entry: BookShelfEntry): void
(e: 'visible', entry: BookShelfEntry): void
(e: 'contextmenu', payload: { entry: BookShelfEntry; x: number; y: number }): void
}>()
const detail = computed(() => props.entry.detail)
@@ -50,6 +51,11 @@ function onEnter(event: MouseEvent) {
emit('hover', { entry: props.entry, el: event.currentTarget as HTMLElement })
}
function onContextMenu(event: MouseEvent) {
event.preventDefault()
emit('contextmenu', { entry: props.entry, x: event.clientX, y: event.clientY })
}
/** 封面淡入。用状态而非直接改 class,避免重渲染后卡在透明态 */
const imgLoaded = ref(false)
watch(
@@ -94,6 +100,7 @@ onBeforeUnmount(() => {
:aria-label="title"
@mouseenter="onEnter"
@mouseleave="emit('leave')"
@contextmenu="onContextMenu"
@click="emit('open', entry)"
@keydown.enter.prevent="emit('open', entry)"
@keydown.space.prevent="emit('open', entry)"
+105 -2
View File
@@ -3,8 +3,10 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BookCard from './BookCard.vue'
import BookGroupCard from './BookGroupCard.vue'
import BookHoverCard from './BookHoverCard.vue'
import ContextMenu from './ContextMenu.vue'
import { TABS, useBookshelf } from './useBookshelf'
import type { BookShelfEntry, BookShelfGroup, BookShelfTabKey } from '../types'
import { moveToGroup, removeFromBookshelf } from '../api/bookshelf'
import type { BookShelfEntry, BookShelfGroup, BookShelfTabKey, MenuItem } from '../types'
/** 持续悬停多久后展示详情 */
const HOVER_DELAY = 300
@@ -14,7 +16,7 @@ const HOVER_WIDTH = 280
const HOVER_GAP = 12
const VIEWPORT_MARGIN = 8
const { loading, detailLoading, error, counts, load, ensureDetails, cellsOf, findGroup, PAGE_SIZE } =
const { loading, detailLoading, error, counts, groups, load, ensureDetails, cellsOf, findGroup, PAGE_SIZE } =
useBookshelf()
const activeTab = ref<BookShelfTabKey>('all')
@@ -244,6 +246,97 @@ function openGroup(group: BookShelfGroup) {
openedGroupName.value = group.name
}
/* ------------------------------ 右键菜单 ------------------------------ */
const menuVisible = ref(false)
const menuPos = ref({ x: 0, y: 0 })
const menuEntry = ref<BookShelfEntry | null>(null)
/** 操作结果提示,几秒后自动消失 */
const toast = ref<string | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | undefined
function showToast(msg: string) {
toast.value = msg
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toast.value = null
toastTimer = undefined
}, 2600)
}
const MOVE_PREFIX = 'move:'
const NO_GROUP_KEY = `${MOVE_PREFIX}`
const menuItems = computed<MenuItem[]>(() => {
const entry = menuEntry.value
if (!entry) return []
const current = entry.item.group_name ?? ''
// 目标分组:所有分组去掉当前所在的那个
const targets: MenuItem[] = groups.value
.filter(g => g.name !== current)
.map(g => ({ key: `${MOVE_PREFIX}${g.name}`, label: g.name }))
// 已经在某个分组里时,额外给一个移出分组的选项
if (current) targets.push({ key: NO_GROUP_KEY, label: '无分组' })
return [
{ key: 'open', label: '打开' },
{ key: 'detail', label: '查看详情' },
{
key: 'move',
label: '移动到分组',
disabled: targets.length === 0,
children: targets
},
{ key: 'remove', label: '从书架删除', danger: true }
]
})
function onCardContextMenu({ entry, x, y }: { entry: BookShelfEntry; x: number; y: number }) {
hideHover(true)
menuEntry.value = entry
menuPos.value = { x, y }
menuVisible.value = true
}
async function onMenuSelect(key: string) {
const entry = menuEntry.value
if (!entry) return
const bookId = entry.item.book_id
if (key === 'open') {
openBook(entry)
return
}
if (key === 'detail') {
unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`
return
}
if (key === 'remove') {
if (!unsafeWindow.confirm(`确定要把《${entry.detail?.title ?? bookId}》从书架删除吗?`)) return
try {
await removeFromBookshelf(bookId)
showToast('已从书架删除')
await refresh()
} catch (err) {
console.error('[fqa:bookshelf] 删除失败:', err)
showToast(err instanceof Error ? err.message : '删除失败')
}
return
}
if (key.startsWith(MOVE_PREFIX)) {
const groupName = key.slice(MOVE_PREFIX.length)
try {
await moveToGroup(bookId, groupName)
showToast(groupName ? `已移动到「${groupName}」` : '已移出分组')
await refresh()
} catch (err) {
console.error('[fqa:bookshelf] 移动分组失败:', err)
showToast(err instanceof Error ? err.message : '移动分组失败')
}
}
}
function backToList() {
openedGroupName.value = null
hideHover(true)
@@ -363,6 +456,7 @@ onBeforeUnmount(() => {
@leave="scheduleHide"
@open="openBook"
@visible="onCardVisible"
@contextmenu="onCardContextMenu"
/>
<BookGroupCard v-else :group="cell.group" @open="openGroup" @visible="onGroupVisible" />
</template>
@@ -382,6 +476,15 @@ onBeforeUnmount(() => {
@panel-enter="onPanelEnter"
@panel-leave="onPanelLeave"
/>
<ContextMenu
:visible="menuVisible"
:x="menuPos.x"
:y="menuPos.y"
:items="menuItems"
@select="onMenuSelect"
@close="menuVisible = false"
/>
<div v-if="toast" class="fqa-toast">{{ toast }}</div>
</Teleport>
</div>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MenuItem } from '../types'
const props = defineProps<{
visible: boolean
x: number
y: number
items: MenuItem[]
}>()
const emit = defineEmits<{
(e: 'select', key: string): void
(e: 'close'): void
}>()
const rootRef = ref<HTMLElement | null>(null)
const subRef = ref<HTMLElement | null>(null)
/** 当前展开子菜单的项 key */
const openKey = ref<string | null>(null)
const pos = ref({ x: 0, y: 0 })
const subPos = ref({ x: 0, y: 0 })
const MARGIN = 8
/** 菜单可能超出视口,翻转/夹取到可见区域内 */
async function place() {
pos.value = { x: props.x, y: props.y }
await nextTick()
const el = rootRef.value
if (!el) return
const { width, height } = el.getBoundingClientRect()
let x = props.x
let y = props.y
if (x + width > window.innerWidth - MARGIN) x = props.x - width
if (y + height > window.innerHeight - MARGIN) y = window.innerHeight - height - MARGIN
pos.value = {
x: Math.max(MARGIN, x),
y: Math.max(MARGIN, y),
}
}
watch(
() => [props.visible, props.x, props.y],
() => {
openKey.value = null
if (props.visible) void place()
}
)
async function openSub(item: MenuItem, event: MouseEvent) {
if (!item.children?.length) {
openKey.value = null
return
}
openKey.value = item.key
const row = event.currentTarget as HTMLElement
const rect = row.getBoundingClientRect()
subPos.value = { x: rect.right - 2, y: rect.top }
await nextTick()
const el = subRef.value
if (!el) return
const { width, height } = el.getBoundingClientRect()
let x = rect.right - 2
let y = rect.top
if (x + width > window.innerWidth - MARGIN) x = rect.left - width + 2
if (y + height > window.innerHeight - MARGIN) y = window.innerHeight - height - MARGIN
subPos.value = { x: Math.max(MARGIN, x), y: Math.max(MARGIN, y) }
}
function choose(item: MenuItem) {
if (item.disabled || item.children?.length) return
emit('select', item.key)
emit('close')
}
function onDocPointer(e: Event) {
const t = e.target as Node
if (rootRef.value?.contains(t) || subRef.value?.contains(t)) return
emit('close')
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
document.addEventListener('pointerdown', onDocPointer, true)
document.addEventListener('keydown', onKey)
// 滚动时直接收起,避免菜单和卡片脱节
window.addEventListener('scroll', () => emit('close'), true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointer, true)
document.removeEventListener('keydown', onKey)
})
const style = computed(() => ({ left: `${pos.value.x}px`, top: `${pos.value.y}px` }))
const subStyle = computed(() => ({ left: `${subPos.value.x}px`, top: `${subPos.value.y}px` }))
const activeChildren = computed(
() => props.items.find(i => i.key === openKey.value)?.children ?? []
)
</script>
<template>
<div v-if="visible" ref="rootRef" class="fqa-menu" :style="style" role="menu">
<div
v-for="item in items"
:key="item.key"
class="fqa-menu-row"
:class="{
'fqa-menu-danger': item.danger,
'fqa-menu-disabled': item.disabled,
'fqa-menu-open': openKey === item.key
}"
role="menuitem"
:aria-disabled="item.disabled"
@mouseenter="openSub(item, $event)"
@click="choose(item)"
>
<span>{{ item.label }}</span>
<span v-if="item.children?.length" class="fqa-menu-arrow">›</span>
</div>
</div>
<div
v-if="visible && activeChildren.length"
ref="subRef"
class="fqa-menu fqa-menu-sub"
:style="subStyle"
role="menu"
>
<div
v-for="child in activeChildren"
:key="child.key"
class="fqa-menu-row"
:class="{ 'fqa-menu-disabled': child.disabled }"
role="menuitem"
@click="choose(child)"
>
<span>{{ child.label }}</span>
</div>
</div>
</template>
+244
View File
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { settings, flushSettings } from '../settings'
import { provisionDevice } from '../api/provision'
import config from '../config'
import { version } from '../../package.json'
const emit = defineEmits<{ (e: 'close'): void }>()
type SectionKey = 'general' | 'ui' | 'protocol' | 'about'
const SECTIONS: Array<{ key: SectionKey; label: string }> = [
{ key: 'general', label: '常规' },
{ key: 'ui', label: '界面' },
{ key: 'protocol', label: '协议' },
{ key: 'about', label: '关于' },
]
const active = ref<SectionKey>('general')
/* 设备重新注册 */
const registering = ref(false)
const registerMsg = ref<string | null>(null)
async function reRegister() {
if (registering.value) return
registering.value = true
registerMsg.value = '正在注册新设备…'
try {
const c = await provisionDevice()
// 重新注册后清掉手填的设备信息,避免被旧值覆盖
settings.deviceId = ''
settings.installId = ''
settings.deviceType = ''
flushSettings()
registerMsg.value = `注册成功:${c.device_id}`
} catch (err) {
console.error('[fqa:settings] 设备注册失败:', err)
registerMsg.value = err instanceof Error ? `注册失败:${err.message}` : '注册失败'
} finally {
registering.value = false
}
}
/** 当前实际生效的设备(手填优先,否则用已注册的) */
const currentDevice = computed(() => ({
device_id: settings.deviceId || config.currentConfig.device_id,
install_id: settings.installId || config.currentConfig.install_id,
device_type: settings.deviceType || config.currentConfig.device_type || '',
}))
function close() {
flushSettings()
emit('close')
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') close()
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
const GREASYFORK =
'https://greasyfork.org/zh-CN/scripts/589115-%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E5%8A%A9%E6%89%8B'
const FEEDBACK = `${GREASYFORK}/feedback`
const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant'
</script>
<template>
<!-- 点遮罩关闭;点面板内部不冒泡到遮罩 -->
<div class="fqa-set-mask" @click.self="close">
<div class="fqa-set-dialog" role="dialog" aria-modal="true" aria-label="助手设置">
<button class="fqa-set-close" aria-label="关闭" @click="close">✕</button>
<!-- 左侧栏 -->
<nav class="fqa-set-side">
<div class="fqa-set-side-title">助手设置</div>
<div
v-for="s in SECTIONS"
:key="s.key"
class="fqa-set-nav"
:class="{ 'fqa-set-nav-active': active === s.key }"
role="button"
tabindex="0"
@click="active = s.key"
@keydown.enter.prevent="active = s.key"
>
{{ s.label }}
</div>
</nav>
<!-- 右侧内容 -->
<section class="fqa-set-main">
<!-- 常规 -->
<template v-if="active === 'general'">
<h3 class="fqa-set-h">常规</h3>
<label class="fqa-set-row">
<span class="fqa-set-label">解密网页端混淆字体</span>
<input v-model="settings.decryptFont" type="checkbox" class="fqa-set-switch" />
</label>
<label class="fqa-set-row">
<span class="fqa-set-label">拦截网页事件上报</span>
<input v-model="settings.blockReport" type="checkbox" class="fqa-set-switch" />
</label>
<label class="fqa-set-row">
<span class="fqa-set-label">允许阅读器复制文本</span>
<input v-model="settings.allowCopy" type="checkbox" class="fqa-set-switch" />
</label>
</template>
<!-- 界面 -->
<template v-else-if="active === 'ui'">
<h3 class="fqa-set-h">界面</h3>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">阅读器字体</span>
<input
v-model="settings.readerFont"
class="fqa-set-input"
type="text"
placeholder="留空表示使用默认字体"
/>
<p class="fqa-set-note">填写字体名称,例如「思源宋体」。留空则跟随网页默认。</p>
</div>
<div class="fqa-set-row">
<span class="fqa-set-label">自定义 CSS</span>
<input v-model="settings.customCssEnabled" type="checkbox" class="fqa-set-switch" />
</div>
<div class="fqa-set-row fqa-set-row-col">
<textarea
v-model="settings.customCss"
class="fqa-set-textarea"
:disabled="!settings.customCssEnabled"
spellcheck="false"
placeholder="/* 自定义 CSS */"
></textarea>
<p class="fqa-set-note">关闭开关后内容会保留,只是不再应用。</p>
</div>
</template>
<!-- 协议 -->
<template v-else-if="active === 'protocol'">
<h3 class="fqa-set-h">协议</h3>
<div class="fqa-set-row fqa-set-row-col">
<span class="fqa-set-label">API 偏好</span>
<div class="fqa-set-radios">
<label class="fqa-set-radio">
<input v-model="settings.apiPreference" type="radio" value="app" />
<span>番茄 APP</span>
</label>
<label class="fqa-set-radio">
<input v-model="settings.apiPreference" type="radio" value="redcandle" />
<span>红烛 APP</span>
</label>
</div>
<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>device_id</span>
<input
v-model="settings.deviceId"
class="fqa-set-input"
type="text"
:placeholder="currentDevice.device_id || '自动注册'"
/>
</label>
<label class="fqa-set-field">
<span>install_id (iid)</span>
<input
v-model="settings.installId"
class="fqa-set-input"
type="text"
:placeholder="currentDevice.install_id || '自动注册'"
/>
</label>
<label class="fqa-set-field">
<span>device_type</span>
<input
v-model="settings.deviceType"
class="fqa-set-input"
type="text"
:placeholder="currentDevice.device_type || '自动注册'"
/>
</label>
<div class="fqa-set-actions">
<button class="fqa-set-btn" :disabled="registering" @click="reRegister">
{{ registering ? '注册中…' : '重新注册' }}
</button>
<span v-if="registerMsg" class="fqa-set-note">{{ registerMsg }}</span>
</div>
</div>
</template>
<!-- 关于 -->
<template v-else>
<h3 class="fqa-set-h">关于</h3>
<p class="fqa-set-note">番茄小说助手 v{{ version }}</p>
<div class="fqa-set-links">
<div class="fqa-set-link-row">
<span>GreasyFork 地址:</span>
<a :href="GREASYFORK" target="_blank" rel="noreferrer noopener">跳转</a>
</div>
<div class="fqa-set-link-row">
<span>GitHub 地址:</span>
<a :href="GITHUB" target="_blank" rel="noreferrer noopener">跳转</a>
</div>
<div class="fqa-set-link-row">
<span>问题反馈:</span>
<a :href="FEEDBACK" target="_blank" rel="noreferrer noopener">GreasyFork</a>
<span>
或
<a :href="`${GITHUB}/issues`" target="_blank" rel="noreferrer noopener">
GitHub Issues
</a>
</span>
</div>
</div>
<div class="fqa-set-license">
<p>
本脚本基于 GNU General Public License 3.0 授权,完全开源且免费,修改/二次开发请注意遵守开源协议。
</p>
<p>本脚本使用 TypeScript + Vue 开发,请避免直接修改编译产物。</p>
</div>
</template>
</section>
</div>
</div>
</template>
+1 -1
View File
@@ -31,7 +31,7 @@ export default defineConfig({
'run-at': 'document-start',
match: ['*://*.fanqienovel.com/*'],
grant: ['GM_addStyle', 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_xmlhttpRequest', 'unsafeWindow'],
connect: ['fanqienovel.com', 'api5-sinfonlinec.jxbhmy.com', 'api3-sinfonlinec.jxbhmy.com', 'reading.snssdk.com'],
connect: ['fanqienovel.com', 'api5-sinfonlinec.jxbhmy.com', 'api3-sinfonlinec.jxbhmy.com', 'reading.snssdk.com', 'i.snssdk.com'],
},
build: {
fileName: 'fanqie-assistant.user.js',