From 952712054fac970a2dbdf9a8fbd3fffe8b81a9fe Mon Sep 17 00:00:00 2001 From: lifegpc Date: Mon, 16 Feb 2026 14:50:41 +0800 Subject: [PATCH] Add support to display book info and save book info to databases --- src/db/indexedDb.ts | 5 ++- src/db/interfaces.ts | 13 ++++--- src/db/pocketBase.ts | 59 ++++++++++++++++++++++++++++--- src/models/QdBookInfo.tsx | 67 ++++++++++++++++++++++++++++++++++++ src/models/QdChatperInfo.tsx | 1 - src/popup.tsx | 8 +++-- src/qdbook.ts | 65 +++++++++++++++++++++++++++++++++- src/qdtypes.ts | 9 ++++- src/types.ts | 6 ++-- 9 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 src/models/QdBookInfo.tsx diff --git a/src/db/indexedDb.ts b/src/db/indexedDb.ts index fa4ac4d..99e4936 100644 --- a/src/db/indexedDb.ts +++ b/src/db/indexedDb.ts @@ -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(); } diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index 5a82541..590638c 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -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; @@ -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; + /** + * 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; close(): void; } @@ -18,11 +23,9 @@ export async function createDb(): Promise { 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'); } diff --git a/src/db/pocketBase.ts b/src/db/pocketBase.ts index 88983c1..ee35761 100644 --- a/src/db/pocketBase.ts +++ b/src/db/pocketBase.ts @@ -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[], 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 { + 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}"`, diff --git a/src/models/QdBookInfo.tsx b/src/models/QdBookInfo.tsx new file mode 100644 index 0000000..ee19426 --- /dev/null +++ b/src/models/QdBookInfo.tsx @@ -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 ( + + + + + + + + + + {info.bookName} + + + {info.id} + + + {info.tags.map(tag => tag.name).join(', ')} + + + {info.intro.split('\n').map((line, index) => ( + <>{line}
+ ))} +
+
+ +
+
+ + + + + ({ + key: volume.id, + label: volume.name, + extra: volume.isVip ? VIP卷 : null, + children: ( + {volume.chapters.map(chapter => ( + { + const url = `https://www.qidian.com/chapter/${info.id}/${chapter.id}`; + chrome.tabs.create({ url }); + }} style={{ cursor: 'pointer' }}>{chapter.name} + ))} + ) + })) + } /> + +
+ ) +} diff --git a/src/models/QdChatperInfo.tsx b/src/models/QdChatperInfo.tsx index 796ef8d..0de0bbc 100644 --- a/src/models/QdChatperInfo.tsx +++ b/src/models/QdChatperInfo.tsx @@ -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'; diff --git a/src/popup.tsx b/src/popup.tsx index 91583e2..5986ef4 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -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(null); @@ -70,7 +71,10 @@ function PopupBody() { return ; } if (result.ok && result.body?.type === 'QdBookInfo') { - return ; + const body: QdBookInfo = result.body; + /**@ts-ignore*/ + delete body.type; + return ; } return ; } diff --git a/src/qdbook.ts b/src/qdbook.ts index 9f6c05b..66c7266 100644 --- a/src/qdbook.ts +++ b/src/qdbook.ts @@ -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, }; diff --git a/src/qdtypes.ts b/src/qdtypes.ts index a48e9e6..e87c193 100644 --- a/src/qdtypes.ts +++ b/src/qdtypes.ts @@ -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, diff --git a/src/types.ts b/src/types.ts index 9b98680..6be91f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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>;