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