mirror of
https://github.com/naiyQAQ/fanqie-assistant.git
synced 2026-10-21 01:54:48 +00:00
feat: readme & update
This commit is contained in:
1 parent
5626fb6558
commit
f94a7123b4
17 files changed
+6084
-74
No files matched your search
+2
-3
@@ -8,7 +8,8 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
# 开发版本直接作为仓库内容发布。大更新再发release
|
||||
# dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
@@ -22,5 +23,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
node_modules
|
||||
@@ -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)
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供学习和技术研究使用。请遵守番茄小说的用户协议与相关法律法规,不要用于商业用途或内容再分发。
|
||||
本项目永久开源免费,未经授权不得用于售卖。
|
||||
本项目不保证任何功能的稳定性、准确性和安全性,使用时请自行承担风险。
|
||||
Vendored
+5464
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fanqie-assistant",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+4
-3
@@ -17,8 +17,9 @@ export function mappingCreationStatus(status: string): string {
|
||||
}
|
||||
|
||||
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 j: any = await response.json()
|
||||
const response = await apiFetch(`https://api5-sinfonlinec.jxbhmy.com/reading/bookapi/multi-detail/v?book_id=${bookId}&aid=1967`)
|
||||
const j: any = response.json()
|
||||
console.log('Book Info:', j)
|
||||
if (
|
||||
typeof j === 'object' &&
|
||||
j !== null &&
|
||||
@@ -41,7 +42,7 @@ export async function getBookInfo(bookId: string): Promise<Book> {
|
||||
cover_url: bookInfo.thumb_url,
|
||||
summary: bookInfo.abstract,
|
||||
// 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),
|
||||
// chapter_count: bookInfo.chapter_count,
|
||||
} as Book
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ export async function getCatalog(bookId: string): Promise<CatalogResult> {
|
||||
item_id: item.itemId,
|
||||
title: item.title,
|
||||
// YYYY-MM-DD HH:mm:ss
|
||||
update_time: moment(item.updateTime).format('YYYY-MM-DD HH:mm:ss') as string,
|
||||
char_count: item.charCount,
|
||||
update_time: moment(item.firstPassTime * 1000).format('YYYY-MM-DD HH:mm:ss') as string,
|
||||
char_count: item.charCount || 0,
|
||||
volume_title: item.volume_name,
|
||||
} as ChapterItem
|
||||
chapters.push(chapterItem)
|
||||
|
||||
@@ -79,4 +79,11 @@ export async function getChapter(itemId: string, _retry?: number): Promise<any>
|
||||
}
|
||||
j.content = await decryptChapter(j?.content, config.currentConfig)
|
||||
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()
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import apiFetch from '../utils/request'
|
||||
import { getCookie } from '../localStorage'
|
||||
import config from '../config'
|
||||
|
||||
/*
|
||||
关于 baseUrl:
|
||||
- baseUrl 是番茄小说旗下红烛小说的接口,与番茄数据基本通用。
|
||||
- 但与番茄小说相比,红烛小说的 API 风控更低,不要签名。
|
||||
- 该为第一方 API 而非第三方,所以请注意这一点。
|
||||
- 之后如果遇到一些功能需要把 Cookie 传到这里的,请不要来造谣“这个脚本把你的凭据发给了第三方”之类的。
|
||||
- 本项目重视您的账号数据安全,不会泄露您的账号数据,接受大家审查。
|
||||
*/
|
||||
export const baseUrl = 'https://api5-sinfonlinec.jxbhmy.com/redcandle/search';
|
||||
export const baseQuery = {
|
||||
aid: '1967',
|
||||
|
||||
+90
-10
@@ -1,4 +1,10 @@
|
||||
import { fetch } from '../config'
|
||||
import { del, read, write } from '../localStorage'
|
||||
|
||||
export interface UserTitle {
|
||||
title: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
@@ -6,35 +12,109 @@ export interface UserInfo {
|
||||
avatar: string
|
||||
desc: string
|
||||
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() {
|
||||
if (userInfo) return userInfo
|
||||
export let userState: UserState = {
|
||||
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 j = await response.json()
|
||||
userInfo = j?.data
|
||||
return userInfo
|
||||
if (j?.data?.id > 1) {
|
||||
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() {
|
||||
// sessionid is HttpOnly. So we use fetch directly to access user api.
|
||||
const response = await fetch('https://fanqienovel.com/api/user/info/v2')
|
||||
const j = await response.json()
|
||||
userInfo = {
|
||||
const _userInfo = {
|
||||
id: j?.data?.id,
|
||||
username: j?.data?.name,
|
||||
avatar: j?.data?.avatar,
|
||||
desc: j?.data?.desc,
|
||||
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> {
|
||||
await checkLogin()
|
||||
if (userInfo) {
|
||||
console.log('Hello, ', userInfo.username)
|
||||
if (userState.isLogin) {
|
||||
console.log('Hello, ', userState?.userInfo?.username)
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -16,10 +16,30 @@
|
||||
display: none!important;
|
||||
}
|
||||
|
||||
/* 移除默认阅读器div,让脚本接管。.muye-reader-content且不带fqa标签的div */
|
||||
.muye-reader-content:not(.fqa) {
|
||||
.fqa-hide {
|
||||
display: none!important;
|
||||
}
|
||||
/* 404 */
|
||||
.no-content {
|
||||
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
@@ -29,7 +29,7 @@ export interface EncryptionContext {
|
||||
encrypted?: ArrayBuffer;
|
||||
plaintext?: ArrayBuffer;
|
||||
}
|
||||
*/
|
||||
*/
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -1,43 +1,55 @@
|
||||
import _config from './config'
|
||||
import injectCSS from './cssInject'
|
||||
import initFontDecrypt from './fontDecrypt'
|
||||
import { onLoad, onUrlChange, onHashChange } from './hooks'
|
||||
import { version, name } from '../package.json'
|
||||
import initUser from './api/user'
|
||||
|
||||
let previousUrl = window.location.href
|
||||
let previousHash = window.location.hash
|
||||
const win = unsafeWindow
|
||||
|
||||
async function mainInit() {
|
||||
console.log(`================================================`)
|
||||
console.log(`== ${name} - ${version} ==`)
|
||||
console.log(`================================================`)
|
||||
initFontDecrypt()
|
||||
await injectCSS()
|
||||
let previousUrl = win.location.href
|
||||
let previousHash = win.location.hash
|
||||
|
||||
function installNavigationHooks() {
|
||||
for (const method of ['pushState', 'replaceState'] as const) {
|
||||
const original = history[method];
|
||||
history[method] = function (
|
||||
const original = win.history[method]
|
||||
win.history[method] = function (
|
||||
this: History,
|
||||
...args: Parameters<History[typeof method]>
|
||||
) {
|
||||
const result = original.apply(this, args);
|
||||
console.debug(`history.${method} called with args:`, args);
|
||||
onUrlChange(previousUrl);
|
||||
previousUrl = window.location.href;
|
||||
return result;
|
||||
};
|
||||
const result = original.apply(this, args)
|
||||
console.debug(`history.${method} called with args:`, args)
|
||||
void onUrlChange(previousUrl)
|
||||
previousUrl = win.location.href
|
||||
return result
|
||||
}
|
||||
}
|
||||
window.addEventListener('load', async () => {
|
||||
await onLoad()
|
||||
win.addEventListener('popstate', () => {
|
||||
void onUrlChange(previousUrl)
|
||||
previousUrl = win.location.href
|
||||
})
|
||||
window.addEventListener('hashchange', async () => {
|
||||
await onHashChange(previousHash)
|
||||
previousHash = window.location.hash
|
||||
win.addEventListener('hashchange', () => {
|
||||
void onHashChange(previousHash)
|
||||
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
@@ -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();
|
||||
// AESGCM
|
||||
const cryptoKey = await subtle.importKey(
|
||||
|
||||
Reference in new issue
Block a user