From 3586b01dbb04551df40be4541d1bac5a11dca4db Mon Sep 17 00:00:00 2001 From: naiy Date: Mon, 3 Aug 2026 16:17:49 +0800 Subject: [PATCH] =?UTF-8?q?feat=20&=20refactor:=20=E6=94=AF=E6=8C=81app?= =?UTF-8?q?=E7=AB=AF=E7=AD=BE=E5=90=8D=E7=9A=84=E8=AF=B7=E6=B1=82=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=B3=E9=94=AE=E6=93=8D=E4=BD=9C=E4=B9=A6?= =?UTF-8?q?=E6=9E=B6=E4=B9=A6=E7=B1=8D=EF=BC=8C=E9=80=BB=E8=BE=91=E5=A4=A7?= =?UTF-8?q?=E5=B9=85=E9=87=8D=E6=9E=84=EF=BC=8C=E6=9B=B4=E6=94=B9=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=9A=84=E8=AE=BE=E5=A4=87=E9=80=BB=E8=BE=91=E4=B8=BA?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=86=8C=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=BD=BD=E5=85=A5=E6=9F=90=E4=BA=9B=E4=B9=A6=E7=B1=8D=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E7=9A=84CSS=E3=80=81=E6=94=AF=E6=8C=81=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E6=AD=A3=E6=96=87=E6=B3=A8=E9=87=8A=E7=AD=89=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + package.json | 2 +- src/api/app.ts | 103 ++++++++++++ src/api/book.ts | 8 +- src/api/bookcss.ts | 207 +++++++++++++++++++++++ src/api/bookshelf.ts | 69 +++++++- src/api/catalog.ts | 42 +++-- src/api/content.ts | 91 ++++++++-- src/api/device.ts | 218 ++++++++++++++++++++++++ src/api/devicebody.ts | 181 ++++++++++++++++++++ src/api/provision.ts | 103 ++++++++++++ src/assets/bookshelf.css | 107 ++++++++++++ src/assets/script.css | 69 ++++++++ src/assets/settings.css | 326 ++++++++++++++++++++++++++++++++++++ src/config.ts | 1 + src/crypto/argus.ts | 143 ++++++++++++++++ src/crypto/content.ts | 65 +++++++ src/crypto/index.ts | 124 ++++++++++++++ src/crypto/ladon.ts | 18 ++ src/crypto/md5.ts | 161 ++++++++++++++++++ src/crypto/protobuf.ts | 54 ++++++ src/crypto/registerkey.ts | 60 +++++++ src/crypto/sign.ts | 56 +++++++ src/crypto/simon.ts | 47 ++++++ src/crypto/sm3.ts | 158 +++++++++++++++++ src/crypto/speck.ts | 79 +++++++++ src/crypto/ttencrypt.ts | 31 ++++ src/crypto/unidbgconfig.ts | 21 +++ src/fontDecrypt.ts | 12 ++ src/hooks/bookshelfHook.ts | 3 +- src/hooks/fetchHook.ts | 3 + src/hooks/readerHook.ts | 36 +++- src/hooks/userHook.ts | 63 +++++++ src/main.ts | 8 + src/settings.ts | 99 +++++++++++ src/settingsPanel.ts | 49 ++++++ src/types.ts | 11 +- src/userStyle.ts | 49 ++++++ src/utils/compress.ts | 16 ++ src/utils/crypto.ts | 190 --------------------- src/utils/footnote.ts | 235 ++++++++++++++++++++++++++ src/utils/index.ts | 11 ++ src/views/BookCard.vue | 7 + src/views/BookshelfView.vue | 107 +++++++++++- src/views/ContextMenu.vue | 146 ++++++++++++++++ src/views/SettingsView.vue | 244 +++++++++++++++++++++++++++ vite.config.ts | 2 +- 47 files changed, 3594 insertions(+), 243 deletions(-) create mode 100644 src/api/app.ts create mode 100644 src/api/bookcss.ts create mode 100644 src/api/device.ts create mode 100644 src/api/devicebody.ts create mode 100644 src/api/provision.ts create mode 100644 src/assets/settings.css create mode 100644 src/crypto/argus.ts create mode 100644 src/crypto/content.ts create mode 100644 src/crypto/index.ts create mode 100644 src/crypto/ladon.ts create mode 100644 src/crypto/md5.ts create mode 100644 src/crypto/protobuf.ts create mode 100644 src/crypto/registerkey.ts create mode 100644 src/crypto/sign.ts create mode 100644 src/crypto/simon.ts create mode 100644 src/crypto/sm3.ts create mode 100644 src/crypto/speck.ts create mode 100644 src/crypto/ttencrypt.ts create mode 100644 src/crypto/unidbgconfig.ts create mode 100644 src/settings.ts create mode 100644 src/settingsPanel.ts create mode 100644 src/userStyle.ts create mode 100644 src/utils/compress.ts delete mode 100644 src/utils/crypto.ts create mode 100644 src/utils/footnote.ts create mode 100644 src/views/ContextMenu.vue create mode 100644 src/views/SettingsView.vue diff --git a/.gitignore b/.gitignore index 51325f1..1e7f215 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ dist-ssr *.njsproj *.sln *.sw? + +temp \ No newline at end of file diff --git a/package.json b/package.json index 3bb4466..4bd0394 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fanqie-assistant", - "version": "0.0.3", + "version": "0.0.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/api/app.ts b/src/api/app.ts new file mode 100644 index 0000000..19c3e28 --- /dev/null +++ b/src/api/app.ts @@ -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): 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, headers?: Record) { + 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, headers?: Record) { + 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, + headers?: Record, +): Promise { + 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, + headers?: Record, +): Promise { + 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 +} diff --git a/src/api/book.ts b/src/api/book.ts index 8bea939..84a0334 100644 --- a/src/api/book.ts +++ b/src/api/book.ts @@ -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 { - 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 ( diff --git a/src/api/bookcss.ts b/src/api/bookcss.ts new file mode 100644 index 0000000..290c4ce --- /dev/null +++ b/src/api/bookcss.ts @@ -0,0 +1,207 @@ +// 书籍随正文下发的 CSS 处理。 +// +// 番茄的正文 XHTML 会 到 Styles/main.css 之类的样式表,真实地址在 +// novel_data.css_map(JSON 字符串,形如 {"Styles/main.css":"novel-static/xxx"})。 +// 这些样式表是给 EPUB 阅读器用的,直接注入页面会有两个问题: +// 1. 里面有 `body{font-family:宋体;color:#000000}` 这类元素级规则,会命中页面的 +// ,把整站字体和颜色改掉,深色模式直接失效; +// 2. `a{text-decoration:none}` 这类裸标签选择器会污染正文以外的元素。 +// 所以统一做作用域重写,把所有选择器限制在正文容器内。 + +/** css_uri 的 CDN 前缀 */ +const CDN_PREFIX = 'https://p3-novel.byteimg.com/origin/' + +/** uri -> css 文本 */ +const cssCache = new Map() + +/** 去掉 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 { + if (!cssMap || typeof cssMap !== 'string') return {} + try { + const parsed = JSON.parse(cssMap) + return parsed && typeof parsed === 'object' ? parsed as Record : {} + } catch { + return {} + } +} + +/** 按 uri 拉取样式表,带内存缓存 */ +async function fetchCss(uri: string): Promise { + 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 { + 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 { + 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 +} diff --git a/src/api/bookshelf.ts b/src/api/bookshelf.ts index 758b7d9..e08543e 100644 --- a/src/api/bookshelf.ts +++ b/src/api/bookshelf.ts @@ -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 { 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 { + 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 { + await shelfPost('/add', { + add_book_source: 0, + identify_data: [identify(bookId)], + }) +} + +/** 从书架移除 */ +export async function removeFromBookshelf(bookId: string): Promise { + await shelfPost('/delete', { + identify_data: [identify(bookId, Date.now())], + }) +} + +/** 分组间移动 */ +export async function moveToGroup(bookId: string, groupName: string): Promise { + 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(), + }, + ], + }) +} diff --git a/src/api/catalog.ts b/src/api/catalog.ts index 8d12e24..86197e0 100644 --- a/src/api/catalog.ts +++ b/src/api/catalog.ts @@ -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 { - 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 { const r = await getCatalogRaw(bookId) as Array - 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 = {} // title -> VolumeItem const chapters: Array = [] - // 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 { const catalog = await getCatalog(book.book_id) book.volume_list = catalog.volume_list book.chapter_list = catalog.chapter_list -} \ No newline at end of file +} diff --git a/src/api/content.ts b/src/api/content.ts index 059cf73..b3e2a0d 100644 --- a/src/api/content.ts +++ b/src/api/content.ts @@ -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 { 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 { 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 { } } + export async function getChapter(itemId: string, _retry?: number): Promise { if (typeof _retry === 'undefined') _retry = 0 if (_retry > 5) { @@ -65,12 +60,11 @@ export async function getChapter(itemId: string, _retry?: number): Promise 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 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> { + 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 = {} + 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 { // 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() -} \ No newline at end of file +} diff --git a/src/api/device.ts b/src/api/device.ts new file mode 100644 index 0000000..757b35b --- /dev/null +++ b/src/api/device.ts @@ -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, +): 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 { + 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 { + 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 { + 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 { + 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, + } +} diff --git a/src/api/devicebody.ts b/src/api/devicebody.ts new file mode 100644 index 0000000..920fa38 --- /dev/null +++ b/src/api/devicebody.ts @@ -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(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 & { 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, + }; +} diff --git a/src/api/provision.ts b/src/api/provision.ts new file mode 100644 index 0000000..1408162 --- /dev/null +++ b/src/api/provision.ts @@ -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 { + 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 { + // 手填的设备信息优先,三项都填了才算有效 + 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 + } +} diff --git a/src/assets/bookshelf.css b/src/assets/bookshelf.css index 107e5ce..e60589a 100644 --- a/src/assets/bookshelf.css +++ b/src/assets/bookshelf.css @@ -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); + } +} diff --git a/src/assets/script.css b/src/assets/script.css index 98a1f50..2637025 100644 --- a/src/assets/script.css +++ b/src/assets/script.css @@ -46,4 +46,73 @@ #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 } \ No newline at end of file diff --git a/src/assets/settings.css b/src/assets/settings.css new file mode 100644 index 0000000..5ec3271 --- /dev/null +++ b/src/assets/settings.css @@ -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; + } +} diff --git a/src/config.ts b/src/config.ts index 6d2601f..5504892 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ export interface DeviceConfig { install_id: string; // iid device_id: string; device_type?: string; + device_brand?: string; key_info?: KeyInfo; } diff --git a/src/crypto/argus.ts b/src/crypto/argus.ts new file mode 100644 index 0000000..300e316 --- /dev/null +++ b/src/crypto/argus.ts @@ -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 { + return sm3(data).slice(0, 6); +} + +function decodeStub(xssStub: string): Uint8Array { + 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 { + 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 { + 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); +} diff --git a/src/crypto/content.ts b/src/crypto/content.ts new file mode 100644 index 0000000..7935cb4 --- /dev/null +++ b/src/crypto/content.ts @@ -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 { + 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 { + 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, + ); +} \ No newline at end of file diff --git a/src/crypto/index.ts b/src/crypto/index.ts new file mode 100644 index 0000000..605ee90 --- /dev/null +++ b/src/crypto/index.ts @@ -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 { + 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 | string; + +function toBytes(input: HashInput): Uint8Array { + 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 => { + const subtle = getSubtle(); + const digest = await subtle.digest("SHA-256", toBytes(input)); + return hex(digest); + }, + sha256bytes: async (input: HashInput): Promise => { + const subtle = getSubtle(); + return subtle.digest("SHA-256", toBytes(input)); + }, + sha512: async (input: HashInput): Promise => { + const subtle = getSubtle(); + const digest = await subtle.digest("SHA-512", toBytes(input)); + return hex(digest); + }, + sha512bytes: async (input: HashInput): Promise => { + const subtle = getSubtle(); + return subtle.digest("SHA-512", toBytes(input)); + }, + md5: async (input: HashInput): Promise => md5( + typeof input === "string" ? input : toBytes(input).buffer, + ), + md5bytes: async (input: HashInput): Promise => unhex(md5( + typeof input === "string" ? input : toBytes(input).buffer, + )), + sm3: async (input: HashInput): Promise => hex(sm3(toBytes(input)).buffer), + sm3bytes: async (input: HashInput): Promise => sm3(toBytes(input)).buffer, +}; diff --git a/src/crypto/ladon.ts b/src/crypto/ladon.ts new file mode 100644 index 0000000..2e4712b --- /dev/null +++ b/src/crypto/ladon.ts @@ -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 { + 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); +} diff --git a/src/crypto/md5.ts b/src/crypto/md5.ts new file mode 100644 index 0000000..db71127 --- /dev/null +++ b/src/crypto/md5.ts @@ -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(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(); +} diff --git a/src/crypto/protobuf.ts b/src/crypto/protobuf.ts new file mode 100644 index 0000000..d59176a --- /dev/null +++ b/src/crypto/protobuf.ts @@ -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): 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 { + return Uint8Array.from(this.buf); + } +} diff --git a/src/crypto/registerkey.ts b/src/crypto/registerkey.ts new file mode 100644 index 0000000..ece2658 --- /dev/null +++ b/src/crypto/registerkey.ts @@ -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 { + 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 { + 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 + ); +} \ No newline at end of file diff --git a/src/crypto/sign.ts b/src/crypto/sign.ts new file mode 100644 index 0000000..88aeff1 --- /dev/null +++ b/src/crypto/sign.ts @@ -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 { + "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 { + 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 | string, + config: UnidbgConfig = defaultUnidbgConfig, +): Promise> { + 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 = 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"; diff --git a/src/crypto/simon.ts b/src/crypto/simon.ts new file mode 100644 index 0000000..e38fbc4 --- /dev/null +++ b/src/crypto/simon.ts @@ -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]; +} diff --git a/src/crypto/sm3.ts b/src/crypto/sm3.ts new file mode 100644 index 0000000..b713f63 --- /dev/null +++ b/src/crypto/sm3.ts @@ -0,0 +1,158 @@ +// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js + +type Bytes = ArrayLike; + +/** + * 循环左移 + */ +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 { + 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 { + 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); +} diff --git a/src/crypto/speck.ts b/src/crypto/speck.ts new file mode 100644 index 0000000..d0050a6 --- /dev/null +++ b/src/crypto/speck.ts @@ -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 { + 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> { + 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); +} diff --git a/src/crypto/ttencrypt.ts b/src/crypto/ttencrypt.ts new file mode 100644 index 0000000..2441bb7 --- /dev/null +++ b/src/crypto/ttencrypt.ts @@ -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 { + 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 + ) +} + diff --git a/src/crypto/unidbgconfig.ts b/src/crypto/unidbgconfig.ts new file mode 100644 index 0000000..81d7acd --- /dev/null +++ b/src/crypto/unidbgconfig.ts @@ -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, +}; diff --git a/src/fontDecrypt.ts b/src/fontDecrypt.ts index 99d302e..12a9909 100644 --- a/src/fontDecrypt.ts +++ b/src/fontDecrypt.ts @@ -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); + } + ); } \ No newline at end of file diff --git a/src/hooks/bookshelfHook.ts b/src/hooks/bookshelfHook.ts index a6f06f4..48ea794 100644 --- a/src/hooks/bookshelfHook.ts +++ b/src/hooks/bookshelfHook.ts @@ -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 { } function filter(path: string, _query: URLSearchParams, _hash: string) { - return isBookshelfPath(path) || !!app + return (isBookshelfPath(path) || !!app) && userState.isLogin } const _exports: HookConfig[] = [ diff --git a/src/hooks/fetchHook.ts b/src/hooks/fetchHook.ts index ea364db..8ba21a2 100644 --- a/src/hooks/fetchHook.ts +++ b/src/hooks/fetchHook.ts @@ -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)) } diff --git a/src/hooks/readerHook.ts b/src/hooks/readerHook.ts index 12d5fd8..b4c4680 100644 --- a/src/hooks/readerHook.ts +++ b/src/hooks/readerHook.ts @@ -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 里的 是 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' diff --git a/src/hooks/userHook.ts b/src/hooks/userHook.ts index ed73dba..20ded3f 100644 --- a/src/hooks/userHook.ts +++ b/src/hooks/userHook.ts @@ -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 { 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 { scan(document.body) } +/** + * 未登录时没有用户菜单,改为复制「注册」按钮做一个「助手设置」入口。 + */ +async function guestHook(_previous?: string): Promise { + const injected = new WeakSet() + + 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('.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 } ] diff --git a/src/main.ts b/src/main.ts index b520bb0..c3f435c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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() diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..f7b12d0 --- /dev/null +++ b/src/settings.ts @@ -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 + for (const key of Object.keys(DEFAULT_SETTINGS) as Array) { + 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(normalize(read(STORE_KEY))) + +let saveTimer: ReturnType | 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() +} diff --git a/src/settingsPanel.ts b/src/settingsPanel.ts new file mode 100644 index 0000000..8419a4f --- /dev/null +++ b/src/settingsPanel.ts @@ -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() +} diff --git a/src/types.ts b/src/types.ts index d737934..0f7aa6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,4 +110,13 @@ export type BookShelfTabKey = 'all' | 'group' | 'publish' /** 全部 tab 里分组卡片与散书混排 */ export type BookShelfCell = | { kind: 'book'; key: string; entry: BookShelfEntry } - | { kind: 'group'; key: string; group: BookShelfGroup } \ No newline at end of file + | { kind: 'group'; key: string; group: BookShelfGroup } +/** 右键菜单项。有 children 时展开二级面板 */ +export interface MenuItem { + key: string + label: string + /** 危险操作,标红 */ + danger?: boolean + disabled?: boolean + children?: MenuItem[] +} diff --git a/src/userStyle.ts b/src/userStyle.ts new file mode 100644 index 0000000..2ffc7a4 --- /dev/null +++ b/src/userStyle.ts @@ -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 + ) +} diff --git a/src/utils/compress.ts b/src/utils/compress.ts new file mode 100644 index 0000000..a579e14 --- /dev/null +++ b/src/utils/compress.ts @@ -0,0 +1,16 @@ +export async function gzip(data: ArrayBuffer | string): Promise { + 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 { + const decoder = new DecompressionStream('gzip'); + const stream = new Blob([data]).stream().pipeThrough(decoder); + const decompressed = new Response(stream).arrayBuffer(); + return decompressed; +} \ No newline at end of file diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts deleted file mode 100644 index 14b549b..0000000 --- a/src/utils/crypto.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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, - ); -} \ No newline at end of file diff --git a/src/utils/footnote.ts b/src/utils/footnote.ts new file mode 100644 index 0000000..981f609 --- /dev/null +++ b/src/utils/footnote.ts @@ -0,0 +1,235 @@ +// 章内注释(EPUB footnote)处理。 +// +// 番茄的正文 XHTML 里,注释引用长这样: +// +// 注释内容集中放在正文末尾: +//
+// +// 直接渲染有几个问题: +// 1. src="$bdFootnote" 是占位符不是真实图片,会变成裂图;页面自带的 +// `.bdFootnote{display:none}` 又会把它整个藏掉,导致注释标记看不见; +// 2.