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

+2 -3
View File
@@ -8,7 +8,8 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist # 开发版本直接作为仓库内容发布。大更新再发release
# dist
dist-ssr dist-ssr
*.local *.local
@@ -22,5 +23,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
node_modules
+105
View File
@@ -0,0 +1,105 @@
# 番茄小说助手 (fanqie-assistant)
一个用于 [番茄小说网页版](https://fanqienovel.com) 的用户脚本(Userscript):去广告、去推广、解锁章节、优化阅读体验。
> **项目处于开发初期。** 功能仅覆盖基础阅读,接口和目录结构都可能随时调整。欢迎试用和反馈,但请做好随时会有破坏性变更的准备。
## 已实现
第一阶段「基础阅读」已经可用:
- **正文替换**:阅读页拉取完整正文并渲染,替换网页原有内容容器,同时允许复制文本。
- **解锁章节**:网页端被屏蔽的章节可以正常阅读。
- **漫画阅读**:图片章节支持懒加载,加密图片滚动到视口时自动处理。
- **字体反混淆**:还原网页自绘字体渲染的混淆文本,动态插入的内容同样生效。
- **屏蔽埋点上报**:丢弃发往字节、百度统计域名的埋点请求。
- **章节信息补全**:补上卷名、精确到秒的更新时间,并修正页面标题。
- **用户信息展示**:已登录时在头像菜单里显示已读本数和累计阅读时长。
## 开发计划
计划不分先后,也可能会开发以下列表以外的功能:
1. 短剧网页端播放
2. 更完善的用户详情界面(自己的和他人的)
3. 段评、章评、书评
4. 阅读数据上报(与安卓端同步阅读进度与时长)
5. APP 端带推荐的排行榜
6. 分类筛选
7. 解锁书架中因下架或版权原因无法在网页阅读、但 APP 端仍可阅读的书籍
8. 更详细的书籍信息(评分、在读人数等)
9. 推书页面
10. 听书
如果您有更多好的想法,欢迎提 PR 或 Issue。
## 使用
需要一个用户脚本管理器,推荐 [Tampermonkey](https://www.tampermonkey.net/)。
安装构建产物 `fanqie-assistant.user.js` 即可。
## 开发
```bash
npm install
npm run dev # 启动开发服务器
npm run build # 类型检查 + 打包
npm run preview # 预览构建产物
```
### 调试
`npm run dev` 后,vite-plugin-monkey 会输出一个安装地址,在脚本管理器里安装这个开发版即可。它指向本地开发服务器,改完代码刷新页面就能看到效果,不需要每次重新打包安装。
脚本运行时机是 `document-start`,日志都打在浏览器控制台,按 `fqa` 或钩子 id(如 `readerHook_load`)过滤比较方便。
调试时注意关掉正式版脚本,两个版本同时启用会重复注入。
### 编译
```bash
npm run build
```
产物是单文件 `dist/fanqie-assistant.user.js`。`build` 会先跑 `tsc` 做类型检查,类型报错会中断打包。
## 项目结构
```
src/
├── main.ts 入口,安装导航钩子并按顺序初始化各模块
├── config.ts 设备配置与全局常量
├── types.ts 书籍、卷、章节等数据模型
├── cssInject.ts 样式注入
├── fontDecrypt.ts 自绘字体文本还原
├── localStorage.ts 存储读写封装
├── api/ 接口层(用户、书籍、目录、正文)
├── hooks/ 页面钩子(阅读页、用户菜单、请求拦截)
├── utils/ 请求封装与加解密工具
└── assets/ 样式与图标
```
新增页面功能时,在 `hooks/` 下建一个模块,导出 `HookConfig[]` 并在 `hooks/index.ts` 里注册。每个钩子声明自己关心的事件和一个 `filter`,命中才执行,互不影响。
### UI 技术选型
目前的界面改动都是直接操作 DOM,因为改的量还很小,插几个节点就够了。Vue 已经引入并配好构建(`@vitejs/plugin-vue`),等做到短剧播放、用户详情、评论这类大型界面时会用 Vue 写,但现在还没到那一步。
## 关于账号安全
正文与书籍详情走 `*.jxbhmy.com`,这是番茄小说旗下**红烛小说**的接口,与番茄数据基本通用,风控更低且不需要请求签名。这是第一方接口,不是第三方服务。
脚本**没有申请 `GM_cookie` 权限**。`sessionid` 是 HttpOnly 的,脚本读不到、也没打算读。所有需要登录凭据的请求都用页面原生 `fetch` 发送的同源请求完成,由浏览器自动带上 Cookie,凭据不经过脚本,也不会被发往任何其他地方。
如果后续同源接口无法满足脚本需求,会考虑使用 `GM_cookie` 权限。但我们确保您的凭据不会被发送到番茄小说及其相关平台以外的任何第三方。
代码全部开源,欢迎审查。
## 许可
[GPL-3.0](LICENSE)
## 免责声明
本项目仅供学习和技术研究使用。请遵守番茄小说的用户协议与相关法律法规,不要用于商业用途或内容再分发。
本项目永久开源免费,未经授权不得用于售卖。
本项目不保证任何功能的稳定性、准确性和安全性,使用时请自行承担风险。
+5464
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fanqie-assistant", "name": "fanqie-assistant",
"version": "0.0.1", "version": "0.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4 -3
View File
@@ -17,8 +17,9 @@ export function mappingCreationStatus(status: string): string {
} }
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=13`) const response = await apiFetch(`https://api5-sinfonlinec.jxbhmy.com/reading/bookapi/multi-detail/v?book_id=${bookId}&aid=1967`)
const j: any = await response.json() const j: any = response.json()
console.log('Book Info:', j)
if ( if (
typeof j === 'object' && typeof j === 'object' &&
j !== null && j !== null &&
@@ -41,7 +42,7 @@ export async function getBookInfo(bookId: string): Promise<Book> {
cover_url: bookInfo.thumb_url, cover_url: bookInfo.thumb_url,
summary: bookInfo.abstract, summary: bookInfo.abstract,
// volume_list: bookInfo.volume_list, // volume_list: bookInfo.volume_list,
update_time: moment(bookInfo.last_chapter_first_pass_time).format('YYYY-MM-DD HH:mm:ss'), update_time: moment(bookInfo.last_chapter_first_pass_time * 1000).format('YYYY-MM-DD HH:mm:ss'),
status: mappingCreationStatus(bookInfo.creation_status), status: mappingCreationStatus(bookInfo.creation_status),
// chapter_count: bookInfo.chapter_count, // chapter_count: bookInfo.chapter_count,
} as Book } as Book
+2 -2
View File
@@ -24,8 +24,8 @@ export async function getCatalog(bookId: string): Promise<CatalogResult> {
item_id: item.itemId, item_id: item.itemId,
title: item.title, title: item.title,
// YYYY-MM-DD HH:mm:ss // YYYY-MM-DD HH:mm:ss
update_time: moment(item.updateTime).format('YYYY-MM-DD HH:mm:ss') as string, update_time: moment(item.firstPassTime * 1000).format('YYYY-MM-DD HH:mm:ss') as string,
char_count: item.charCount, char_count: item.charCount || 0,
volume_title: item.volume_name, volume_title: item.volume_name,
} as ChapterItem } as ChapterItem
chapters.push(chapterItem) chapters.push(chapterItem)
+7
View File
@@ -79,4 +79,11 @@ export async function getChapter(itemId: string, _retry?: number): Promise<any>
} }
j.content = await decryptChapter(j?.content, config.currentConfig) j.content = await decryptChapter(j?.content, config.currentConfig)
return j return j
}
export async function getChapterInfo(itemId: string): Promise<unknown> {
// use page fetch (includes bytedance security sdk. we reuse it)
// same origin
const res = await fetch('https://fanqienovel.com/api/reader/full?itemId=' + itemId)
return await res.json()
} }
+8
View File
@@ -2,6 +2,14 @@ import apiFetch from '../utils/request'
import { getCookie } from '../localStorage' import { getCookie } from '../localStorage'
import config from '../config' import config from '../config'
/*
关于 baseUrl:
- baseUrl 是番茄小说旗下红烛小说的接口,与番茄数据基本通用。
- 但与番茄小说相比,红烛小说的 API 风控更低,不要签名。
- 该为第一方 API 而非第三方,所以请注意这一点。
- 之后如果遇到一些功能需要把 Cookie 传到这里的,请不要来造谣“这个脚本把你的凭据发给了第三方”之类的。
- 本项目重视您的账号数据安全,不会泄露您的账号数据,接受大家审查。
*/
export const baseUrl = 'https://api5-sinfonlinec.jxbhmy.com/redcandle/search'; export const baseUrl = 'https://api5-sinfonlinec.jxbhmy.com/redcandle/search';
export const baseQuery = { export const baseQuery = {
aid: '1967', aid: '1967',
+90 -10
View File
@@ -1,4 +1,10 @@
import { fetch } from '../config' import { fetch } from '../config'
import { del, read, write } from '../localStorage'
export interface UserTitle {
title: string
desc: string
}
export interface UserInfo { export interface UserInfo {
id: string id: string
@@ -6,35 +12,109 @@ export interface UserInfo {
avatar: string avatar: string
desc: string desc: string
age: number age: number
// detailed
gender?: number // 0: 女 | 1: 男 | 2: 未知/没设置
recommend_gender?: number // 0: 女频 | 1: 男频 | 2: 我都爱看
fans_num?: number
following_num?: number
is_author?: boolean
author_desc?: string
read_book_num?: number
read_book_time?: bigint // ms
} }
export let userInfo: UserInfo | null = null export interface UserState {
isLogin: boolean
userInfo: UserInfo | null
}
export async function getUserInfo() { export let userState: UserState = {
if (userInfo) return userInfo isLogin: false,
userInfo: null,
}
if (read('userState')) {
userState = read('userState')
}
export async function getDetailedUserInfo(): Promise<UserInfo | null> {
if (!userState?.isLogin || !userState?.userInfo) {
return null
}
if (userState.userInfo.gender !== undefined &&
userState.userInfo.recommend_gender !== undefined &&
userState.userInfo.fans_num !== undefined &&
userState.userInfo.following_num !== undefined &&
userState.userInfo.is_author !== undefined &&
userState.userInfo.author_desc !== undefined &&
userState.userInfo.read_book_num !== undefined &&
userState.userInfo.read_book_time !== undefined
) {
return userState.userInfo
}
const response = await fetch('https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967')
const j = await response.json()
if (j?.data) {
const data = j.data
userState.userInfo.gender = data.profile_gender
userState.userInfo.recommend_gender = data.gender
userState.userInfo.fans_num = data.fans_num
userState.userInfo.following_num = data.follow_user_num
userState.userInfo.is_author = data.is_author
userState.userInfo.author_desc = data.author_desc
userState.userInfo.read_book_num = data.read_book_num
userState.userInfo.read_book_time = BigInt(data.read_book_time)
// userState.userInfo.titles = data.titles
return userState.userInfo
}
return userState.userInfo
}
export async function getUserInfo(): Promise<UserInfo | null> {
if (userState?.isLogin && userState.userInfo) return userState.userInfo
const response = await fetch('https://fanqienovel.com/api/user/info/v2') const response = await fetch('https://fanqienovel.com/api/user/info/v2')
const j = await response.json() const j = await response.json()
userInfo = j?.data if (j?.data?.id > 1) {
return userInfo userState = {
isLogin: true,
userInfo: {
id: j?.data?.id,
username: j?.data?.name,
avatar: j?.data?.avatar,
desc: j?.data?.desc,
age: j?.data?.age,
},
}
return userState.userInfo
} else {
return null
}
} }
export async function checkLogin() { export async function checkLogin() {
// sessionid is HttpOnly. So we use fetch directly to access user api. // sessionid is HttpOnly. So we use fetch directly to access user api.
const response = await fetch('https://fanqienovel.com/api/user/info/v2') const response = await fetch('https://fanqienovel.com/api/user/info/v2')
const j = await response.json() const j = await response.json()
userInfo = { const _userInfo = {
id: j?.data?.id, id: j?.data?.id,
username: j?.data?.name, username: j?.data?.name,
avatar: j?.data?.avatar, avatar: j?.data?.avatar,
desc: j?.data?.desc, desc: j?.data?.desc,
age: j?.data?.age, age: j?.data?.age,
} as UserInfo }
return j?.data?.id > 1 if (j?.data?.id > 1) {
userState.isLogin = true
userState.userInfo = _userInfo
write('userState', userState)
return true
} else {
del('userState')
return false
}
} }
export default async function init(): Promise<void> { export default async function init(): Promise<void> {
await checkLogin() await checkLogin()
if (userInfo) { if (userState.isLogin) {
console.log('Hello, ', userInfo.username) console.log('Hello, ', userState?.userInfo?.username)
} }
} }
+22 -2
View File
@@ -16,10 +16,30 @@
display: none!important; display: none!important;
} }
/* 移除默认阅读器div,让脚本接管。.muye-reader-content且不带fqa标签的div */ .fqa-hide {
.muye-reader-content:not(.fqa) {
display: none!important; display: none!important;
} }
/* 404 */
.no-content { .no-content {
display: none!important; display: none!important;
}
.fqa-comic-img {
width: 100%!important;
height: 100%!important;
max-width: 100%!important;
max-height: 100%!important;
padding-top: 0!important;
padding-bottom: 0!important;
margin-top: 0!important;
margin-bottom: 0!important;
}
.fqa-comic-reader {
line-height: 0!important;
}
.fqa-menu-item,
.arco-menu-item {
width: 100%!important;
} }
+1 -1
View File
@@ -29,7 +29,7 @@ export interface EncryptionContext {
encrypted?: ArrayBuffer; encrypted?: ArrayBuffer;
plaintext?: ArrayBuffer; plaintext?: ArrayBuffer;
} }
*/ */
// b64decode("rCXGfd2POMGzeiNIgo4iLg==") = 172, 37, 198, 125, 221, 143, 56, 193, 179, 122, 35, 72, 130, 142, 34, 46 // b64decode("rCXGfd2POMGzeiNIgo4iLg==") = 172, 37, 198, 125, 221, 143, 56, 193, 179, 122, 35, 72, 130, 142, 34, 46
export const shared_key: ArrayBuffer = new Uint8Array([172, 37, 198, 125, 221, 143, 56, 193, 179, 122, 35, 72, 130, 142, 34, 46]).buffer; export const shared_key: ArrayBuffer = new Uint8Array([172, 37, 198, 125, 221, 143, 56, 193, 179, 122, 35, 72, 130, 142, 34, 46]).buffer;
+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 type { HookConfig, HookEvent } from "../config";
import readerHook from "./readerHook"; import readerHook from "./readerHook";
import fetchHook from "./fetchHook";
import userHook from "./userHook";
const hooks = [ const hooks: HookConfig[] = [
...readerHook, ...readerHook,
] as HookConfig[]; ...fetchHook,
...userHook,
];
async function eventListener(event: HookEvent, previous?: string) { async function onEvent(event: HookEvent, previous?: string) {
console.log(`eventListener, event: ${event}, previous: ${previous}`); console.log(`onEvent, event: ${event}, previous: ${previous}`);
const path = window.location.pathname; const path = window.location.pathname;
const hash = window.location.hash; const hash = window.location.hash;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const tasks = []
for (const hook of hooks) { for (const hook of hooks) {
if (hook.event === event && hook.filter(path, params, hash)) { 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) { export async function onUrlChange(previous: string) {
return eventListener('onUrlChange', previous); return await onEvent('onUrlChange', previous);
} }
export async function onHashChange(previous: string) { export async function onHashChange(previous: string) {
return eventListener('onHashChange', previous); return await onEvent('onHashChange', previous);
} }
export async function onLoad() { 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 { 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 { 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 moment from 'moment' // import moment from 'moment'
let currentBook: Book | null = null let currentBook: Book | null = null
let latestItemId: string | null = null
async function insertContent() { async function insertContent() {
const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || '' const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || ''
if (!itemId) { if (!itemId) {
console.warn('No item_id found in URL') console.warn('No item_id found in URL')
return return
} }
latestItemId = itemId
const chapter = await getChapter(itemId) const chapter = await getChapter(itemId)
if (!chapter) { if (!chapter) {
console.warn('No chapter found for item_id:', itemId) console.warn('No chapter found for item_id:', itemId)
return return
} }
if (latestItemId !== itemId) {
console.debug('Stale chapter response discarded:', itemId)
return
}
console.log('Chapter:', chapter) console.log('Chapter:', chapter)
const pageState: any = (unsafeWindow as typeof unsafeWindow & { const pageState: any = (unsafeWindow as typeof unsafeWindow & {
@@ -38,20 +49,136 @@ async function insertContent() {
toProcess.removeChild(toProcess.childNodes[i] as HTMLElement) 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) { if (readerContainer) {
scriptContainer = cloneElement(readerContainer) as HTMLDivElement // 防止插入多次
scriptContainer.classList.add('fqa') let scriptContainer = document.getElementById('fqa-reader-content') as HTMLDivElement | null
scriptContainer.classList.remove('noselect') // allow text selection if (!scriptContainer) {
// readerContainer应该已经被注入的内容CSS隐藏掉了 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 = '' scriptContainer.innerHTML = ''
// 隐藏原来的内容div
readerContainer.classList.add('fqa-hide')
scriptContainer.appendChild(toProcess) 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 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) { if (muyeReaderTitle) {
muyeReaderTitle.textContent = chapterTitle 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] let updateTimeSpan = muyeReaderSubtitle.querySelectorAll('span.desc-item')[1]
if (updateTimeSpan) { if (updateTimeSpan) {
@@ -90,6 +218,7 @@ async function insertContent() {
moment(pageState?.reader?.chapterData?.firstPassTime * 1000).format('YYYY-MM-DD HH:mm:ss') moment(pageState?.reader?.chapterData?.firstPassTime * 1000).format('YYYY-MM-DD HH:mm:ss')
} }
} }
*/
/* /*
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
if (readerContainer) { if (readerContainer) {
@@ -110,6 +239,9 @@ async function insertContent() {
const currentChapterItem = currentBook.chapter_list.find(c => c.item_id === itemId) const currentChapterItem = currentBook.chapter_list.find(c => c.item_id === itemId)
if (currentChapterItem) { if (currentChapterItem) {
console.log('Current chapter:', currentChapterItem) console.log('Current chapter:', currentChapterItem)
document.title = currentChapterItem.title + ' - ' + currentBook.title +
' - 番茄小说'
// 防止插入多次
if (document.getElementById('fqa-current-chapter-volume')) { if (document.getElementById('fqa-current-chapter-volume')) {
const c = document.getElementById('fqa-current-chapter-volume') const c = document.getElementById('fqa-current-chapter-volume')
if (c) { if (c) {
@@ -126,6 +258,25 @@ async function insertContent() {
c.insertAdjacentElement('beforebegin', volSpan) 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') return path.startsWith('/reader') || path.startsWith('reader')
} }
export default [ const _exports: HookConfig[] = [
{ {
id: 'readerHook_load', id: 'readerHook_load',
event: 'load', event: 'load',
handler: onLoad, handler: onLoad,
filter: readerFilter filter: readerFilter
} as HookConfig, },
{ {
id: 'readerHook_urlChange', id: 'readerHook_urlChange',
event: 'onUrlChange', event: 'onUrlChange',
handler: onUrlChange, handler: onUrlChange,
filter: readerFilter filter: readerFilter
} as HookConfig, },
{ {
id: 'readerHook_hashChange', id: 'readerHook_hashChange',
event: 'onHashChange', event: 'onHashChange',
handler: onHashChange, handler: onHashChange,
filter: readerFilter 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
+39 -27
View File
@@ -1,43 +1,55 @@
import _config from './config'
import injectCSS from './cssInject' import injectCSS from './cssInject'
import initFontDecrypt from './fontDecrypt' import initFontDecrypt from './fontDecrypt'
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'
let previousUrl = window.location.href const win = unsafeWindow
let previousHash = window.location.hash
async function mainInit() { let previousUrl = win.location.href
console.log(`================================================`) let previousHash = win.location.hash
console.log(`== ${name} - ${version} ==`)
console.log(`================================================`) function installNavigationHooks() {
initFontDecrypt()
await injectCSS()
for (const method of ['pushState', 'replaceState'] as const) { for (const method of ['pushState', 'replaceState'] as const) {
const original = history[method]; const original = win.history[method]
history[method] = function ( win.history[method] = function (
this: History, this: History,
...args: Parameters<History[typeof method]> ...args: Parameters<History[typeof method]>
) { ) {
const result = original.apply(this, args); const result = original.apply(this, args)
console.debug(`history.${method} called with args:`, args); console.debug(`history.${method} called with args:`, args)
onUrlChange(previousUrl); void onUrlChange(previousUrl)
previousUrl = window.location.href; previousUrl = win.location.href
return result; return result
}; }
} }
window.addEventListener('load', async () => { win.addEventListener('popstate', () => {
await onLoad() void onUrlChange(previousUrl)
previousUrl = win.location.href
}) })
window.addEventListener('hashchange', async () => { win.addEventListener('hashchange', () => {
await onHashChange(previousHash) void onHashChange(previousHash)
previousHash = window.location.hash previousHash = win.location.hash
}) })
window.addEventListener('popstate', async () => {
await onUrlChange(previousUrl)
previousUrl = window.location.href
})
await initUser()
} }
mainInit() async function mainInit() {
// config 必须最先初始化以确保一些全局函数没有被劫持
if (_config.currentConfig) {} // force load
console.log(`================================================`)
console.log(`== ${name} - ${version} ==`)
console.log(`================================================`)
installNavigationHooks()
initFontDecrypt()
await injectCSS()
await initUser()
void onLoad()
}
mainInit()
+1 -1
View File
@@ -170,7 +170,7 @@ export async function decryptKeyinfoResponse(encrypted: string): Promise<ArrayBu
); );
} }
export async function decryptMangaImage(image: ArrayBuffer, key: string /* hex */): Promise<ArrayBuffer> { export async function decryptComicImage(image: ArrayBuffer, key: string /* hex */): Promise<ArrayBuffer> {
const subtle = getSubtle(); const subtle = getSubtle();
// AESGCM // AESGCM
const cryptoKey = await subtle.importKey( const cryptoKey = await subtle.importKey(