feat: readme & update

This commit is contained in:
naiy committed 2026-07-30 04:07:41 +08:00
1 parent 5626fb6558
commit f94a7123b4
17 files changed
+6084 -74

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
import type { HookConfig } from "../config"
// disable report
const blackList = [
'mcs.zijieapi.com',
'vcs.zijieapi.com/vc/setting',
'mon.zijieapi.com',
'mssdk.bytedance.com/web/common',
'hm.baidu.com'
]
function checkBlack(url: string): boolean {
return blackList.some(black => url.includes(black))
}
const originalFetch = unsafeWindow.fetch.bind(unsafeWindow)
unsafeWindow.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
let url: string
if (input instanceof Request) {
url = input.url
} else if (input instanceof URL) {
url = input.href
} else {
url = input
}
if (checkBlack(url)) {
console.log('blocked request: ' + url)
return new Promise(() => {
return new Response('{}', { status: 403 })
})
}
return originalFetch(input, init)
}
const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest
unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest {
open(method: string, url: string, async: boolean = true, user?: string | null, password?: string | null): void {
if (checkBlack(url)) {
console.log('blocked request: ' + url)
this.abort()
} else {
super.open(method, url, async, user, password)
}
}
}
// 执行期hook,所以不用显式声明事件类hook
const _exports: HookConfig[] = []
export default _exports
+24 -9
View File
@@ -1,31 +1,46 @@
import type { HookConfig, HookEvent } from "../config";
import readerHook from "./readerHook";
import fetchHook from "./fetchHook";
import userHook from "./userHook";
const hooks = [
const hooks: HookConfig[] = [
...readerHook,
] as HookConfig[];
...fetchHook,
...userHook,
];
async function eventListener(event: HookEvent, previous?: string) {
console.log(`eventListener, event: ${event}, previous: ${previous}`);
async function onEvent(event: HookEvent, previous?: string) {
console.log(`onEvent, event: ${event}, previous: ${previous}`);
const path = window.location.pathname;
const hash = window.location.hash;
const params = new URLSearchParams(window.location.search);
const tasks = []
for (const hook of hooks) {
if (hook.event === event && hook.filter(path, params, hash)) {
await hook.handler();
tasks.push(async () => {
try {
await hook.handler(previous);
} catch (err) {
console.error(`[hook:${hook.id}] handler failed:`, err);
}
})
}
}
return Promise.resolve();
console.log(`tasks: `, tasks);
if (tasks.length > 0) {
// task must be Promise
await Promise.allSettled(tasks.map(task => task()));
}
}
export async function onUrlChange(previous: string) {
return eventListener('onUrlChange', previous);
return await onEvent('onUrlChange', previous);
}
export async function onHashChange(previous: string) {
return eventListener('onHashChange', previous);
return await onEvent('onHashChange', previous);
}
export async function onLoad() {
return eventListener('load');
return await onEvent('load');
}
+168 -15
View File
@@ -1,23 +1,34 @@
import { type HookConfig } from '../config'
import { getChapter } from '../api/content'
import { getBookInfoAndCatalog } from '../api/book'
import { decryptComicImage } from '../utils/crypto'
import { fetch } from '../config'
// import defaultcss from '../assets/default.css?raw';
import { cloneElement } from '../utils';
import { type Book } from '../types'
import moment from 'moment'
// import moment from 'moment'
let currentBook: Book | null = null
let latestItemId: string | null = null
async function insertContent() {
const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || ''
if (!itemId) {
console.warn('No item_id found in URL')
return
}
latestItemId = itemId
const chapter = await getChapter(itemId)
if (!chapter) {
console.warn('No chapter found for item_id:', itemId)
return
}
if (latestItemId !== itemId) {
console.debug('Stale chapter response discarded:', itemId)
return
}
console.log('Chapter:', chapter)
const pageState: any = (unsafeWindow as typeof unsafeWindow & {
@@ -38,20 +49,136 @@ async function insertContent() {
toProcess.removeChild(toProcess.childNodes[i] as HTMLElement)
}
}
const readerContainer = document.querySelector("div.muye-reader-content")
let scriptContainer: HTMLDivElement | null = null
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)")
if (readerContainer) {
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
scriptContainer.classList.add('fqa')
scriptContainer.classList.remove('noselect') // allow text selection
// readerContainer应该已经被注入的内容CSS隐藏掉了
// 防止插入多次
let scriptContainer = document.getElementById('fqa-reader-content') as HTMLDivElement | null
if (!scriptContainer) {
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
scriptContainer.id = 'fqa-reader-content'
scriptContainer.classList.add('fqa')
scriptContainer.classList.remove('noselect') // allow text selection
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
}
scriptContainer.innerHTML = ''
// 隐藏原来的内容div
readerContainer.classList.add('fqa-hide')
scriptContainer.appendChild(toProcess)
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
}
} else if (chapter.content.picInfos) { // comic
if (chapter.content.encrypt) {
// 加密漫画图片:懒加载解密
const imgs: HTMLImageElement[] = []
for (let i = 0; i < chapter.content.picInfos.length; i++) {
const picInfo = chapter.content.picInfos[i]
const img = document.createElement('img')
img.className = 'fqa-comic-img fqa-comic-encrypted'
img.alt = `第${i + 1}页`
img.dataset.encryptedUrl = picInfo.picUrl
img.dataset.encryptKey = chapter.content.encrypt_key
img.dataset.pageIndex = i.toString()
// 占位符:加载中
img.style.minHeight = '500px'
img.style.backgroundColor = '#f0f0f0'
imgs.push(img)
}
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)")
if (readerContainer) {
let scriptContainer = document.getElementById('fqa-comic-content') as HTMLDivElement | null
if (!scriptContainer) {
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
scriptContainer.id = 'fqa-reader-content'
scriptContainer.classList.add('fqa')
scriptContainer.classList.add('fqa-comic-reader')
scriptContainer.classList.remove('noselect')
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
}
scriptContainer.innerHTML = ''
readerContainer.classList.add('fqa-hide')
imgs.forEach(img => scriptContainer.appendChild(img))
const observer = new IntersectionObserver(
async (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement
if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) {
observer.unobserve(img) // 只解密一次
try {
const response = await fetch(img.dataset.encryptedUrl)
const encryptedBuffer = await response.arrayBuffer()
const decryptedBuffer = await decryptComicImage(
encryptedBuffer,
img.dataset.encryptKey
)
const blob = new Blob([decryptedBuffer], { type: 'image/jpeg' })
const blobUrl = URL.createObjectURL(blob)
img.src = blobUrl
img.style.minHeight = ''
img.style.backgroundColor = ''
img.onload = () => {
URL.revokeObjectURL(blobUrl)
}
} catch (error) {
console.error(`解密图片失败 (页 ${img.dataset.pageIndex}):`, error)
img.alt = `第${Number(img.dataset.pageIndex) + 1}页 - 解密失败`
img.style.backgroundColor = '#ffebee'
}
}
}
}
},
{
rootMargin: '200px'
}
)
imgs.forEach(img => observer.observe(img))
}
} else {
const imgs: HTMLImageElement[] = []
for (let i = 0; i < chapter.content.picInfos.length; i++) {
const picInfo = chapter.content.picInfos[i]
const img = document.createElement('img')
img.className = 'fqa-comic-img'
img.alt = `第${i + 1}页`
img.src = picInfo.picUrl
imgs.push(img)
}
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)")
if (readerContainer) {
// 防止插入多次
let scriptContainer = document.getElementById('fqa-comic-content') as HTMLDivElement | null
if (!scriptContainer) {
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
scriptContainer.id = 'fqa-reader-content'
scriptContainer.classList.add('fqa')
scriptContainer.classList.add('fqa-comic-reader')
scriptContainer.classList.remove('noselect')
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
}
scriptContainer.innerHTML = ''
// 隐藏原来的内容div
readerContainer.classList.add('fqa-hide')
imgs.forEach(img => scriptContainer.appendChild(img))
}
}
}
const muyeReaderTitle = document.querySelector("h1.muye-reader-title")
const muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle")
let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle")
document.querySelector('#fqa-subtitle')?.remove() // prevent duplicate subtitle
if (muyeReaderSubtitle) {
// 脱离原页面 Vue tree ,防止被二次修改
let _cloned = cloneElement(muyeReaderSubtitle)
muyeReaderSubtitle.classList.add('fqa-hide')
_cloned.id = 'fqa-subtitle'
muyeReaderSubtitle.insertAdjacentElement('afterend', _cloned)
muyeReaderSubtitle = _cloned
_cloned.classList.remove('fqa-hide')
console.log('clone subtitle: ', _cloned)
}
if (muyeReaderTitle) {
muyeReaderTitle.textContent = chapterTitle
}
@@ -79,7 +206,8 @@ async function insertContent() {
}
*/
if (muyeReaderSubtitle && pageState?.reader?.chapterData?.itemId === itemId) {
/*
if (muyeReaderSubtitle) {
// 写入更精确的更新时间
let updateTimeSpan = muyeReaderSubtitle.querySelectorAll('span.desc-item')[1]
if (updateTimeSpan) {
@@ -90,6 +218,7 @@ async function insertContent() {
moment(pageState?.reader?.chapterData?.firstPassTime * 1000).format('YYYY-MM-DD HH:mm:ss')
}
}
*/
/*
for (let i = 0; i < 10; i++) {
if (readerContainer) {
@@ -110,6 +239,9 @@ async function insertContent() {
const currentChapterItem = currentBook.chapter_list.find(c => c.item_id === itemId)
if (currentChapterItem) {
console.log('Current chapter:', currentChapterItem)
document.title = currentChapterItem.title + ' - ' + currentBook.title +
' - 番茄小说'
// 防止插入多次
if (document.getElementById('fqa-current-chapter-volume')) {
const c = document.getElementById('fqa-current-chapter-volume')
if (c) {
@@ -126,6 +258,25 @@ async function insertContent() {
c.insertAdjacentElement('beforebegin', volSpan)
}
}
// 写入更精确的更新时间
let updateTimeSpans = muyeReaderSubtitle?.querySelectorAll('span.desc-item') || []
if (updateTimeSpans.length >= 2 /* 应该是始终为3 */) {
/* 最后一个一般是更新时间 */
let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1] as HTMLSpanElement
let uttspan = updateTimeSpan.firstChild as HTMLSpanElement
// console.log(uttspan)
uttspan?.remove()
// seconds
updateTimeSpan.innerHTML =
'更新时间:' +
currentChapterItem.update_time
} else {
// 网页端屏蔽章节。只有一个本章字数 + 前面插入的卷名
let updateTimeSpan = document.createElement('span')
// let updateTimeSubSpan = document.createElement('span')
updateTimeSpan.className = 'desc-item'
updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`
}
}
}
}
@@ -170,23 +321,25 @@ function readerFilter(path: string, _query: URLSearchParams, _hash: string) {
return path.startsWith('/reader') || path.startsWith('reader')
}
export default [
const _exports: HookConfig[] = [
{
id: 'readerHook_load',
event: 'load',
handler: onLoad,
filter: readerFilter
} as HookConfig,
},
{
id: 'readerHook_urlChange',
event: 'onUrlChange',
handler: onUrlChange,
filter: readerFilter
} as HookConfig,
},
{
id: 'readerHook_hashChange',
event: 'onHashChange',
handler: onHashChange,
filter: readerFilter
} as HookConfig
] as HookConfig[]
}
]
export default _exports
+91
View File
@@ -0,0 +1,91 @@
import { getDetailedUserInfo, userState } from '../api/user'
import type { HookConfig } from '../config'
function formatReadingTime(readBookTime: bigint): string {
let minutes = readBookTime / 60_000n
const hours = minutes / 60n
minutes = minutes % 60n
return `${hours} 时 ${minutes} 分`
}
function createMenuItem(text: string): HTMLDivElement {
// <div tabindex="0"
// role="menuitem"
// class="arco-menu-item serial-menu-item slogin-user-avatar__menu-item-wrapper"
// >
// <div class="slogin-user-avatar__menu-item">
// <div class="slogin-user-avatar__menu-item__content">兑换会员</div>
// </div>
// </div>
const item = document.createElement('div')
item.role = 'menuitem'
item.classList.add('fqa-menu-item', 'arco-menu-item', 'serial-menu-item', 'slogin-user-avatar__menu-item-wrapper')
const inner1 = document.createElement('div')
inner1.classList.add('slogin-user-avatar__menu-item')
const inner2 = document.createElement('div')
inner2.classList.add('slogin-user-avatar__menu-item__content')
inner2.textContent = text
inner1.appendChild(inner2)
item.appendChild(inner1)
return item
}
async function mainHook(_previous?: string): Promise<void> {
const userInfo = await getDetailedUserInfo()
if (!userInfo) {
return
}
const injected = new WeakSet<HTMLElement>()
const inject = (menuInner: HTMLElement) => {
if (injected.has(menuInner)) {
return
}
injected.add(menuInner)
console.log('user action dialog created', menuInner)
const firstDiv = createMenuItem(`阅读 ${userInfo.read_book_num} 本书`)
menuInner.insertAdjacentElement('afterbegin', firstDiv)
const secondDiv = createMenuItem(formatReadingTime(userInfo.read_book_time ?? 0n))
firstDiv.insertAdjacentElement('afterend', secondDiv)
}
const scan = (root: HTMLElement) => {
if (root.classList.contains('arco-menu-inner')) {
inject(root)
}
root.querySelectorAll<HTMLElement>('.arco-menu-inner').forEach(inject)
}
// watch create of div.arco-menu-inner
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLElement) {
scan(node)
}
})
}
})
})
observer.observe(document.body, { childList: true, subtree: true })
// 请求 userInfo 期间菜单可能已经创建
scan(document.body)
}
function filter(_path: string, _query: URLSearchParams, _hash: string): boolean {
return userState.isLogin
}
const _exports: HookConfig[] = [
{
id: 'userHook',
event: 'load',
filter,
handler: mainHook
}
]
export default _exports