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
+3594 -243

No files matched your search

+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
}
+67 -2
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]
@@ -82,4 +84,67 @@ export async function multidetail(books: BookShelfItem[]): Promise<BookShelfBook
results.push(item)
}
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(),
},
],
})
}
+25 -17
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),
@@ -50,4 +58,4 @@ export async function enrichBookCatalog(book: Book): Promise<void> {
const catalog = await getCatalog(book.book_id)
book.volume_list = catalog.volume_list
book.chapter_list = catalog.chapter_list
}
}
+74 -17
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,9 +75,72 @@ 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
const res = await fetch('https://fanqienovel.com/api/reader/full?itemId=' + itemId)
return await res.json()
}
}
+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
}
}