Add support to display book info and save book info to databases

This commit is contained in:
2026-02-16 14:50:41 +08:00
parent 0cf5eccaf1
commit 952712054f
9 changed files with 216 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
import { IndexedDbConfig } from "../config";
import type { QdChapterInfo } from "../types";
import type { QdChapterInfo, QdBookInfo } from "../types";
import { compress, isServiceWorker } from "../utils";
import { hash_qdchapter_info } from "../utils/qd";
import type { Db } from "./interfaces";
@@ -148,6 +148,9 @@ export class IndexedDb implements Db {
await save_data(this.qddb, 'chapters', info);
}
}
async saveQdBook(info: QdBookInfo) {
await save_data(this.qddb, 'books', info);
}
close() {
this.qddb.close();
}

View File

@@ -1,7 +1,7 @@
import { DbConfig, DbType } from "../config";
import { IndexedDb } from "./indexedDb";
import { PocketBaseDb } from "./pocketBase";
import type { QdChapterInfo } from "../types";
import type { QdChapterInfo, QdBookInfo } from "../types";
export interface Db {
init(): Promise<void>;
@@ -10,6 +10,11 @@ export interface Db {
* @param info Chapter info to save. if id, bookId and hash are matched in the database, skip saving.
*/
saveQdChapter(info: QdChapterInfo): Promise<void>;
/**
* Save book info to database.
* @param info Book info to save. if id is matched in the database, update the existing record.
*/
saveQdBook(info: QdBookInfo): Promise<void>;
close(): void;
}
@@ -18,11 +23,9 @@ export async function createDb(): Promise<Db> {
await config.init();
switch (config.DbType) {
case DbType.IndexedDb:
const db1 = new IndexedDb(config.IndexedDb);
return db1;
return new IndexedDb(config.IndexedDb);
case DbType.PocketBase:
const db2 = new PocketBaseDb(config.PocketBase);
return db2;
return new PocketBaseDb(config.PocketBase);
default:
throw new Error('Unsupported database type');
}

View File

@@ -2,7 +2,7 @@ import type { Db } from "./interfaces";
import PocketBase from "pocketbase";
import type { CollectionModel } from "pocketbase";
import { PocketBaseConfig } from "../config";
import { QdChapterInfo } from "../types";
import { QdChapterInfo, QdBookInfo } from "../types";
import { hash_qdchapter_info } from "../utils/qd";
const QD_CHAPTERS_FIELDS = [
@@ -32,10 +32,31 @@ const QD_CHAPTERS_FIELDS = [
'required': true,
}
];
const QD_BOOKS_FIELDS = [
{
'name': 'bookId',
'type': 'number',
'required': true,
},
{
'name': 'name',
'type': 'text',
'required': true,
},
{
'name': 'data',
'type': 'json',
'required': true,
}
];
const QD_CHAPTERS_INDEXES = [
'CREATE INDEX `idx_cid` ON `{name}` (chapterId)',
'CREATE INDEX `idx_bid` ON `{name}` (bookId)',
'CREATE INDEX `idx_hash` ON `{name}` (chapterId,bookId,hash)',
'CREATE INDEX `idx_{name}_cid` ON `{name}` (chapterId)',
'CREATE INDEX `idx_{name}_bid` ON `{name}` (bookId)',
'CREATE INDEX `idx_{name}_hash` ON `{name}` (chapterId,bookId,hash)',
]
const QD_BOOKS_INDEXES = [
'CREATE UNIQUE INDEX `idx_{name}_bid` ON `{name}` (bookId)',
'CREATE INDEX `idx_{name}_name` ON `{name}` (name)',
]
export class PocketBaseDb implements Db {
@@ -60,6 +81,14 @@ export class PocketBaseDb implements Db {
await this.updateCollection('qd_chapters', QD_CHAPTERS_FIELDS, QD_CHAPTERS_INDEXES);
}
}
if (!collectionNames.has(`${this.cfg.prefix}qd_books`)) {
await this.createCollection('qd_books', QD_BOOKS_FIELDS, QD_BOOKS_INDEXES);
} else {
const target = collections.find(c => c.name === `${this.cfg.prefix}qd_books`)!;
if (!this.checkCollection(target, QD_BOOKS_FIELDS, QD_BOOKS_INDEXES)) {
await this.updateCollection('qd_books', QD_BOOKS_FIELDS, QD_BOOKS_INDEXES);
}
}
}
async createCollection(name: string, fields: Record<string, unknown>[], indexes: string[]) {
await this.client.collections.create({
@@ -114,6 +143,28 @@ export class PocketBaseDb implements Db {
});
console.log(re);
}
async saveQdBook(info: QdBookInfo) {
const id = await this.getQdBookId(info.id);
if (id) {
await this.client.collection(`${this.cfg.prefix}qd_books`).update(id, {
name: info.bookName,
data: info,
});
} else {
await this.client.collection(`${this.cfg.prefix}qd_books`).create({
bookId: info.id,
name: info.bookName,
data: info,
});
}
}
async getQdBookId(id: number): Promise<string | null> {
const records = await this.client.collection(`${this.cfg.prefix}qd_books`).getList(1, 1, {
filter: `bookId = ${id}`,
fields: 'id',
});
return records.totalItems > 0 ? records.items[0].id : null;
}
async hasQdChapter(id: number, bookId: number, hash: string) {
const records = await this.client.collection(`${this.cfg.prefix}qd_chapters`).getList(1, 1, {
filter: `chapterId = ${id} && bookId = ${bookId} && hash = "${hash}"`,

67
src/models/QdBookInfo.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { Space, Card, Col, Row, Image, Descriptions, Collapse, Typography, Button } from 'antd';
import type { QdBookInfo } from '../types';
import { createDb } from '../db/interfaces';
interface QdBookInfoProps {
info: QdBookInfo;
}
const { Text } = Typography;
export default function QdBookInfo({ info }: QdBookInfoProps) {
async function saveToDb() {
const db = await createDb();
await db.init();
await db.saveQdBook(info);
db.close();
}
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Card title="书籍信息" size="small">
<Row>
<Col span={6}>
<Image src={info.bookInfo.imgUrl} />
</Col>
<Col span={18}>
<Descriptions column={1} size="small">
<Descriptions.Item label="书名">
{info.bookName}
</Descriptions.Item>
<Descriptions.Item label="书籍ID">
{info.id}
</Descriptions.Item>
<Descriptions.Item label="标签">
{info.tags.map(tag => tag.name).join(', ')}
</Descriptions.Item>
<Descriptions.Item label="简介">
{info.intro.split('\n').map((line, index) => (
<>{line}<br /></>
))}
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
</Card>
<Card title="操作" size="small">
<Button type="primary" onClick={saveToDb}></Button>
</Card>
<Card title="卷信息" size="small">
<Collapse items={
info.volumes.map(volume => ({
key: volume.id,
label: volume.name,
extra: volume.isVip ? <span style={{ color: 'red' }}>VIP卷</span> : null,
children: (<Space size="small" orientation="vertical">
{volume.chapters.map(chapter => (
<Text key={chapter.id} onClick={() => {
const url = `https://www.qidian.com/chapter/${info.id}/${chapter.id}`;
chrome.tabs.create({ url });
}} style={{ cursor: 'pointer' }}>{chapter.name}</Text>
))}
</Space>)
}))
} />
</Card>
</Space>
)
}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Descriptions, Card, Typography, Tag, Space, Button } from 'antd';
import type { QdChapterInfo } from '../types';
import { get_chapter_content, saveAsFile } from '../utils';

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import type { Message, QdChapterInfo } from "./types";
import type { Message, QdChapterInfo, QdBookInfo } from "./types";
import { getCurrentTab, parseUrlParams, sendMessageToTab } from "./utils";
import * as styles from "./popup.module.css";
import { Spin, Result } from "antd";
import QdChapterInfoModel from "./models/QdChatperInfo";
import QdBookInfoModel from "./models/QdBookInfo";
function PopupBody() {
const [result, setResult] = useState<Message | null>(null);
@@ -70,7 +71,10 @@ function PopupBody() {
return <QdChapterInfoModel info={body} />;
}
if (result.ok && result.body?.type === 'QdBookInfo') {
return <Result status="success" title={result.body.bookName} subTitle={`Book ID: ${result.body.id}`} />;
const body: QdBookInfo = result.body;
/**@ts-ignore*/
delete body.type;
return <QdBookInfoModel info={body} />;
}
return <Result status="error" title="错误" subTitle={result.msg || '未知错误'} />;
}

View File

@@ -1,9 +1,11 @@
import type { BookGData, QdBookTag } from "./qdtypes";
import type { BookGData, QdBookTag, Volume } from "./qdtypes";
import type { SendMessage, Message } from "./types";
import { QdBookTagType } from "./qdtypes";
let g_data: BookGData | undefined;
export const QD_CHAPTER_URLPATH_REGEX = /^\/chapter\/\d+\/(\d+)\/?$/;
function get_book_name() {
const bookName = document.getElementById('bookName') as HTMLHeadingElement | null;
if (!bookName) {
@@ -53,6 +55,65 @@ function get_book_tags() {
return tags;
}
function get_book_intro() {
const intro = document.getElementById('book-intro-detail') as HTMLParagraphElement | null;
if (!intro) {
throw new Error('Failed to find book intro element');
}
return intro.innerText.trim();
}
function get_book_volumes() {
const volumes: Volume[] = [];
const vols = document.querySelectorAll('div.catalog-volume');
for (const vol of vols) {
const volInput = vol.querySelector('input.input-vol') as HTMLInputElement | null;
if (!volInput) {
throw new Error('Failed to find volume input element');
}
const volId = volInput.id;
const volName = vol.querySelector('.volume-name') as HTMLElement | null;
if (!volName) {
throw new Error('Failed to find volume name element');
}
const firstNode = volName.firstChild;
if (!firstNode) {
throw new Error('Volume name element has no child');
}
if (firstNode.nodeType !== Node.TEXT_NODE) {
throw new Error('Volume name element first child is not a text node');
}
const name = firstNode.textContent?.trim() || '';
const vipNode = volName.querySelector('span.vip');
const volume: Volume = {
name,
id: volId,
isVip: !!vipNode,
chapters: [],
}
const chs = vol.querySelectorAll('li.chapter-item');
for (const ch of chs) {
const chName = ch.querySelector('a.chapter-name') as HTMLAnchorElement | null;
if (!chName) {
throw new Error('Failed to find chapter name element');
}
const name = chName.innerText.trim();
const href = new URL(chName.href);
const match = href.pathname.match(QD_CHAPTER_URLPATH_REGEX);
if (!match) {
throw new Error(`Chapter URL does not match expected pattern: ${chName.href}`);
}
const chapterId = match[1];
volume.chapters.push({
name,
id: parseInt(chapterId),
});
}
volumes.push(volume);
}
return volumes;
}
window.addEventListener('message', (event) => {
const data = event.data;
if (data && data['@type'] === 'g_data') {
@@ -85,6 +146,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
bookName,
id: g_data.pageJson.bookId,
tags: get_book_tags(),
intro: get_book_intro(),
volumes: get_book_volumes(),
},
for: m.type,
};

View File

@@ -192,11 +192,18 @@ export type BookGData = {
}
}
export type Volume = {
export type Chapter = {
name: string;
id: number;
}
export type Volume = {
name: string;
id: string;
isVip: boolean;
chapters: Chapter[];
}
export enum QdBookTagType {
System,
Category,

View File

@@ -22,12 +22,14 @@ export type QdChapterInfo = {
hash?: string;
}
export type QDBookInfo = {
export type QdBookInfo = {
bookInfo: QdTypes.BookGData;
bookName: string;
/**Book ID */
id: number;
tags: QdTypes.QdBookTag[];
intro: string;
volumes: QdTypes.Volume[];
}
export type SendMessageMap = {
@@ -42,7 +44,7 @@ export type SendMessage = DiscriminatedUnion<"type", SendMessageMap>;
export type MessageMap = {
QdChapterInfo: QdChapterInfo,
QdBookInfo: QDBookInfo,
QdBookInfo: QdBookInfo,
};
export type MessageBody = DiscriminatedUnion<"type", MessageMap>;