mirror of
https://github.com/naiyQAQ/fanqie-assistant.git
synced 2026-10-21 10:24:47 +00:00
feat & refactor: 支持app端签名的请求,支持右键操作书架书籍,逻辑大幅重构,更改默认的设备逻辑为自动注册,支持载入某些书籍内置的CSS、支持跳转正文注释等等
This commit is contained in:
1 parent
2f2ee606c0
commit
3586b01dbb
47 files changed
+3594
-243
No files matched your search
+103
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user