chore: 暂存更改

This commit is contained in:
naiy committed 2026-09-19 23:51:05 +08:00
1 parent 20e7a6fc79
commit b62ec5dbe6
12 files changed
+1429 -519

No files matched your search

+853 -481
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -148,3 +148,9 @@ export async function moveToGroup(bookId: string, groupName: string): Promise<vo
], ],
}) })
} }
export async function isInBookshelf(book_id: string): Promise<boolean> {
const response = await fetch(`https://fanqienovel.com/reading/bookapi/bookshelf/check/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700&book_id=${book_id}`)
const data = await response.json()
return Boolean(data.data)
}
+8 -4
View File
@@ -14,7 +14,7 @@ export async function getCatalogRaw(bookId: string): Promise<Array<any>> {
const j: any = response.json() const j: any = response.json()
const items = j?.data?.item_data_list const items = j?.data?.item_data_list
if (j?.code !== 0 || !Array.isArray(items) || items.length === 0) { if (j?.code !== 0 || !Array.isArray(items) || items.length === 0) {
throw new Error('Empty catalog') return [null, null]
} }
return [items, items.map((it: any) => String(it.item_id))] return [items, items.map((it: any) => String(it.item_id))]
} }
@@ -39,25 +39,29 @@ export async function webCatalog(bookId: string): Promise<Array<any>> {
"firstPassTime": "1749281928" "firstPassTime": "1749281928"
}, },
*/ */
const volmap: Record<string, Array<any>> = {} const cs: Array<any> = []
const vname: string[] = d.volumeNameList const vname: string[] = d.volumeNameList
for (let i = 0; i < vname.length; i++) { for (let i = 0; i < vname.length; i++) {
const volumeName = vname[i] const volumeName = vname[i]
if (volumeName !== undefined) { if (volumeName !== undefined) {
volmap[volumeName] = d.chapterListWithVolume[i] // volmap[volumeName] = d.chapterListWithVolume[i]
cs.push(...d.chapterListWithVolume[i])
} }
} }
return [volmap, allItems] return [cs, allItems]
} }
export async function getCatalog(bookId: string): Promise<CatalogResult> { export async function getCatalog(bookId: string): Promise<CatalogResult> {
const r = await getCatalogRaw(bookId) const r = await getCatalogRaw(bookId)
let catalogRaw = r[0] as any[] // item_data_list let catalogRaw = r[0] as any[] // item_data_list
let allItemIds = r[1] as string[] // all item ids let allItemIds = r[1] as string[] // all item ids
console.log('catalogRaw', catalogRaw, 'allItemIds', allItemIds)
if (!catalogRaw || !allItemIds) { if (!catalogRaw || !allItemIds) {
const rw = await webCatalog(bookId) const rw = await webCatalog(bookId)
catalogRaw = rw[0] catalogRaw = rw[0]
allItemIds = rw[1] allItemIds = rw[1]
console.log('webCatalog', catalogRaw, 'allItemIds', allItemIds)
// console.log('catalogRaw', catalogRaw, 'allItemIds', allItemIds)
} }
const vmap: Record<string, VolumeItem> = {} // title -> VolumeItem const vmap: Record<string, VolumeItem> = {} // title -> VolumeItem
const chapters: Array<ChapterItem> = [] const chapters: Array<ChapterItem> = []
+17
View File
@@ -13,6 +13,20 @@ const REGISTER_URL = 'https://i.snssdk.com/service/2/device_register/?tt_data=a'
const READING_BASE = 'https://reading.snssdk.com' const READING_BASE = 'https://reading.snssdk.com'
const USER_AGENT = 'com.dragon.read' const USER_AGENT = 'com.dragon.read'
/**
* 设备协议请求一律不带 Cookie。
*
* GM_xmlhttpRequest 默认会带上扩展上下文里属于这些域名的 Cookie
* (store-region、install_id、ttreq 之类),那是浏览器的痕迹,APP 的 okhttp 不带。
* 而且「注册一台新设备」还带着已有的 install_id cookie 本身自相矛盾。
* anonymous 让 GM 不发 Cookie —— 这一点在页面里用 fetch/XHR 反而做不到。
*
* 去不掉的另一些:Sec-Fetch-*、Priority、Accept-Encoding、Accept-Language
* 是 Chromium 按请求上下文自己加的禁止头,JS(含扩展)都改不了,
* 要完全消除只能让请求离开浏览器(原生客户端或服务端代理)。
*/
const APP_REQUEST = { credentials: 'omit' } as const
/** 设备注册结果 */ /** 设备注册结果 */
export interface RegisteredDevice { export interface RegisteredDevice {
device_id: string; device_id: string;
@@ -56,6 +70,7 @@ export async function registerDevice(): Promise<RegisteredDevice> {
) )
const res = await apiFetch(REGISTER_URL, { const res = await apiFetch(REGISTER_URL, {
...APP_REQUEST,
method: 'POST', method: 'POST',
headers: { headers: {
'User-Agent': 'okhttp/4.10.0', 'User-Agent': 'okhttp/4.10.0',
@@ -107,6 +122,7 @@ export async function activatePremium(device: RegisteredDevice): Promise<string>
try { try {
const headers = await signRequest(url, body) const headers = await signRequest(url, body)
const res = await apiFetch(url, { const res = await apiFetch(url, {
...APP_REQUEST,
method: 'POST', method: 'POST',
headers: { headers: {
...headers, ...headers,
@@ -166,6 +182,7 @@ export async function registerKey(device: RegisteredDevice): Promise<KeyInfo> {
// 签名针对 gzip 之后的字节,与 Go 里 SignedRequest 收到压缩流的行为一致 // 签名针对 gzip 之后的字节,与 Go 里 SignedRequest 收到压缩流的行为一致
const headers = await signRequest(url, gzipped) const headers = await signRequest(url, gzipped)
const res = await apiFetch(url, { const res = await apiFetch(url, {
...APP_REQUEST,
method: 'POST', method: 'POST',
headers: { headers: {
...headers, ...headers,
+1 -1
View File
@@ -77,7 +77,7 @@ export async function checkLogin() {
desc: j?.data?.desc, desc: j?.data?.desc,
age: j?.data?.age, age: j?.data?.age,
} }
if (j?.data?.id > 1) { if (j.code !== -1) {
userState.isLogin = true userState.isLogin = true
userState.userInfo = _userInfo userState.userInfo = _userInfo
write('userState', userState) write('userState', userState)
+6 -3
View File
@@ -17,9 +17,12 @@
color: var(--fqa-audio-text); color: var(--fqa-audio-text);
} }
/* 页面切到夜间模式时整体反色 */ /*
.muye-reader-dark .fqa-audio-root, * 暗色反色。悬浮栏挂在 body 下,够不到 .muye-reader-dark 的后代选择器,
.fqa-audio-root.fqa-audio-dark { * 所以 audioPanel 在 #fqa-audio-root 容器上打 .fqa-audio-dark 类;
* 真正的 .fqa-audio-root(Vue 根)是容器的子节点,用后代选择器作用到它。
*/
.fqa-audio-dark .fqa-audio-root {
--fqa-audio-bg: #2b2b2b; --fqa-audio-bg: #2b2b2b;
--fqa-audio-text: #b3b3b3; --fqa-audio-text: #b3b3b3;
--fqa-audio-sub: #7a7a7a; --fqa-audio-sub: #7a7a7a;
+2 -2
View File
@@ -2,8 +2,8 @@
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" <path fill-rule="evenodd" clip-rule="evenodd"
d="M12 8.25C9.92894 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92894 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25ZM9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12Z" d="M12 8.25C9.92894 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92894 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25ZM9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12Z"
fill="#000000" /> fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" <path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9747 1.25C11.5303 1.24999 11.1592 1.24999 10.8546 1.27077C10.5375 1.29241 10.238 1.33905 9.94761 1.45933C9.27379 1.73844 8.73843 2.27379 8.45932 2.94762C8.31402 3.29842 8.27467 3.66812 8.25964 4.06996C8.24756 4.39299 8.08454 4.66251 7.84395 4.80141C7.60337 4.94031 7.28845 4.94673 7.00266 4.79568C6.64714 4.60777 6.30729 4.45699 5.93083 4.40743C5.20773 4.31223 4.47642 4.50819 3.89779 4.95219C3.64843 5.14353 3.45827 5.3796 3.28099 5.6434C3.11068 5.89681 2.92517 6.21815 2.70294 6.60307L2.67769 6.64681C2.45545 7.03172 2.26993 7.35304 2.13562 7.62723C1.99581 7.91267 1.88644 8.19539 1.84541 8.50701C1.75021 9.23012 1.94617 9.96142 2.39016 10.5401C2.62128 10.8412 2.92173 11.0602 3.26217 11.2741C3.53595 11.4461 3.68788 11.7221 3.68786 12C3.68785 12.2778 3.53592 12.5538 3.26217 12.7258C2.92169 12.9397 2.62121 13.1587 2.39007 13.4599C1.94607 14.0385 1.75012 14.7698 1.84531 15.4929C1.88634 15.8045 1.99571 16.0873 2.13552 16.3727C2.26983 16.6469 2.45535 16.9682 2.67758 17.3531L2.70284 17.3969C2.92507 17.7818 3.11058 18.1031 3.28089 18.3565C3.45817 18.6203 3.64833 18.8564 3.89769 19.0477C4.47632 19.4917 5.20763 19.6877 5.93073 19.5925C6.30717 19.5429 6.647 19.3922 7.0025 19.2043C7.28833 19.0532 7.60329 19.0596 7.8439 19.1986C8.08452 19.3375 8.24756 19.607 8.25964 19.9301C8.27467 20.3319 8.31403 20.7016 8.45932 21.0524C8.73843 21.7262 9.27379 22.2616 9.94761 22.5407C10.238 22.661 10.5375 22.7076 10.8546 22.7292C11.1592 22.75 11.5303 22.75 11.9747 22.75H12.0252C12.4697 22.75 12.8407 22.75 13.1454 22.7292C13.4625 22.7076 13.762 22.661 14.0524 22.5407C14.7262 22.2616 15.2616 21.7262 15.5407 21.0524C15.686 20.7016 15.7253 20.3319 15.7403 19.93C15.7524 19.607 15.9154 19.3375 16.156 19.1985C16.3966 19.0596 16.7116 19.0532 16.9974 19.2042C17.3529 19.3921 17.6927 19.5429 18.0692 19.5924C18.7923 19.6876 19.5236 19.4917 20.1022 19.0477C20.3516 18.8563 20.5417 18.6203 20.719 18.3565C20.8893 18.1031 21.0748 17.7818 21.297 17.3969L21.3223 17.3531C21.5445 16.9682 21.7301 16.6468 21.8644 16.3726C22.0042 16.0872 22.1135 15.8045 22.1546 15.4929C22.2498 14.7697 22.0538 14.0384 21.6098 13.4598C21.3787 13.1586 21.0782 12.9397 20.7378 12.7258C20.464 12.5538 20.3121 12.2778 20.3121 11.9999C20.3121 11.7221 20.464 11.4462 20.7377 11.2742C21.0783 11.0603 21.3788 10.8414 21.6099 10.5401C22.0539 9.96149 22.2499 9.23019 22.1547 8.50708C22.1136 8.19546 22.0043 7.91274 21.8645 7.6273C21.7302 7.35313 21.5447 7.03183 21.3224 6.64695L21.2972 6.60318C21.0749 6.21825 20.8894 5.89688 20.7191 5.64347C20.5418 5.37967 20.3517 5.1436 20.1023 4.95225C19.5237 4.50826 18.7924 4.3123 18.0692 4.4075C17.6928 4.45706 17.353 4.60782 16.9975 4.79572C16.7117 4.94679 16.3967 4.94036 16.1561 4.80144C15.9155 4.66253 15.7524 4.39297 15.7403 4.06991C15.7253 3.66808 15.686 3.2984 15.5407 2.94762C15.2616 2.27379 14.7262 1.73844 14.0524 1.45933C13.762 1.33905 13.4625 1.29241 13.1454 1.27077C12.8407 1.24999 12.4697 1.24999 12.0252 1.25H11.9747ZM10.5216 2.84515C10.5988 2.81319 10.716 2.78372 10.9567 2.76729C11.2042 2.75041 11.5238 2.75 12 2.75C12.4762 2.75 12.7958 2.75041 13.0432 2.76729C13.284 2.78372 13.4012 2.81319 13.4783 2.84515C13.7846 2.97202 14.028 3.21536 14.1548 3.52165C14.1949 3.61826 14.228 3.76887 14.2414 4.12597C14.271 4.91835 14.68 5.68129 15.4061 6.10048C16.1321 6.51968 16.9974 6.4924 17.6984 6.12188C18.0143 5.9549 18.1614 5.90832 18.265 5.89467C18.5937 5.8514 18.9261 5.94047 19.1891 6.14228C19.2554 6.19312 19.3395 6.27989 19.4741 6.48016C19.6125 6.68603 19.7726 6.9626 20.0107 7.375C20.2488 7.78741 20.4083 8.06438 20.5174 8.28713C20.6235 8.50382 20.6566 8.62007 20.6675 8.70287C20.7108 9.03155 20.6217 9.36397 20.4199 9.62698C20.3562 9.70995 20.2424 9.81399 19.9397 10.0041C19.2684 10.426 18.8122 11.1616 18.8121 11.9999C18.8121 12.8383 19.2683 13.574 19.9397 13.9959C20.2423 14.186 20.3561 14.29 20.4198 14.373C20.6216 14.636 20.7107 14.9684 20.6674 15.2971C20.6565 15.3799 20.6234 15.4961 20.5173 15.7128C20.4082 15.9355 20.2487 16.2125 20.0106 16.6249C19.7725 17.0373 19.6124 17.3139 19.474 17.5198C19.3394 17.72 19.2553 17.8068 19.189 17.8576C18.926 18.0595 18.5936 18.1485 18.2649 18.1053C18.1613 18.0916 18.0142 18.045 17.6983 17.8781C16.9973 17.5075 16.132 17.4803 15.4059 17.8995C14.68 18.3187 14.271 19.0816 14.2414 19.874C14.228 20.2311 14.1949 20.3817 14.1548 20.4784C14.028 20.7846 13.7846 21.028 13.4783 21.1549C13.4012 21.1868 13.284 21.2163 13.0432 21.2327C12.7958 21.2496 12.4762 21.25 12 21.25C11.5238 21.25 11.2042 21.2496 10.9567 21.2327C10.716 21.2163 10.5988 21.1868 10.5216 21.1549C10.2154 21.028 9.97201 20.7846 9.84514 20.4784C9.80512 20.3817 9.77195 20.2311 9.75859 19.874C9.72896 19.0817 9.31997 18.3187 8.5939 17.8995C7.86784 17.4803 7.00262 17.5076 6.30158 17.8781C5.98565 18.0451 5.83863 18.0917 5.73495 18.1053C5.40626 18.1486 5.07385 18.0595 4.81084 17.8577C4.74458 17.8069 4.66045 17.7201 4.52586 17.5198C4.38751 17.314 4.22736 17.0374 3.98926 16.625C3.75115 16.2126 3.59171 15.9356 3.4826 15.7129C3.37646 15.4962 3.34338 15.3799 3.33248 15.2971CLine truncated d="M11.9747 1.25C11.5303 1.24999 11.1592 1.24999 10.8546 1.27077C10.5375 1.29241 10.238 1.33905 9.94761 1.45933C9.27379 1.73844 8.73843 2.27379 8.45932 2.94762C8.31402 3.29842 8.27467 3.66812 8.25964 4.06996C8.24756 4.39299 8.08454 4.66251 7.84395 4.80141C7.60337 4.94031 7.28845 4.94673 7.00266 4.79568C6.64714 4.60777 6.30729 4.45699 5.93083 4.40743C5.20773 4.31223 4.47642 4.50819 3.89779 4.95219C3.64843 5.14353 3.45827 5.3796 3.28099 5.6434C3.11068 5.89681 2.92517 6.21815 2.70294 6.60307L2.67769 6.64681C2.45545 7.03172 2.26993 7.35304 2.13562 7.62723C1.99581 7.91267 1.88644 8.19539 1.84541 8.50701C1.75021 9.23012 1.94617 9.96142 2.39016 10.5401C2.62128 10.8412 2.92173 11.0602 3.26217 11.2741C3.53595 11.4461 3.68788 11.7221 3.68786 12C3.68785 12.2778 3.53592 12.5538 3.26217 12.7258C2.92169 12.9397 2.62121 13.1587 2.39007 13.4599C1.94607 14.0385 1.75012 14.7698 1.84531 15.4929C1.88634 15.8045 1.99571 16.0873 2.13552 16.3727C2.26983 16.6469 2.45535 16.9682 2.67758 17.3531L2.70284 17.3969C2.92507 17.7818 3.11058 18.1031 3.28089 18.3565C3.45817 18.6203 3.64833 18.8564 3.89769 19.0477C4.47632 19.4917 5.20763 19.6877 5.93073 19.5925C6.30717 19.5429 6.647 19.3922 7.0025 19.2043C7.28833 19.0532 7.60329 19.0596 7.8439 19.1986C8.08452 19.3375 8.24756 19.607 8.25964 19.9301C8.27467 20.3319 8.31403 20.7016 8.45932 21.0524C8.73843 21.7262 9.27379 22.2616 9.94761 22.5407C10.238 22.661 10.5375 22.7076 10.8546 22.7292C11.1592 22.75 11.5303 22.75 11.9747 22.75H12.0252C12.4697 22.75 12.8407 22.75 13.1454 22.7292C13.4625 22.7076 13.762 22.661 14.0524 22.5407C14.7262 22.2616 15.2616 21.7262 15.5407 21.0524C15.686 20.7016 15.7253 20.3319 15.7403 19.93C15.7524 19.607 15.9154 19.3375 16.156 19.1985C16.3966 19.0596 16.7116 19.0532 16.9974 19.2042C17.3529 19.3921 17.6927 19.5429 18.0692 19.5924C18.7923 19.6876 19.5236 19.4917 20.1022 19.0477C20.3516 18.8563 20.5417 18.6203 20.719 18.3565C20.8893 18.1031 21.0748 17.7818 21.297 17.3969L21.3223 17.3531C21.5445 16.9682 21.7301 16.6468 21.8644 16.3726C22.0042 16.0872 22.1135 15.8045 22.1546 15.4929C22.2498 14.7697 22.0538 14.0384 21.6098 13.4598C21.3787 13.1586 21.0782 12.9397 20.7378 12.7258C20.464 12.5538 20.3121 12.2778 20.3121 11.9999C20.3121 11.7221 20.464 11.4462 20.7377 11.2742C21.0783 11.0603 21.3788 10.8414 21.6099 10.5401C22.0539 9.96149 22.2499 9.23019 22.1547 8.50708C22.1136 8.19546 22.0043 7.91274 21.8645 7.6273C21.7302 7.35313 21.5447 7.03183 21.3224 6.64695L21.2972 6.60318C21.0749 6.21825 20.8894 5.89688 20.7191 5.64347C20.5418 5.37967 20.3517 5.1436 20.1023 4.95225C19.5237 4.50826 18.7924 4.3123 18.0692 4.4075C17.6928 4.45706 17.353 4.60782 16.9975 4.79572C16.7117 4.94679 16.3967 4.94036 16.1561 4.80144C15.9155 4.66253 15.7524 4.39297 15.7403 4.06991C15.7253 3.66808 15.686 3.2984 15.5407 2.94762C15.2616 2.27379 14.7262 1.73844 14.0524 1.45933C13.762 1.33905 13.4625 1.29241 13.1454 1.27077C12.8407 1.24999 12.4697 1.24999 12.0252 1.25H11.9747ZM10.5216 2.84515C10.5988 2.81319 10.716 2.78372 10.9567 2.76729C11.2042 2.75041 11.5238 2.75 12 2.75C12.4762 2.75 12.7958 2.75041 13.0432 2.76729C13.284 2.78372 13.4012 2.81319 13.4783 2.84515C13.7846 2.97202 14.028 3.21536 14.1548 3.52165C14.1949 3.61826 14.228 3.76887 14.2414 4.12597C14.271 4.91835 14.68 5.68129 15.4061 6.10048C16.1321 6.51968 16.9974 6.4924 17.6984 6.12188C18.0143 5.9549 18.1614 5.90832 18.265 5.89467C18.5937 5.8514 18.9261 5.94047 19.1891 6.14228C19.2554 6.19312 19.3395 6.27989 19.4741 6.48016C19.6125 6.68603 19.7726 6.9626 20.0107 7.375C20.2488 7.78741 20.4083 8.06438 20.5174 8.28713C20.6235 8.50382 20.6566 8.62007 20.6675 8.70287C20.7108 9.03155 20.6217 9.36397 20.4199 9.62698C20.3562 9.70995 20.2424 9.81399 19.9397 10.0041C19.2684 10.426 18.8122 11.1616 18.8121 11.9999C18.8121 12.8383 19.2683 13.574 19.9397 13.9959C20.2423 14.186 20.3561 14.29 20.4198 14.373C20.6216 14.636 20.7107 14.9684 20.6674 15.2971C20.6565 15.3799 20.6234 15.4961 20.5173 15.7128C20.4082 15.9355 20.2487 16.2125 20.0106 16.6249C19.7725 17.0373 19.6124 17.3139 19.474 17.5198C19.3394 17.72 19.2553 17.8068 19.189 17.8576C18.926 18.0595 18.5936 18.1485 18.2649 18.1053C18.1613 18.0916 18.0142 18.045 17.6983 17.8781C16.9973 17.5075 16.132 17.4803 15.4059 17.8995C14.68 18.3187 14.271 19.0816 14.2414 19.874C14.228 20.2311 14.1949 20.3817 14.1548 20.4784C14.028 20.7846 13.7846 21.028 13.4783 21.1549C13.4012 21.1868 13.284 21.2163 13.0432 21.2327C12.7958 21.2496 12.4762 21.25 12 21.25C11.5238 21.25 11.2042 21.2496 10.9567 21.2327C10.716 21.2163 10.5988 21.1868 10.5216 21.1549C10.2154 21.028 9.97201 20.7846 9.84514 20.4784C9.80512 20.3817 9.77195 20.2311 9.75859 19.874C9.72896 19.0817 9.31997 18.3187 8.5939 17.8995C7.86784 17.4803 7.00262 17.5076 6.30158 17.8781C5.98565 18.0451 5.83863 18.0917 5.73495 18.1053C5.40626 18.1486 5.07385 18.0595 4.81084 17.8577C4.74458 17.8069 4.66045 17.7201 4.52586 17.5198C4.38751 17.314 4.22736 17.0374 3.98926 16.625C3.75115 16.2126 3.59171 15.9356 3.4826 15.7129C3.37646 15.4962 3.34338 15.3799 3.33248 15.2971CLine truncated
fill="#000000" /> fill="currentColor" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

+356 -25
View File
@@ -1,6 +1,51 @@
import type { HookConfig } from "../config" import type { HookConfig } from "../config"
import { settings } from "../settings" import { settings } from "../settings"
// ---------------------------------------------------------------------------
// 功能性的请求拦截 / 响应改写层。
//
// 与下面的 blockReport 黑名单不同:这里是「有目的、主动」的拦截,
// 由模块自己往 modifiers 里注册规则,不随设置开关而变化。
// 每一条规则可以:
// - should_break:不把请求发给服务器,直接用 make_response 造一个本地响应;
// - modify_response:照常发请求,等真实响应回来后按需改写(body/status/headers)。
// 两者按 modifiers 声明顺序叠加,前一条的输出是后一条的输入。
// ---------------------------------------------------------------------------
export interface SimpleResponse {
/**
* 响应体。给字符串就按 UTF-8 编码,省得每条规则都自己 TextEncoder 一遍。
* 读进来的(modify_response 的入参)一律是 ArrayBuffer,
* 要按文本处理用下面的 bodyToText。
*/
responseBody: ArrayBuffer | string
statusCode: number
responseHeaders: Record<string, string>
}
/** 内部流转用:responseBody 已经归一成 ArrayBuffer */
interface ResolvedResponse extends SimpleResponse {
responseBody: ArrayBuffer
}
export interface ResponseModifier {
matcher: (url: string) => boolean // 匹配规则
should_break: (url: string) => boolean // 是否中断请求(不发给服务器)
make_response?: (url: string) => SimpleResponse // 中断请求后该返回什么响应(should_break为true时)
modify_response?: (url: string, response: SimpleResponse) => SimpleResponse // 服务器响应后应该怎么改(should_break为false时)
}
/** 注册一条响应修改规则。返回一个取消函数,方便需要临时挂载的模块 */
export function addResponseModifier(modifier: ResponseModifier): () => void {
modifiers.push(modifier)
return () => {
const i = modifiers.indexOf(modifier)
if (i >= 0) modifiers.splice(i, 1)
}
}
export const modifiers: ResponseModifier[] = []
// disable report // disable report
const blackList = [ const blackList = [
'mcs.zijieapi.com', 'mcs.zijieapi.com',
@@ -22,9 +67,79 @@ function checkBlack(url: string): boolean {
return blackList.some(black => url.includes(black)) return blackList.some(black => url.includes(black))
} }
// ----------------------------- 工具 -------------------------------------
/** 拷贝到一个本地 realm 的精确尺寸 ArrayBuffer,避免跨 realm 共享底块 */
function toLocalArrayBuffer(buf: ArrayBuffer | ArrayBufferView): ArrayBuffer {
const bytes =
buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
const out = new ArrayBuffer(bytes.byteLength)
new Uint8Array(out).set(bytes)
return out
}
function textToBody(text: string): ArrayBuffer {
return toLocalArrayBuffer(new TextEncoder().encode(text))
}
/** 响应体转文本。给 modifier 作者用,免得自己判断联合类型 */
export function bodyToText(body: ArrayBuffer | string): string {
if (typeof body === 'string') return body
return new TextDecoder().decode(body)
}
/** 响应体转 ArrayBuffer。字符串按 UTF-8 编码 */
export function bodyToBuffer(body: ArrayBuffer | string): ArrayBuffer {
if (typeof body === 'string') return textToBody(body)
return body
}
/** 把规则产出的响应归一成内部形态,只在这里做一次编码 */
function resolveSimple(simple: SimpleResponse): ResolvedResponse {
return { ...simple, responseBody: bodyToBuffer(simple.responseBody) }
}
/** 命中当前 URL 的全部修饰器 */
function matchingModifiers(url: string): ResponseModifier[] {
return modifiers.filter(m => m.matcher(url))
}
/** 依次套用 modify_response(只对定义了它的修饰器) */
function applyModifiers(url: string, matched: ResponseModifier[], simple: SimpleResponse): SimpleResponse {
let current = simple
for (const m of matched) {
if (m.modify_response) current = m.modify_response(url, current)
}
return current
}
/** 状态码不允许带 body 的响应,构造 Response 时给 null 避免抛错 */
function responseAllowsBody(status: number): boolean {
return status !== 204 && status !== 205 && status !== 304
}
function simpleToResponse(simple: SimpleResponse): Response {
return new Response(responseAllowsBody(simple.statusCode) ? simple.responseBody : null, {
status: simple.statusCode,
headers: simple.responseHeaders,
})
}
/** 真实 Response -> SimpleResponse(不消费原 body,调用方自行决定是否要克隆) */
async function responseToSimple(res: Response): Promise<SimpleResponse> {
const body = toLocalArrayBuffer(await res.arrayBuffer())
const headers: Record<string, string> = {}
res.headers.forEach((value, key) => {
headers[key] = value
})
return { responseBody: body, statusCode: res.status, responseHeaders: headers }
}
// --------------------------------- fetch --------------------------------
const originalFetch = unsafeWindow.fetch.bind(unsafeWindow) const originalFetch = unsafeWindow.fetch.bind(unsafeWindow)
unsafeWindow.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { unsafeWindow.fetch = async function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
let url: string let url: string
if (input instanceof Request) { if (input instanceof Request) {
@@ -37,27 +152,109 @@ unsafeWindow.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit
if (checkBlack(url)) { if (checkBlack(url)) {
console.log('blocked request: ' + url) console.log('blocked request: ' + url)
return Promise.resolve(new Response(BLOCKED_BODY, { return new Response(BLOCKED_BODY, {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
})) })
} }
return originalFetch(input, init) const matched = matchingModifiers(url)
if (matched.length === 0) return originalFetch(input, init)
// 中断请求:不发给服务器,就地造一个响应
const breaker = matched.find(m => m.should_break(url))
if (breaker?.make_response) {
return simpleToResponse(breaker.make_response(url))
} }
// 没有命中任何改写规则时就原样透传,避免为无关请求多读一遍 body
if (!matched.some(m => m.modify_response)) return originalFetch(input, init)
const res = await originalFetch(input, init)
try {
// 克隆后再读,原响应保留给页面(或改写失败时回退用)
const simple = await responseToSimple(res.clone())
return simpleToResponse(applyModifiers(url, matched, simple))
} catch (error) {
console.error('[fqa:fetch] 修改响应失败,回退原始响应:', error)
return res
}
}
// ----------------------------- XMLHttpRequest -----------------------------
const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest
const XHR_PROTO = originalXMLHttpRequest.prototype
// 把原型上的访问器/方法先抄一份,实例上做影子属性时用它绕开自身递归
const XHR_DESC = {
status: Object.getOwnPropertyDescriptor(XHR_PROTO, 'status')!,
responseText: Object.getOwnPropertyDescriptor(XHR_PROTO, 'responseText')!,
response: Object.getOwnPropertyDescriptor(XHR_PROTO, 'response')!,
}
const XHR_GETALL = XHR_PROTO.getAllResponseHeaders as () => string
const XHR_GETONE = XHR_PROTO.getResponseHeader as (name: string) => string | null
/** 用原型方法读原始响应头,避开实例上的影子方法 */
function readXhrHeaders(self: XMLHttpRequest): Record<string, string> {
const headers: Record<string, string> = {}
const all = XHR_GETALL.call(self)
if (!all) return headers
for (const line of all.trim().split(/[\r\n]+/)) {
const idx = line.indexOf(':')
if (idx > 0) {
headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
}
}
return headers
}
/** 用原型方法读原始响应体并按 responseType 归一成 ArrayBuffer */
function readXhrBody(self: XMLHttpRequest): ArrayBuffer {
const type = self.responseType
if (type === 'arraybuffer') {
return toLocalArrayBuffer(XHR_DESC.response.get!.call(self) as ArrayBuffer)
}
if (type === 'json') {
// responseType=json 时 responseText 会抛错,只能拿解析后的对象再序列化
const parsed = XHR_DESC.response.get!.call(self)
return textToBody(JSON.stringify(parsed))
}
return textToBody(XHR_DESC.responseText.get!.call(self) as string)
}
unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest {
private _blockedUrl?: string private _blockedUrl?: string
private _fxaUrl = ''
private _fxaMatched: ResponseModifier[] | null = null
private _fxaBreaker: ResponseModifier | null = null
private _fxaApplied = false
private _fxaSimple: ResolvedResponse | null = null
open(method: string, url: string, async: boolean = true, user?: string | null, password?: string | null): void { open(method: string, url: string, async: boolean = true, user?: string | null, password?: string | null): void {
this._fxaUrl = url
this._fxaMatched = null
this._fxaBreaker = null
this._fxaApplied = false
this._fxaSimple = null
if (checkBlack(url)) { if (checkBlack(url)) {
console.log('blocked request: ' + url) console.log('blocked request: ' + url)
this._blockedUrl = url this._blockedUrl = url
return return
} }
this._blockedUrl = undefined this._blockedUrl = undefined
// 功能性的拦截/改写与 blockReport 无关,独立判定
const matched = matchingModifiers(url)
const breaker = matched.find(m => m.should_break(url))
if (breaker?.make_response) {
this._fxaBreaker = breaker
this._fxaMatched = matched
} else if (matched.some(m => m.modify_response)) {
this._fxaMatched = matched
}
super.open(method, url, async, user, password) super.open(method, url, async, user, password)
} }
@@ -67,29 +264,20 @@ unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpReques
} }
send(body?: Document | XMLHttpRequestBodyInit | null): void { send(body?: Document | XMLHttpRequestBodyInit | null): void {
if (this._blockedUrl === undefined) { if (this._blockedUrl !== undefined) {
super.send(body) this._synthesizeFromSimple({
responseBody: textToBody(BLOCKED_BODY),
statusCode: 200,
responseHeaders: { 'content-type': 'application/json' },
})
return return
} }
const url = this._blockedUrl if (this._fxaBreaker?.make_response) {
const shadow = (prop: string, value: unknown) => this._synthesizeFromSimple(this._fxaBreaker.make_response(this._fxaUrl))
Object.defineProperty(this, prop, { configurable: true, get: () => value }) return
}
setTimeout(() => { if (this._fxaMatched?.length) this._armTransform()
shadow('readyState', 4) super.send(body)
shadow('status', 200)
shadow('statusText', 'OK')
shadow('responseURL', url)
shadow('responseText', this.responseType === '' || this.responseType === 'text' ? BLOCKED_BODY : '')
shadow('response', this.responseType === 'json' ? {
"e": 0,
"sc": 10,
"tc": 10
} : BLOCKED_BODY)
this.dispatchEvent(new Event('readystatechange'))
this.dispatchEvent(new ProgressEvent('load'))
this.dispatchEvent(new ProgressEvent('loadend'))
}, 0)
} }
abort(): void { abort(): void {
@@ -108,6 +296,149 @@ unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpReques
} }
return super.getResponseHeader(name) return super.getResponseHeader(name)
} }
/**
* 中断请求后合成一个假响应并手动派发完成事件。
* 与 fetch 的 make_response 对齐,也复用了黑名单那条原本的 setTimeout 派发逻辑。
*/
private _synthesizeFromSimple(raw: SimpleResponse): void {
const url = this._blockedUrl ?? this._fxaUrl
// 提前归一,下面 arraybuffer / blob 分支要的是真正的字节
const simple = resolveSimple(raw)
const headers = simple.responseHeaders
const shadow = (prop: string, value: unknown) =>
Object.defineProperty(this, prop, { configurable: true, get: () => value })
setTimeout(() => {
const text = bodyToText(simple.responseBody)
shadow('readyState', 4)
shadow('status', simple.statusCode)
shadow('statusText', 'OK')
shadow('responseURL', url)
const type = this.responseType
shadow('responseText', type === '' || type === 'text' ? text : '')
let value: unknown
if (type === 'json') {
try {
value = JSON.parse(text)
} catch {
value = text
}
} else if (type === 'arraybuffer') {
value = simple.responseBody
} else if (type === 'blob') {
value = new Blob([simple.responseBody])
} else {
value = text
}
shadow('response', value)
shadow('getAllResponseHeaders', () =>
Object.entries(headers)
.map(([k, v]) => `${k}: ${v}\r\n`)
.join('')
)
shadow('getResponseHeader', (name: string) => {
const lower = name.toLowerCase()
const key = Object.keys(headers).find(k => k.toLowerCase() === lower)
return key ? headers[key]! : null
})
this.dispatchEvent(new Event('readystatechange'))
this.dispatchEvent(new ProgressEvent('load'))
this.dispatchEvent(new ProgressEvent('loadend'))
}, 0)
}
/**
* 在实例上挂影子属性:等真实响应就绪(readyState=4),
* 页面第一次读取时才同步套用 modify_response 并缓存结果。
* 这样不用跟页面的 onload / onreadystatechange 争先后顺序。
*/
private _armTransform(): void {
const type = this.responseType
// 这几类拿不到同步的 ArrayBuffer,或语义不明,无法改写,原样放行
if (type === 'blob' || type === 'document') return
const self = this
const matched = this._fxaMatched!
const url = this._fxaUrl
const ensure = () => {
if (self._fxaApplied || self.readyState !== 4) return
// 归一一次并缓存:重复读 response 时要拿到同一个 ArrayBuffer 对象,
// 每次现编会让 xhr.response === xhr.response 变成 false
self._fxaSimple = resolveSimple(applyModifiers(url, matched, {
responseBody: readXhrBody(self),
statusCode: XHR_DESC.status.get!.call(self) as number,
responseHeaders: readXhrHeaders(self),
}))
self._fxaApplied = true
}
// status
Object.defineProperty(this, 'status', {
configurable: true,
get() {
ensure()
if (self._fxaApplied) return self._fxaSimple!.statusCode
return XHR_DESC.status.get!.call(self) as number
},
})
// responseText
Object.defineProperty(this, 'responseText', {
configurable: true,
get() {
ensure()
const t = self.responseType
if (!self._fxaApplied || (t !== '' && t !== 'text')) {
return XHR_DESC.responseText.get!.call(self)
}
return bodyToText(self._fxaSimple!.responseBody)
},
})
// response
Object.defineProperty(this, 'response', {
configurable: true,
get() {
ensure()
if (!self._fxaApplied) return XHR_DESC.response.get!.call(self)
const body = self._fxaSimple!.responseBody
switch (self.responseType) {
case '':
case 'text':
return bodyToText(body)
case 'json':
return JSON.parse(bodyToText(body))
case 'arraybuffer':
return body
default:
return XHR_DESC.response.get!.call(self)
}
},
})
// getAllResponseHeaders
this.getAllResponseHeaders = function getAllResponseHeaders(): string {
ensure()
if (!self._fxaApplied) return XHR_GETALL.call(self)
return Object.entries(self._fxaSimple!.responseHeaders)
.map(([k, v]) => `${k}: ${v}\r\n`)
.join('')
}
// getResponseHeader
this.getResponseHeader = function getResponseHeader(name: string): string | null {
ensure()
if (!self._fxaApplied) return XHR_GETONE.call(self, name)
const lower = name.toLowerCase()
const key = Object.keys(self._fxaSimple!.responseHeaders).find(k => k.toLowerCase() === lower)
return key ? self._fxaSimple!.responseHeaders[key]! : null
}
}
} }
// 执行期hook,所以不用显式声明事件类hook // 执行期hook,所以不用显式声明事件类hook
+150 -3
View File
@@ -5,11 +5,14 @@ import { applyBookCss } from '../api/bookcss'
import { processFootnotes, bindFootnoteInteraction } from '../utils/footnote' import { processFootnotes, bindFootnoteInteraction } from '../utils/footnote'
import { settings } from '../settings' import { settings } from '../settings'
// import defaultcss from '../assets/default.css?raw'; // import defaultcss from '../assets/default.css?raw';
import { cloneElement } from '../utils'; import { cloneElement, sleep } from '../utils';
import { fetchArrayBuffer } from '../utils/request'; import { fetchArrayBuffer } from '../utils/request';
import { type Book } from '../types' import { type Book } from '../types'
import { decryptComicImage } from '../crypto/content'; import { decryptComicImage } from '../crypto/content';
import { initAudioPanel } from '../audioPanel' import { initAudioPanel } from '../audioPanel'
import { addToBookshelf, removeFromBookshelf, isInBookshelf } from '../api/bookshelf'
import { addResponseModifier } from './fetchHook'
import { userState } from '../api/user'
import { import {
closeAudiobook, closeAudiobook,
openAudiobook, openAudiobook,
@@ -23,12 +26,77 @@ let currentBook: Book | null = null
let latestItemId: string | null = null let latestItemId: string | null = null
let currentChapterWithContent: any = null
/** 脚本接管的正文容器 id。文字章与漫画章共用同一个,切章时复用 */ /** 脚本接管的正文容器 id。文字章与漫画章共用同一个,切章时复用 */
const SCRIPT_CONTAINER_ID = 'fqa-reader-content' const SCRIPT_CONTAINER_ID = 'fqa-reader-content'
/** 漫画懒加载观察器,切章时要换掉旧的 */ /** 漫画懒加载观察器,切章时要换掉旧的 */
let comicObserver: IntersectionObserver | null = null let comicObserver: IntersectionObserver | null = null
/*
content-length
39
content-type
application/json; charset=utf-8
date
Wed, 16 Sep 2026 08:58:41 GMT
server
volc-dcdn
server-timing
inner; dur=49, cdn-cache;desc=MISS, origin;dur=63, edge;dur=14, cdn-cache;desc=MISS, tt_agw; dur=45
strict-transport-security
max-age=63072000; includeSubDomains; preload
tt-idc-switch
10000@20260825024420
tt_stable
1
via
n172-005-142.sjzmp-agg.Creative,n173-115-143.nxzhongwei-mp01-cu.Creative
x-agw-info
tZz4wrQl57dt0wY2pTAgR2f48x1mv_TN71v9oOmqjxWS7rul6BjF9t8eZSMnCnTsdvrqH1P6-ELR41Ql_zMwMP_kZavRq2UkjiwjSI7ptI8EHk6qozYlJnJbaqiDWW2sO1RAbsmbFxPsUzXSsp3aUmh7JEMxEBn9_MgD_N49ZXPnqH1EJbjVod0JRshPQPmIzfsU_1PzITtNH20Wb96wrScoWjk=
x-dsa-origin-status
200
x-dsa-trace-id
17895491212d5366f9d0c2c959ffbc745c0148e43f
x-request-ip
2408:847a:718:cd2:20ed:81:8e36:a911
x-tt-agw-login
1
x-tt-logid
20260916165841775622D9BB198F0EF9C9
x-tt-trace-host
014917046478eae01e5ce9f4b5991634e2c2ac3d6c4ea331b6945fee3151af03e55859798c9e8c898e5508e08f78fb380fa48cd479a7ff5f96a2ef74435c7f420dcf4eb744b2aaca36db660c94c334414c30ce2bd99c166030189372edcc8a00b77f30aabb4f0447fece571b3a3c22a3de373bf420de4c924468d754783af3d783
x-tt-trace-id
00-260916165841775622D9BB198F0EF9C9-3157EADB1F76429E-00
x-tt-trace-tag
id=5
*/
// 确保脚本clone的书架按钮是干净不受账号状态影响的
// 没登录不会请求这个api,所以不影响
addResponseModifier({
matcher: (url: string) =>
url.indexOf('/reading/bookapi/bookshelf/check/') !== -1
&& location.pathname.startsWith('/reader'),
should_break: () => true,
make_response: () => {
const body = JSON.stringify({
code: 0,
message: 'SUCCESS',
data: 0
})
return {
responseBody: body,
statusCode: 200,
responseHeaders: {
'content-type': 'application/json; charset=utf-8',
'content-length': String(new TextEncoder().encode(body).length)
}
}
},
})
/** /**
* 取到脚本容器:没有就克隆一个插到原容器前面,有就复用并清空。 * 取到脚本容器:没有就克隆一个插到原容器前面,有就复用并清空。
* *
@@ -75,6 +143,7 @@ async function insertContent() {
return return
} }
console.log('Chapter:', chapter) console.log('Chapter:', chapter)
currentChapterWithContent = chapter
const pageState: any = (unsafeWindow as typeof unsafeWindow & { const pageState: any = (unsafeWindow as typeof unsafeWindow & {
__INITIAL_STATE__?: { reader?: { chapterData?: { title?: string } } } __INITIAL_STATE__?: { reader?: { chapterData?: { title?: string } } }
@@ -292,7 +361,9 @@ async function insertContent() {
} }
// 写入更精确的更新时间 // 写入更精确的更新时间
let updateTimeSpans = muyeReaderSubtitle?.querySelectorAll('span.desc-item') || [] let updateTimeSpans = muyeReaderSubtitle?.querySelectorAll('span.desc-item') || []
if (updateTimeSpans.length >= 2 /* 应该是始终为3 */) { console.log('spans len', updateTimeSpans.length, 'assertIsOffshelf', updateTimeSpans.length < 3)
if (updateTimeSpans.length >= 3 /* 应该是始终为3 */) {
console.log('if')
/* 最后一个一般是更新时间 */ /* 最后一个一般是更新时间 */
let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1] as HTMLSpanElement let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1] as HTMLSpanElement
let uttspan = updateTimeSpan.firstChild as HTMLSpanElement let uttspan = updateTimeSpan.firstChild as HTMLSpanElement
@@ -303,11 +374,31 @@ async function insertContent() {
'更新时间:' + '更新时间:' +
currentChapterItem.update_time currentChapterItem.update_time
} else { } else {
console.log('else')
// 网页端屏蔽章节。只有一个本章字数 + 前面插入的卷名 // 网页端屏蔽章节。只有一个本章字数 + 前面插入的卷名
let updateTimeSpan = document.createElement('span') // 有个默认显示的 0 字,给它删了
updateTimeSpans[updateTimeSpans.length - 1]?.remove()
const updateTimeSpan = document.createElement('span')
// let updateTimeSubSpan = document.createElement('span') // let updateTimeSubSpan = document.createElement('span')
updateTimeSpan.className = 'desc-item' updateTimeSpan.className = 'desc-item'
updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}` updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`
console.log('assert equal', currentChapterItem.item_id === currentChapterWithContent?.novel_data?.item_id)
console.log('wordcnt', currentChapterWithContent?.novel_data?.chapter_word_number)
const c = document.getElementById('fqa-current-chapter-volume')
let b: HTMLSpanElement | null = null;
if (currentChapterItem.item_id === currentChapterWithContent?.novel_data?.item_id) {
const wordCntSpan = document.createElement('span')
wordCntSpan.className = 'desc-item'
wordCntSpan.textContent = `本章字数:${currentChapterWithContent?.novel_data?.chapter_word_number}字`
console.log(wordCntSpan)
if (c) {
console.log('insert wordcnt')
c.insertAdjacentElement('afterend', wordCntSpan)
}
b = wordCntSpan
}
console.log('insert update time');
(b || c)?.insertAdjacentElement('afterend', updateTimeSpan)
} }
} }
} }
@@ -352,6 +443,14 @@ async function onHashChange(_previous?: string): Promise<void> {
// TODO: 支持从hash里解析并跳转到指定行 // TODO: 支持从hash里解析并跳转到指定行
} }
async function onLoad(): Promise<void> { async function onLoad(): Promise<void> {
async function fetchBookInfo(): Promise<void> {
const pageState: any = (unsafeWindow as typeof unsafeWindow & {
__INITIAL_STATE__?: { reader?: { chapterData?: { title?: string } } }
}).__INITIAL_STATE__
const bid = pageState?.reader?.chapterData?.bookId
currentBook = await getBookInfoAndCatalog(bid)
}
void fetchBookInfo() // 后台继续执行
/* /*
if (!document.getElementById('fqa-inject-css-reader')) { if (!document.getElementById('fqa-inject-css-reader')) {
const style = document.createElement('style') const style = document.createElement('style')
@@ -382,6 +481,54 @@ async function onLoad(): Promise<void> {
c.addEventListener('click', () => void startAudioPlay()) c.addEventListener('click', () => void startAudioPlay())
toolbar.appendChild(c) toolbar.appendChild(c)
} }
const bookshelfButton = document.querySelector("div.reader-toolbar-item") // 第一个按钮一定是书架状态
// 没登录的话就走网页原线路,会要求登录
if (userState.isLogin && bookshelfButton && bookshelfButton.innerHTML.includes('书架')) {
const shelf = cloneElement(bookshelfButton)
bookshelfButton.replaceWith(shelf)
let inShelf = false
let ready = false
let busy = false
async function ensureBookshelfState(): Promise<void> {
while (!currentBook) {
// 等 fetchBookInfo 飞回来
await sleep(50)
}
inShelf = await isInBookshelf(currentBook.book_id)
shelf.classList.toggle('reader-toolbar-item-disabled', inShelf)
const label = shelf.lastChild
if (label) label.textContent = inShelf ? '已在书架' : '加入书架'
ready = true
}
void ensureBookshelfState()
shelf.addEventListener('click', async () => {
const book = currentBook
// 状态还没同步好 / 书籍信息还没到,先不响应
if (!ready || busy || !book) return
busy = true
try {
if (inShelf) {
// 已加书架,按设置决定是否带确认移出书架
if (settings.shelfRemoveConfirm) {
if (!unsafeWindow.confirm(`确定要把《${book.title ?? '这本书'}》从书架移出吗?`)) return
}
await removeFromBookshelf(book.book_id)
} else {
// 未加书架,直接加入书架
await addToBookshelf(book.book_id)
}
await ensureBookshelfState()
} catch (error) {
console.error('[fqa:reader] 书架操作失败:', error)
unsafeWindow.alert(error instanceof Error ? error.message : '书架操作失败')
} finally {
busy = false
}
})
}
const btns = document.querySelector("div.muye-reader-btns") // single div const btns = document.querySelector("div.muye-reader-btns") // single div
if (btns) { if (btns) {
// TODO: 直接覆写按钮行为,切章节由脚本完成 // TODO: 直接覆写按钮行为,切章节由脚本完成
+14
View File
@@ -4,6 +4,7 @@ import bookshelf from '../assets/bookshelf.svg?raw'
import settings from '../assets/settings.svg?raw' import settings from '../assets/settings.svg?raw'
import { openSettings } from '../settingsPanel' import { openSettings } from '../settingsPanel'
import { cloneElement } from '../utils' import { cloneElement } from '../utils'
import { settings as userSettings } from '../settings'
function formatReadingTime(readBookTime: bigint): string { function formatReadingTime(readBookTime: bigint): string {
let minutes = readBookTime / 60_000n let minutes = readBookTime / 60_000n
@@ -77,6 +78,19 @@ async function mainHook(_previous?: string): Promise<void> {
secondDiv.insertAdjacentElement('afterend', thirdDiv) secondDiv.insertAdjacentElement('afterend', thirdDiv)
// 助手设置固定放在菜单最后 // 助手设置固定放在菜单最后
menuInner.appendChild(createMenuItem('助手设置', settings, openSettings)) menuInner.appendChild(createMenuItem('助手设置', settings, openSettings))
menuInner.childNodes.forEach((node) => {
if (node.lastChild?.textContent?.trim() === '退出登录') {
const original = node as HTMLElement
const replaced = cloneElement(node as Element)
original.classList.add('fqa-hide')
original.insertAdjacentElement('afterend', replaced)
replaced.addEventListener('click', (_) => {
if (!userSettings.logoutConfirm || unsafeWindow.confirm('确认退出登录吗?')) {
original.click()
}
})
}
})
} }
const scan = (root: HTMLElement) => { const scan = (root: HTMLElement) => {
+6
View File
@@ -26,6 +26,10 @@ export interface Settings {
blockReport: boolean blockReport: boolean
/** 允许阅读器复制文本 */ /** 允许阅读器复制文本 */
allowCopy: boolean allowCopy: boolean
/** 阅读页移出书架前弹确认框 */
shelfRemoveConfirm: boolean
/** 退出登录前弹确认框 */
logoutConfirm: boolean
/* --- 界面 --- */ /* --- 界面 --- */
/** 阅读器字体,空字符串表示跟随页面默认 */ /** 阅读器字体,空字符串表示跟随页面默认 */
@@ -84,6 +88,8 @@ export const DEFAULT_SETTINGS: Settings = {
decryptFont: true, decryptFont: true,
blockReport: true, blockReport: true,
allowCopy: true, allowCopy: true,
shelfRemoveConfirm: true,
logoutConfirm: true,
readerFont: '', readerFont: '',
customCssEnabled: false, customCssEnabled: false,
+10
View File
@@ -121,6 +121,16 @@ const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant'
<span class="fqa-set-label">允许阅读器复制文本</span> <span class="fqa-set-label">允许阅读器复制文本</span>
<input v-model="settings.allowCopy" type="checkbox" class="fqa-set-switch" /> <input v-model="settings.allowCopy" type="checkbox" class="fqa-set-switch" />
</label> </label>
<label class="fqa-set-row">
<span class="fqa-set-label">移出书架时二次确认</span>
<input v-model="settings.shelfRemoveConfirm" type="checkbox" class="fqa-set-switch" />
</label>
<label class="fqa-set-row">
<span class="fqa-set-label">退出登录时二次确认</span>
<input v-model="settings.logoutConfirm" type="checkbox" class="fqa-set-switch" />
</label>
</template> </template>
<!-- 界面 --> <!-- 界面 -->