BaseParser> = {
+ quanben: QuanbenParser,
+ newsite: NewsiteParser, // 添加新解析器
+};
+```
+
+### 步骤 4: 编写测试
+
+在 `src/configs/parsers/__tests__/` 和 `src/configs/__tests__/` 中添加测试用例。
+
+## URL 模板说明
+
+### 书籍列表 URL 模板
+
+使用 `{url}` 作为占位符,例如:
+
+- `"{url}/list.html"` → `https://site.com/book/list.html`
+- `"{url}/chapters"` → `https://site.com/book/chapters`
+
+### 文章 URL 模板
+
+使用 `{url}` 和 `{number}` 作为占位符,例如:
+
+- `"{url}/{number}.html"` → `https://site.com/book/1.html`
+- `"{url}/chapter/{number}"` → `https://site.com/book/chapter/1`
+
+**注意**:系统会自动处理 URL 末尾的斜杠,避免出现双斜杠问题。
+
+## 章节号提取
+
+如果网站的章节号需要从特定属性提取,可以使用 `lastChapterNumber` 配置:
+
+```typescript
+lastChapterNumber: {
+ selector: ".chapter-list li:last-child a",
+ extract: /chapter-(\d+)/, // 正则表达式,捕获组1为章节号
+ attribute: "href", // 从 href 属性提取
+}
+```
+
+- `selector`: 选择器定位元素
+- `extract`: 可选,正则表达式提取章节号
+- `attribute`: 可选,从哪个属性提取("href" | "textContent" | "innerHTML"),默认为 textContent
+
+## 错误处理
+
+解析器在以下情况会抛出错误:
+
+- 找不到必需的元素(书籍信息或文章内容)
+- URL 格式不正确
+- 网站不支持
+
+API 端点会捕获这些错误并返回适当的 HTTP 状态码和错误消息。
+
+## 测试
+
+运行测试:
+
+```bash
+pnpm test:ci src/configs
+```
+
+## 示例
+
+### 使用解析器工厂
+
+```typescript
+import { getParser, getSupportedSites } from "@/configs";
+
+// 获取支持的网站列表
+const sites = getSupportedSites();
+console.log(sites); // [{ id: "quanben", name: "全本小说", domain: "quanben.io" }]
+
+// 获取解析器
+const url = "https://quanben.io/n/yishilingwutianxia/";
+const parser = getParser(url);
+
+if (parser) {
+ // 构建书籍列表URL
+ const listUrl = parser.buildBookListUrl(url);
+
+ // 构建文章URL
+ const articleUrl = parser.buildArticleUrl(url, 1);
+
+ // 解析HTML
+ const bookInfo = parser.parseBookInfo(html, url);
+ const content = parser.parseArticle(html, 1);
+}
+```
+
+## 注意事项
+
+1. **URL 规范化**:系统会自动处理 URL 末尾的斜杠,但建议在配置中使用一致的格式
+2. **选择器稳定性**:选择器应该尽可能稳定,避免因网站更新而失效
+3. **错误处理**:解析器应该对缺失元素进行适当的错误处理
+4. **类型安全**:所有配置和解析器都使用 TypeScript 类型定义,确保类型安全
diff --git a/src/configs/__tests__/parser-factory.test.ts b/src/configs/__tests__/parser-factory.test.ts
new file mode 100644
index 0000000..abdcf14
--- /dev/null
+++ b/src/configs/__tests__/parser-factory.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest";
+import { getParser, identifySite, getSupportedSites } from "../index";
+import { QuanbenParser } from "../parsers/quanben";
+
+describe("Parser Factory", () => {
+ describe("identifySite", () => {
+ it("should identify quanben.io site correctly", () => {
+ const url = "https://quanben.io/n/yishilingwutianxia/";
+ const config = identifySite(url);
+ expect(config).not.toBeNull();
+ expect(config?.id).toBe("quanben");
+ expect(config?.domain).toBe("quanben.io");
+ });
+
+ it("should identify quanben.io with www prefix", () => {
+ const url = "https://www.quanben.io/n/yishilingwutianxia/";
+ const config = identifySite(url);
+ expect(config).not.toBeNull();
+ expect(config?.id).toBe("quanben");
+ });
+
+ it("should return null for unsupported site", () => {
+ const url = "https://example.com/book/123";
+ const config = identifySite(url);
+ expect(config).toBeNull();
+ });
+
+ it("should handle URLs without trailing slash", () => {
+ const url = "https://quanben.io/n/yishilingwutianxia";
+ const config = identifySite(url);
+ expect(config).not.toBeNull();
+ expect(config?.id).toBe("quanben");
+ });
+ });
+
+ describe("getParser", () => {
+ it("should return QuanbenParser for quanben.io URL", () => {
+ const url = "https://quanben.io/n/yishilingwutianxia/";
+ const parser = getParser(url);
+ expect(parser).not.toBeNull();
+ expect(parser).toBeInstanceOf(QuanbenParser);
+ });
+
+ it("should return null for unsupported site", () => {
+ const url = "https://example.com/book/123";
+ const parser = getParser(url);
+ expect(parser).toBeNull();
+ });
+
+ it("should build correct book list URL", () => {
+ const url = "https://quanben.io/n/yishilingwutianxia/";
+ const parser = getParser(url);
+ expect(parser).not.toBeNull();
+ if (parser) {
+ const listUrl = parser.buildBookListUrl(url);
+ expect(listUrl).toBe("https://quanben.io/n/yishilingwutianxia/list.html");
+ }
+ });
+
+ it("should build correct article URL", () => {
+ const url = "https://quanben.io/n/yishilingwutianxia/";
+ const parser = getParser(url);
+ expect(parser).not.toBeNull();
+ if (parser) {
+ const articleUrl = parser.buildArticleUrl(url, 1);
+ expect(articleUrl).toBe("https://quanben.io/n/yishilingwutianxia/1.html");
+ }
+ });
+ });
+
+ describe("getSupportedSites", () => {
+ it("should return list of supported sites", () => {
+ const sites = getSupportedSites();
+ expect(sites).toBeInstanceOf(Array);
+ expect(sites.length).toBeGreaterThan(0);
+ expect(sites[0]).toHaveProperty("id");
+ expect(sites[0]).toHaveProperty("name");
+ expect(sites[0]).toHaveProperty("domain");
+ });
+
+ it("should include quanben in supported sites", () => {
+ const sites = getSupportedSites();
+ const quanben = sites.find((site) => site.id === "quanben");
+ expect(quanben).toBeDefined();
+ expect(quanben?.name).toBe("全本小说");
+ expect(quanben?.domain).toBe("quanben.io");
+ });
+ });
+});
+
diff --git a/src/configs/__tests__/site-identification.test.ts b/src/configs/__tests__/site-identification.test.ts
new file mode 100644
index 0000000..4e0ac99
--- /dev/null
+++ b/src/configs/__tests__/site-identification.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect } from "vitest";
+import { identifySite } from "../index";
+
+describe("Site Identification", () => {
+ describe("quanben.io", () => {
+ it("should identify quanben.io URLs", () => {
+ const testUrls = [
+ "https://quanben.io/n/yishilingwutianxia/",
+ "https://www.quanben.io/n/yishilingwutianxia/",
+ "http://quanben.io/n/xuezhonghandaoxing/",
+ "https://quanben.io/n/chongshengbiannuwangnaxiatezhongduizhang",
+ ];
+
+ testUrls.forEach((url) => {
+ const config = identifySite(url);
+ expect(config).not.toBeNull();
+ expect(config?.id).toBe("quanben");
+ expect(config?.domain).toBe("quanben.io");
+ });
+ });
+
+ it("should not identify invalid quanben.io URLs", () => {
+ const invalidUrls = [
+ "https://quanben.io/",
+ "https://quanben.io/n/",
+ "https://quanben.io/other/path",
+ "https://fake-quanben.io/n/test/",
+ ];
+
+ invalidUrls.forEach((url) => {
+ const config = identifySite(url);
+ // Some might match, but we're testing edge cases
+ if (config) {
+ // If it matches, it should still be quanben
+ expect(config.id).toBe("quanben");
+ }
+ });
+ });
+ });
+
+ describe("unsupported sites", () => {
+ it("should return null for unsupported sites", () => {
+ const unsupportedUrls = [
+ "https://example.com/book/123",
+ "https://other-novel-site.com/novel/456",
+ "https://not-a-book-site.com",
+ ];
+
+ unsupportedUrls.forEach((url) => {
+ const config = identifySite(url);
+ expect(config).toBeNull();
+ });
+ });
+ });
+
+ describe("URL normalization", () => {
+ it("should handle URLs with and without trailing slashes", () => {
+ const url1 = "https://quanben.io/n/yishilingwutianxia/";
+ const url2 = "https://quanben.io/n/yishilingwutianxia";
+
+ const config1 = identifySite(url1);
+ const config2 = identifySite(url2);
+
+ expect(config1).not.toBeNull();
+ expect(config2).not.toBeNull();
+ expect(config1?.id).toBe(config2?.id);
+ });
+ });
+});
+
diff --git a/src/configs/index.ts b/src/configs/index.ts
new file mode 100644
index 0000000..77d2438
--- /dev/null
+++ b/src/configs/index.ts
@@ -0,0 +1,76 @@
+/**
+ * 配置系统主入口
+ * 提供解析器工厂和网站识别功能
+ */
+import { SiteConfig } from "./types";
+import { BaseParser } from "./parsers/base";
+import { QuanbenParser } from "./parsers/quanben";
+import { quanbenConfig } from "./sites/quanben.config";
+
+// 网站配置注册表
+const siteConfigs: SiteConfig[] = [quanbenConfig];
+
+// 解析器映射
+const parserMap: Record<
+ string,
+ new (config: SiteConfig) => BaseParser
+> = {
+ quanben: QuanbenParser,
+};
+
+/**
+ * 根据URL识别网站
+ * @param url 书籍URL
+ * @returns 匹配的网站配置,如果没有匹配则返回 null
+ */
+export function identifySite(url: string): SiteConfig | null {
+ // 直接使用原始URL进行匹配,因为URL模式已经考虑了各种格式
+ for (const config of siteConfigs) {
+ if (config.urlPattern.test(url)) {
+ return config;
+ }
+ }
+ return null;
+}
+
+/**
+ * 获取解析器实例
+ * @param url 书籍URL
+ * @returns 解析器实例,如果网站不支持则返回 null
+ */
+export function getParser(url: string): BaseParser | null {
+ const config = identifySite(url);
+ if (!config) {
+ return null;
+ }
+
+ const ParserClass = parserMap[config.id];
+ if (!ParserClass) {
+ throw new Error(`No parser found for site: ${config.id}`);
+ }
+
+ return new ParserClass(config);
+}
+
+/**
+ * 获取所有支持的网站列表
+ * @returns 支持的网站信息数组
+ */
+export function getSupportedSites(): Array<{
+ id: string;
+ name: string;
+ domain: string;
+}> {
+ return siteConfigs.map((config) => ({
+ id: config.id,
+ name: config.name,
+ domain: config.domain,
+ }));
+}
+
+// 导出工具函数
+export { normalizeUrl, validateBookUrl } from "./utils";
+
+// 导出类型
+export type { SiteConfig, BookInfoConfig, ArticleConfig } from "./types";
+
diff --git a/src/configs/parsers/__tests__/quanben.test.ts b/src/configs/parsers/__tests__/quanben.test.ts
new file mode 100644
index 0000000..2670021
--- /dev/null
+++ b/src/configs/parsers/__tests__/quanben.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { QuanbenParser } from "../quanben";
+import { quanbenConfig } from "../../sites/quanben.config";
+
+// Mock HTML content for testing
+const mockBookInfoHtml = `
+
+
+
Test Book
+
+
+
异世灵武天下
+

+
+
+
+
+
+
+`;
+
+const mockArticleHtml = `
+
+
+
Chapter 1
+
+
+
+
+`;
+
+describe("QuanbenParser", () => {
+ let parser: QuanbenParser;
+
+ beforeEach(() => {
+ parser = new QuanbenParser(quanbenConfig);
+ });
+
+ describe("parseBookInfo", () => {
+ it("should parse book info correctly", () => {
+ const baseUrl = "https://quanben.io/n/yishilingwutianxia/";
+ const bookInfo = parser.parseBookInfo(mockBookInfoHtml, baseUrl);
+
+ expect(bookInfo.title).toBe("异世灵武天下");
+ expect(bookInfo.img).toBe("https://example.com/book.jpg");
+ expect(bookInfo.description).toBe("这是一本测试书籍的描述内容");
+ expect(bookInfo.url).toBe(baseUrl);
+ expect(bookInfo.currentChapter).toBe("1");
+ });
+
+ it("should extract last chapter number correctly", () => {
+ const baseUrl = "https://quanben.io/n/yishilingwutianxia/";
+ const bookInfo = parser.parseBookInfo(mockBookInfoHtml, baseUrl);
+
+ // The last chapter link should contain "200" in the href
+ expect(bookInfo.lastChapterNumber).toBeTruthy();
+ });
+
+ it("should throw error when required elements are missing", () => {
+ const invalidHtml = "";
+ const baseUrl = "https://quanben.io/n/test/";
+
+ expect(() => {
+ parser.parseBookInfo(invalidHtml, baseUrl);
+ }).toThrow();
+ });
+ });
+
+ describe("parseArticle", () => {
+ it("should parse article content correctly", () => {
+ const content = parser.parseArticle(mockArticleHtml, 1);
+ expect(content).toContain("第一章");
+ expect(content).toContain("这是第一章的内容");
+ });
+
+ it("should throw error when content selector not found", () => {
+ const invalidHtml =
+ "
No main content
";
+
+ expect(() => {
+ parser.parseArticle(invalidHtml, 1);
+ }).toThrow();
+ });
+ });
+
+ describe("buildBookListUrl", () => {
+ it("should build correct book list URL", () => {
+ const baseUrl = "https://quanben.io/n/yishilingwutianxia/";
+ const listUrl = parser.buildBookListUrl(baseUrl);
+ expect(listUrl).toBe("https://quanben.io/n/yishilingwutianxia/list.html");
+ });
+ });
+
+ describe("buildArticleUrl", () => {
+ it("should build correct article URL", () => {
+ const baseUrl = "https://quanben.io/n/yishilingwutianxia/";
+ const articleUrl = parser.buildArticleUrl(baseUrl, 1);
+ expect(articleUrl).toBe("https://quanben.io/n/yishilingwutianxia/1.html");
+ });
+
+ it("should handle different chapter numbers", () => {
+ const baseUrl = "https://quanben.io/n/yishilingwutianxia/";
+ const articleUrl = parser.buildArticleUrl(baseUrl, 100);
+ expect(articleUrl).toBe(
+ "https://quanben.io/n/yishilingwutianxia/100.html"
+ );
+ });
+ });
+});
diff --git a/src/configs/parsers/base.ts b/src/configs/parsers/base.ts
new file mode 100644
index 0000000..d5e8fb5
--- /dev/null
+++ b/src/configs/parsers/base.ts
@@ -0,0 +1,126 @@
+/**
+ * 基础解析器抽象类
+ * 所有网站解析器都应继承此类
+ */
+import { JSDOM } from "jsdom";
+import { BookProps } from "@/types/book";
+import { SiteConfig } from "../types";
+
+export abstract class BaseParser {
+ protected config: SiteConfig;
+ protected dom: JSDOM | null = null;
+
+ constructor(config: SiteConfig) {
+ this.config = config;
+ }
+
+ /**
+ * 获取网站配置
+ */
+ getConfig(): SiteConfig {
+ return this.config;
+ }
+
+ /**
+ * 构建书籍列表URL
+ */
+ buildBookListUrl(baseUrl: string): string {
+ // 规范化URL,移除末尾斜杠以避免双斜杠
+ const normalizedUrl = baseUrl.endsWith("/")
+ ? baseUrl.slice(0, -1)
+ : baseUrl;
+ return this.config.bookInfo.listUrl.replace("{url}", normalizedUrl);
+ }
+
+ /**
+ * 构建文章URL
+ */
+ buildArticleUrl(baseUrl: string, chapterNumber: number): string {
+ // 规范化URL,移除末尾斜杠以避免双斜杠
+ const normalizedUrl = baseUrl.endsWith("/")
+ ? baseUrl.slice(0, -1)
+ : baseUrl;
+ return this.config.article.urlTemplate
+ .replace("{url}", normalizedUrl)
+ .replace("{number}", chapterNumber.toString());
+ }
+
+ /**
+ * 解析书籍信息
+ * @param html HTML内容
+ * @param baseUrl 书籍基础URL
+ * @returns 书籍信息
+ */
+ abstract parseBookInfo(html: string, baseUrl: string): BookProps;
+
+ /**
+ * 解析文章内容
+ * @param html HTML内容
+ * @param chapterNumber 章节号(可选,某些解析器可能需要)
+ * @returns 文章HTML内容
+ */
+ abstract parseArticle(html: string, chapterNumber?: number): string;
+
+ /**
+ * 通用DOM查询辅助方法
+ */
+ protected querySelector(selector: string): Element | null {
+ if (!this.dom) return null;
+ return this.dom.window.document.querySelector(selector);
+ }
+
+ /**
+ * 通用DOM查询辅助方法(多个元素)
+ */
+ protected querySelectorAll(selector: string): NodeListOf
{
+ if (!this.dom) {
+ return [] as unknown as NodeListOf;
+ }
+ return this.dom.window.document.querySelectorAll(selector);
+ }
+
+ /**
+ * 获取元素的文本内容
+ */
+ protected getTextContent(selector: string): string {
+ const element = this.querySelector(selector);
+ return element?.textContent?.trim() || "";
+ }
+
+ /**
+ * 获取元素的属性值
+ */
+ protected getAttribute(selector: string, attribute: string): string {
+ const element = this.querySelector(selector);
+ if (element instanceof HTMLElement) {
+ return element.getAttribute(attribute) || "";
+ }
+ return "";
+ }
+
+ /**
+ * 提取章节号
+ */
+ protected extractChapterNumber(
+ element: Element | null,
+ config?: { extract?: RegExp; attribute?: string }
+ ): string {
+ if (!element) return "";
+
+ let text = "";
+ if (config?.attribute === "href") {
+ text = (element as HTMLAnchorElement).href || "";
+ } else if (config?.attribute === "innerHTML") {
+ text = element.innerHTML || "";
+ } else {
+ text = element.textContent || "";
+ }
+
+ if (config?.extract) {
+ const match = text.match(config.extract);
+ return match ? match[1] || match[0] : "";
+ }
+
+ return text;
+ }
+}
diff --git a/src/configs/parsers/quanben.ts b/src/configs/parsers/quanben.ts
new file mode 100644
index 0000000..43b7f9d
--- /dev/null
+++ b/src/configs/parsers/quanben.ts
@@ -0,0 +1,73 @@
+/**
+ * quanben.io 解析器实现
+ */
+import { BaseParser } from "./base";
+import { BookProps } from "@/types/book";
+import { JSDOM } from "jsdom";
+
+export class QuanbenParser extends BaseParser {
+ parseBookInfo(html: string, baseUrl: string): BookProps {
+ this.dom = new JSDOM(html);
+ const body = this.dom.window.document.body;
+
+ // 使用配置的选择器提取书籍信息
+ const title = body.querySelector(this.config.bookInfo.selectors.title);
+ const img = body.querySelector(
+ this.config.bookInfo.selectors.img
+ ) as HTMLImageElement;
+ const description = body.querySelector(
+ this.config.bookInfo.selectors.description
+ );
+
+ // 获取最后一章元素
+ const lastChapterElements = body.querySelectorAll(
+ this.config.bookInfo.selectors.lastChapter
+ );
+ const lastChapter = lastChapterElements[1] as HTMLAnchorElement;
+
+ if (!title || !img || !description || !lastChapter) {
+ throw new Error(
+ "Failed to find required elements using the specified selectors."
+ );
+ }
+
+ // 提取章节号
+ let lastChapterNumber = "";
+ if (this.config.bookInfo.selectors.lastChapterNumber) {
+ const config = this.config.bookInfo.selectors.lastChapterNumber;
+ lastChapterNumber = this.extractChapterNumber(lastChapter, config);
+ } else {
+ // 如果没有配置,使用默认逻辑
+ const lastChapterUrl = lastChapter.href || lastChapter.toString();
+ const chapterNumberMatch = lastChapterUrl.match(/(\d+)/);
+ lastChapterNumber = chapterNumberMatch ? chapterNumberMatch[0] : "";
+ }
+
+ return {
+ title: title.textContent?.trim() || "",
+ img: img.src || "",
+ description: description.textContent?.trim() || "",
+ lastChapterNumber,
+ url: baseUrl,
+ currentChapter: "1",
+ };
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ parseArticle(html: string, _chapterNumber?: number): string {
+ this.dom = new JSDOM(html);
+ const document = this.dom.window.document;
+
+ const content = document.querySelector(
+ this.config.article.selectors.content
+ );
+
+ if (!content) {
+ throw new Error(
+ "Failed to find article content using the specified selector."
+ );
+ }
+
+ return content.innerHTML || "";
+ }
+}
diff --git a/src/configs/sites/quanben.config.ts b/src/configs/sites/quanben.config.ts
new file mode 100644
index 0000000..d0a9e6a
--- /dev/null
+++ b/src/configs/sites/quanben.config.ts
@@ -0,0 +1,53 @@
+/**
+ * quanben.io 网站配置
+ */
+import { SiteConfig } from "../types";
+
+export const quanbenConfig: SiteConfig = {
+ id: "quanben",
+ name: "全本小说",
+ domain: "quanben.io",
+ urlPattern: /^https?:\/\/(www\.)?quanben\.io\/n\/[^/]+\/?$/,
+
+ bookInfo: {
+ listUrl: "{url}/list.html",
+ selectors: {
+ title: ".list2 h3 span",
+ img: ".list2 img",
+ description: ".description p",
+ lastChapter: ".list3 li:last-child a",
+ lastChapterNumber: {
+ selector: ".list3 li:last-child a",
+ extract: /(\d+)/,
+ attribute: "href",
+ },
+ },
+ },
+
+ article: {
+ urlTemplate: "{url}/{number}.html",
+ selectors: {
+ content: ".main",
+ },
+ },
+
+ request: {
+ headers: {
+ "User-Agent":
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ Accept:
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Cache-Control": "no-cache",
+ Pragma: "no-cache",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ timeout: 15000,
+ },
+};
+
diff --git a/src/configs/types.ts b/src/configs/types.ts
new file mode 100644
index 0000000..f033888
--- /dev/null
+++ b/src/configs/types.ts
@@ -0,0 +1,87 @@
+/**
+ * 网站解析配置类型定义
+ */
+
+/**
+ * 章节号提取配置
+ */
+export interface ChapterNumberExtract {
+ selector: string; // 选择器
+ extract?: RegExp; // 提取章节号的正则表达式
+ attribute?: "href" | "textContent" | "innerHTML"; // 从哪个属性提取,默认为 textContent
+}
+
+/**
+ * 书籍信息选择器配置
+ */
+export interface BookInfoSelectors {
+ title: string; // 标题选择器
+ img: string; // 封面图选择器
+ description: string; // 描述选择器
+ lastChapter: string; // 最后一章选择器
+ lastChapterNumber?: ChapterNumberExtract; // 章节号提取配置
+}
+
+/**
+ * 内容清理配置
+ */
+export interface ContentCleanup {
+ removeSelectors?: string[]; // 需要移除的元素选择器
+ keepAttributes?: string[]; // 需要保留的HTML属性
+}
+
+/**
+ * 文章内容选择器配置
+ */
+export interface ArticleSelectors {
+ content: string; // 内容选择器
+ title?: string; // 可选:文章标题选择器
+ nextChapter?: string; // 可选:下一章链接选择器
+ prevChapter?: string; // 可选:上一章链接选择器
+}
+
+/**
+ * HTTP请求配置
+ */
+export interface RequestConfig {
+ headers?: Record; // 请求头
+ timeout?: number; // 超时时间(毫秒)
+}
+
+/**
+ * 书籍信息页面配置
+ */
+export interface BookInfoConfig {
+ listUrl: string; // 章节列表URL模板,使用 {url} 作为占位符
+ selectors: BookInfoSelectors; // 选择器配置
+}
+
+/**
+ * 文章内容页面配置
+ */
+export interface ArticleConfig {
+ urlTemplate: string; // 文章URL模板,使用 {url} 和 {number} 作为占位符
+ selectors: ArticleSelectors; // 选择器配置
+ cleanup?: ContentCleanup; // 内容清理配置
+}
+
+/**
+ * 网站配置接口
+ */
+export interface SiteConfig {
+ // 网站标识
+ id: string; // 唯一标识符,如 'quanben'
+ name: string; // 网站名称,如 '全本小说'
+ domain: string; // 域名,如 'quanben.io'
+ urlPattern: RegExp; // URL 匹配模式
+
+ // 书籍信息页面配置
+ bookInfo: BookInfoConfig;
+
+ // 文章内容页面配置
+ article: ArticleConfig;
+
+ // HTTP 请求配置
+ request?: RequestConfig;
+}
+
diff --git a/src/configs/utils.ts b/src/configs/utils.ts
new file mode 100644
index 0000000..c33a98d
--- /dev/null
+++ b/src/configs/utils.ts
@@ -0,0 +1,36 @@
+/**
+ * 配置系统工具函数
+ */
+
+/**
+ * 规范化URL格式
+ * 确保URL以 / 结尾(如果需要)
+ */
+export function normalizeUrl(url: string): string {
+ try {
+ const urlObj = new URL(url);
+ // 移除末尾的斜杠(除了根路径)
+ if (urlObj.pathname !== "/" && urlObj.pathname.endsWith("/")) {
+ urlObj.pathname = urlObj.pathname.slice(0, -1);
+ }
+ return urlObj.toString();
+ } catch {
+ // 如果不是有效URL,返回原字符串
+ return url;
+ }
+}
+
+/**
+ * 验证书籍URL格式
+ * 检查URL是否符合基本格式要求
+ */
+export function validateBookUrl(url: string): boolean {
+ try {
+ const urlObj = new URL(url);
+ // 基本验证:必须是 http 或 https
+ return urlObj.protocol === "http:" || urlObj.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
diff --git a/src/lib/modelManager.ts b/src/lib/modelManager.ts
index 6283195..d8d2fe0 100644
--- a/src/lib/modelManager.ts
+++ b/src/lib/modelManager.ts
@@ -3,13 +3,18 @@ import { OpenAI } from "openai";
class Client extends OpenAI {
private static instance: Client;
- private constructor() {
+ // 允许直接使用 new Client(apiKey) 创建实例
+ constructor(apiKey?: string) {
super({
- apiKey: process.env.OPENAI_API_KEY,
+ apiKey: apiKey || process.env.OPENAI_API_KEY,
});
}
- public static getInstance(): Client {
+ public static getInstance(apiKey?: string): Client {
+ // 如果提供了 API Key,创建新实例;否则使用单例
+ if (apiKey) {
+ return new Client(apiKey);
+ }
if (!Client.instance) {
Client.instance = new Client();
}
@@ -19,12 +24,12 @@ class Client extends OpenAI {
public async createChatCompletion(
options: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming
): Promise {
- const response = await Client.instance.chat.completions.create(options);
+ const response = await this.chat.completions.create(options);
return response.choices[0].message.content || "";
}
public streamChatCompletion() {
- return Client.instance.beta.chat.completions.stream;
+ return this.beta.chat.completions.stream;
}
}
diff --git a/src/pages/aireading.tsx b/src/pages/aireading.tsx
index d64d990..8f291c1 100644
--- a/src/pages/aireading.tsx
+++ b/src/pages/aireading.tsx
@@ -70,11 +70,18 @@ export default function AiReadingPage({
setContent("");
setError(null);
- // 创建 EventSource 实例
+ // 从 localStorage 获取 API Key
+ const apiKey = storage.get("apiKey", "");
+
+ // 创建 EventSource 实例,传递 API Key
const eventSource = new EventSource(
- `/api/aiReader?number=${number}&url=${url}`
+ `/api/aiReader?number=${number}&url=${encodeURIComponent(url)}${
+ apiKey ? `&apiKey=${encodeURIComponent(apiKey)}` : ""
+ }`
);
+ let hasReceivedError = false; // 标记是否已收到服务端错误事件
+
eventSource.onopen = () => {
if (process.env.NODE_ENV !== "production") {
console.log("EventSource连接建立时间:", formatTime(Date.now()));
@@ -100,6 +107,7 @@ export default function AiReadingPage({
// 处理错误事件(来自服务端的 error 事件)
eventSource.addEventListener("error", (event: Event) => {
+ hasReceivedError = true; // 标记已收到服务端错误
const messageEvent = event as MessageEvent;
try {
const errorData = JSON.parse(messageEvent.data);
@@ -115,8 +123,8 @@ export default function AiReadingPage({
// 处理连接错误
eventSource.onerror = () => {
// onerror 会在连接失败时触发,此时可能还没有收到 error 事件
- // 如果已经有错误信息就不覆盖
- if (!error) {
+ // 如果已经收到服务端错误事件,就不覆盖
+ if (!hasReceivedError) {
setError("连接失败,请检查网络或 API 配置");
}
eventSource.close();
@@ -180,7 +188,6 @@ export default function AiReadingPage({
eventSource.close();
}
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [number, url]);
useEffect(() => {
@@ -190,8 +197,7 @@ export default function AiReadingPage({
eventSource.close();
}
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentPage]);
+ }, [currentPage, url]);
return (
diff --git a/src/pages/api/aiReader.ts b/src/pages/api/aiReader.ts
index d7fca51..f17c665 100644
--- a/src/pages/api/aiReader.ts
+++ b/src/pages/api/aiReader.ts
@@ -18,39 +18,91 @@ export default async function handler(
req: NextApiRequest,
res: CustomResponse
) {
- const { number, url } = req.query;
+ const { number, url: urlParam, apiKey: clientApiKey } = req.query;
- // 检查 API Key
- const apiKey = process.env.OPENAI_API_KEY;
- if (!apiKey) {
+ // 确保 url 是字符串类型
+ const url = typeof urlParam === "string" ? urlParam : Array.isArray(urlParam) ? urlParam[0] : "";
+
+ // 验证必需参数
+ if (!url || !number) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.write(
`event: error\ndata: ${JSON.stringify({
- error: "API Key 未配置,请在设置页面配置 API Key",
+ error: "缺少必需参数:url 或 number",
})}\n\n`
);
res.end();
return;
}
+
+ // 获取 API Key:生产环境必须使用客户端提供的,开发环境优先使用客户端的,否则使用环境变量
+ const isProduction = process.env.NODE_ENV === "production";
+ let apiKey: string | undefined;
+
+ if (isProduction) {
+ // 生产环境:必须使用客户端提供的 API Key
+ apiKey = typeof clientApiKey === "string" ? clientApiKey : undefined;
+ if (!apiKey) {
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+ res.write(
+ `event: error\ndata: ${JSON.stringify({
+ error: "API Key 未配置,请在设置页面配置 API Key",
+ })}\n\n`
+ );
+ res.end();
+ return;
+ }
+ } else {
+ // 开发环境:优先使用客户端提供的,否则使用环境变量
+ apiKey = (typeof clientApiKey === "string" ? clientApiKey : undefined) || process.env.OPENAI_API_KEY;
+ if (!apiKey) {
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+ res.write(
+ `event: error\ndata: ${JSON.stringify({
+ error: "API Key 未配置,请在设置页面配置 API Key 或在环境变量中设置 OPENAI_API_KEY",
+ })}\n\n`
+ );
+ res.end();
+ return;
+ }
+ }
try {
// 在服务端发起请求,因为没有当前页这个概念,所以不能使用相对路径来发起请求
const host = req.headers.host || "localhost:3000";
const protocol = req.headers["x-forwarded-proto"] || "http";
- const fetchURL = `${protocol}://${host}/api/fetchArticle?number=${number}&url=${url}`;
+ // url 从查询参数获取,Next.js 会自动解码,所以需要重新编码
+ const encodedUrl = encodeURIComponent(url);
+ const chapterNumber = typeof number === "string" ? number : Array.isArray(number) ? number[0] : String(number);
+ const fetchURL = `${protocol}://${host}/api/fetchArticle?number=${chapterNumber}&url=${encodedUrl}`;
const response = await fetch(fetchURL);
if (!response.ok) {
console.error("Fetch error:", response.status, response.statusText);
+ // 尝试解析错误信息
+ let errorMessage = "获取文章内容失败";
+ try {
+ const errorData = await response.json();
+ if (errorData.error) {
+ errorMessage = errorData.error;
+ }
+ } catch {
+ // 如果无法解析错误信息,使用默认消息
+ }
+
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.write(
`event: error\ndata: ${JSON.stringify({
- error: "获取文章内容失败",
+ error: errorMessage,
})}\n\n`
);
res.end();
@@ -58,6 +110,20 @@ export default async function handler(
}
const article = await response.json();
+
+ // 检查文章内容是否存在
+ if (!article || !article.content) {
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+ res.write(
+ `event: error\ndata: ${JSON.stringify({
+ error: article?.error || "文章内容为空",
+ })}\n\n`
+ );
+ res.end();
+ return;
+ }
const processedArticle = stripHtmlTags(
removeWhitespaceAndNewlines(article.content)
diff --git a/src/pages/api/bookInfo.ts b/src/pages/api/bookInfo.ts
index 5b1140e..69af198 100644
--- a/src/pages/api/bookInfo.ts
+++ b/src/pages/api/bookInfo.ts
@@ -1,7 +1,7 @@
-// pages/api/fetchArticle.ts
+// pages/api/bookInfo.ts
import type { NextApiRequest, NextApiResponse } from "next";
import axios from "axios";
-import { JSDOM } from "jsdom";
+import { getParser, getSupportedSites, validateBookUrl } from "@/configs";
import { BookProps } from "@/types/book";
type Data = BookProps & {
@@ -18,56 +18,74 @@ export default async function handler(
return res.status(400).json({ error: "Invalid book url" });
}
+ // 验证URL格式
+ if (!validateBookUrl(url)) {
+ return res.status(400).json({ error: "Invalid URL format" });
+ }
+
try {
- // 使用Axios发起GET请求
- const response = await axios.get(`${url}/list.html`, {
- headers: {
- "User-Agent": "PostmanRuntime/7.43.0", // 设置合适的User-Agent
- },
- });
+ // 获取对应的解析器
+ const parser = getParser(url);
+ if (!parser) {
+ const supportedSites = getSupportedSites()
+ .map((site) => site.domain)
+ .join(", ");
+ return res.status(400).json({
+ error: `Unsupported website. Supported sites: ${supportedSites}`,
+ });
+ }
+
+ // 构建请求URL
+ const listUrl = parser.buildBookListUrl(url);
- // 解析HTML文档并提取文章内容
- const dom = new JSDOM(response.data);
- const body = dom.window.document.body;
+ // 发起请求
+ const config = parser.getConfig();
+ const response = await axios.get(listUrl, {
+ headers: config.request?.headers || {},
+ timeout: config.request?.timeout || 15000,
+ validateStatus: (status) => status < 500, // 允许 4xx 状态码,以便更好地处理错误
+ });
- const title = body.querySelector(".list2 h3 span");
- const img = body.querySelector(".list2 img") as HTMLImageElement;
- const description = body.querySelector(".description p");
- // 从章节列表获取最后一章的页数
- const lastChapter = body.querySelectorAll(
- ".list3 li:last-child a"
- )[1] as HTMLAnchorElement;
- const lastChapterUrl = lastChapter.toString();
- const chapterNumberMatch = lastChapterUrl.match(/(\d+)/);
- const lastChapterNumber = chapterNumberMatch ? chapterNumberMatch[0] : "";
+ // 检查响应状态
+ if (response.status === 403) {
+ return res.status(403).json({
+ error: "访问被拒绝,网站可能检测到自动化请求。请稍后重试。",
+ });
+ }
- if (!title || !img || !description || !lastChapterNumber) {
- throw new Error(
- "Failed to find article content using the specified selector."
- );
+ if (response.status !== 200) {
+ return res.status(response.status).json({
+ error: `请求失败,状态码: ${response.status}`,
+ });
}
- // 返回文章内容
- res.status(200).json({
- title: title?.textContent || "",
- img: img?.src || "",
- description: description?.textContent || "",
- lastChapterNumber: lastChapterNumber || "",
- url: url || "",
- currentChapter: "1",
- });
+ // 使用解析器解析书籍信息
+ const bookInfo = parser.parseBookInfo(response.data, url);
+
+ res.status(200).json(bookInfo);
} catch (error) {
console.error("Detailed error information:", error);
- let errorMessage = "Failed to fetch or parse the article.";
+ let errorMessage = "Failed to fetch or parse the book info.";
+ let statusCode = 500;
+
if (axios.isAxiosError(error)) {
// 如果是Axios错误,尝试获取更多信息
- errorMessage += ` Status: ${error.response?.status}, Message: ${error.message}`;
+ const status = error.response?.status;
+ if (status === 403) {
+ errorMessage = "访问被拒绝,网站可能检测到自动化请求。请稍后重试。";
+ statusCode = 403;
+ } else if (status === 404) {
+ errorMessage = "书籍不存在或链接无效。";
+ statusCode = 404;
+ } else {
+ errorMessage += ` Status: ${status}, Message: ${error.message}`;
+ }
} else {
// 对于其他类型的错误
errorMessage += ` Message: ${
error instanceof Error ? error.message : "Unknown error"
}`;
}
- res.status(500).json({ error: errorMessage });
+ res.status(statusCode).json({ error: errorMessage });
}
}
diff --git a/src/pages/api/fetchAiContent.ts b/src/pages/api/fetchAiContent.ts
index 2db7f6d..35dd38a 100644
--- a/src/pages/api/fetchAiContent.ts
+++ b/src/pages/api/fetchAiContent.ts
@@ -43,21 +43,36 @@ export default async function handler(
return res.status(405).json({ error: "Method Not Allowed" });
}
- const { prompt } = req.body;
+ const { prompt, apiKey: clientApiKey } = req.body;
if (!prompt || typeof prompt !== "string") {
return res.status(400).json({ error: "Invalid prompt" });
}
- // 检查 API Key
- const apiKey = process.env.OPENAI_API_KEY;
- if (!apiKey) {
- return res.status(400).json({
- error: "API Key 未配置,请在设置页面配置 API Key",
- });
+ // 获取 API Key:生产环境必须使用客户端提供的,开发环境优先使用客户端的,否则使用环境变量
+ const isProduction = process.env.NODE_ENV === "production";
+ let apiKey: string | undefined;
+
+ if (isProduction) {
+ // 生产环境:必须使用客户端提供的 API Key
+ apiKey = clientApiKey;
+ if (!apiKey) {
+ return res.status(400).json({
+ error: "API Key 未配置,请在设置页面配置 API Key",
+ });
+ }
+ } else {
+ // 开发环境:优先使用客户端提供的,否则使用环境变量
+ apiKey = clientApiKey || process.env.OPENAI_API_KEY;
+ if (!apiKey) {
+ return res.status(400).json({
+ error: "API Key 未配置,请在设置页面配置 API Key 或在环境变量中设置 OPENAI_API_KEY",
+ });
+ }
}
- const client = Client.getInstance();
+ // 创建使用指定 API Key 的客户端实例
+ const client = new Client(apiKey);
try {
const response = await client.createChatCompletion({
diff --git a/src/pages/api/fetchArticle.ts b/src/pages/api/fetchArticle.ts
index 060a65f..691c2a4 100644
--- a/src/pages/api/fetchArticle.ts
+++ b/src/pages/api/fetchArticle.ts
@@ -1,7 +1,7 @@
// pages/api/fetchArticle.ts
import type { NextApiRequest, NextApiResponse } from "next";
import axios from "axios";
-import { JSDOM } from "jsdom";
+import { getParser, getSupportedSites, validateBookUrl } from "@/configs";
type Data = {
content?: string;
@@ -14,45 +14,95 @@ export default async function handler(
) {
const { url, number } = req.query;
- if (!number || typeof number !== "string") {
- return res.status(400).json({ error: "Invalid article number" });
+ if (!number || typeof number !== "string" || !url || typeof url !== "string") {
+ return res.status(400).json({ error: "Invalid parameters" });
+ }
+
+ // 验证URL格式
+ if (!validateBookUrl(url)) {
+ return res.status(400).json({ error: "Invalid URL format" });
}
try {
- // 使用Axios发起GET请求
- const response = await axios.get(`${url}${number}.html`, {
- headers: {
- "User-Agent": "PostmanRuntime/7.43.0", // 设置合适的User-Agent
- },
+ // 获取对应的解析器
+ const parser = getParser(url);
+ if (!parser) {
+ const supportedSites = getSupportedSites()
+ .map((site) => site.domain)
+ .join(", ");
+ return res.status(400).json({
+ error: `Unsupported website. Supported sites: ${supportedSites}`,
+ });
+ }
+
+ const chapterNumber = parseInt(number, 10);
+ if (isNaN(chapterNumber)) {
+ return res.status(400).json({ error: "Invalid chapter number" });
+ }
+
+ // 构建文章URL
+ const articleUrl = parser.buildArticleUrl(url, chapterNumber);
+
+ // 发起请求
+ const config = parser.getConfig();
+ const defaultHeaders = config.request?.headers || {};
+
+ // 添加 Referer 头,指向书籍列表页,使请求更像从网站内部跳转
+ const headers = {
+ ...defaultHeaders,
+ Referer: parser.buildBookListUrl(url),
+ };
+
+ const response = await axios.get(articleUrl, {
+ headers,
+ timeout: config.request?.timeout || 15000,
+ validateStatus: (status) => status < 500, // 允许 4xx 状态码,以便更好地处理错误
});
- // 解析HTML文档并提取文章内容
- const dom = new JSDOM(response.data);
- const document = dom.window.document;
+ // 检查响应状态
+ if (response.status === 403) {
+ return res.status(403).json({
+ error: "访问被拒绝,网站可能检测到自动化请求。请稍后重试。",
+ });
+ }
+
+ if (response.status !== 200) {
+ return res.status(response.status).json({
+ error: `请求失败,状态码: ${response.status}`,
+ });
+ }
- // 注意:这里的选择器需要根据实际网页结构调整
- const articleContent = document.querySelector(".main")?.innerHTML || "";
+ // 使用解析器解析文章内容
+ const content = parser.parseArticle(response.data, chapterNumber);
- if (!articleContent) {
- throw new Error(
- "Failed to find article content using the specified selector."
- );
+ if (!content) {
+ throw new Error("Failed to parse article content");
}
- // 返回文章内容
- res.status(200).json({ content: articleContent });
+ res.status(200).json({ content });
} catch (error) {
console.error("Detailed error information:", error);
let errorMessage = "Failed to fetch or parse the article.";
+ let statusCode = 500;
+
if (axios.isAxiosError(error)) {
// 如果是Axios错误,尝试获取更多信息
- errorMessage += ` Status: ${error.response?.status}, Message: ${error.message}`;
+ const status = error.response?.status;
+ if (status === 403) {
+ errorMessage = "访问被拒绝,网站可能检测到自动化请求。请稍后重试。";
+ statusCode = 403;
+ } else if (status === 404) {
+ errorMessage = "章节不存在或已被删除。";
+ statusCode = 404;
+ } else {
+ errorMessage += ` Status: ${status}, Message: ${error.message}`;
+ }
} else {
// 对于其他类型的错误
errorMessage += ` Message: ${
error instanceof Error ? error.message : "Unknown error"
}`;
}
- res.status(500).json({ error: errorMessage });
+ res.status(statusCode).json({ error: errorMessage });
}
}
diff --git a/src/pages/api/test/bookInfo.test.ts b/src/pages/api/test/bookInfo.test.ts
index c07d409..9772aec 100644
--- a/src/pages/api/test/bookInfo.test.ts
+++ b/src/pages/api/test/bookInfo.test.ts
@@ -5,7 +5,10 @@ import axios from "axios"; // Import axiosq
import { bookInfo } from "./res";
type AxiosMock = {
- mockResolvedValue: (value: { data: typeof bookInfo.resHtml }) => void;
+ mockResolvedValue: (value: {
+ data: typeof bookInfo.resHtml;
+ status?: number;
+ }) => void;
};
// Mock axios
@@ -30,6 +33,7 @@ describe("API Handler", () => {
// Mock the Axios GET request
(axios.get as unknown as AxiosMock).mockResolvedValue({
data: bookInfo.resHtml, // Mocked response data
+ status: 200, // Add status to mock response
});
await handler(req, res); // Call API handler
diff --git a/src/pages/article.tsx b/src/pages/article.tsx
index 4f02b30..12124be 100644
--- a/src/pages/article.tsx
+++ b/src/pages/article.tsx
@@ -39,8 +39,14 @@ function ArticlePage({ number, url }: ServerSideProps) {
const router = useRouter();
const [books, setBooks] = useState([]);
const [book, setBook] = useState(null);
+
+ // 监听路由查询参数变化,确保直接修改 URL 时能正确更新
+ const queryChapter = router.query.number ? Number(router.query.number) : null;
+ const currentChapter =
+ queryChapter && !isNaN(queryChapter) ? queryChapter : number;
+
const { currentPage, content, handleNextPage, handlePrevPage, isLoading } =
- usePagination(number, url);
+ usePagination(currentChapter, url);
const { textSize } = useSettings();
const [cleanedContent, setCleanContent] = useState("");
const [sanitizedContent, setSanitizedContent] = useState("");
diff --git a/src/pages/controlpanel.tsx b/src/pages/controlpanel.tsx
index 412110c..ecfa3e7 100644
--- a/src/pages/controlpanel.tsx
+++ b/src/pages/controlpanel.tsx
@@ -14,24 +14,24 @@ function ControlPanelPage() {
useLayoutEffect(() => {
const files = [
{
- name: "《比特币革命:数字货币时代的新机遇与挑战》",
- body: "本书深入探讨了比特币这一2008年由神秘人物或团体以中本聪之名发明的加密货币。书中不仅讲述了比特币的基本原理和技术基础,还分析了其对现代经济体系的影响以及未来发展的潜力。",
+ name: "《AI智能阅读助手》",
+ body: "利用先进的AI技术,将长篇小说内容进行智能摘要和提炼,帮助您快速理解故事情节,节省阅读时间。支持多种AI模型,可根据需求选择最适合的阅读模式。",
},
{
- name: "《财务管理精要:从基础到精通》",
- body: "这是一本详细介绍如何使用电子表格来整理、安排和计算财务数据的指南。通过实例和案例研究,读者可以学习到如何高效地利用电子表格进行数据分析,从而更好地做出财务决策。",
+ name: "《多网站书籍支持》",
+ body: "支持从多个小说网站添加书籍,通过统一的解析系统自动识别网站并提取内容。目前已支持全本小说等主流网站,未来将持续扩展更多网站支持。",
},
{
- name: "《SVG图形设计:动画与交互的艺术》",
- body: "本书专注于可缩放矢量图形(SVG),这是一种基于XML的二维图形格式,支持交互性和动画效果。书中讲解了如何创建、编辑和优化SVG图像,并展示了如何在网页设计和其他领域中应用这些技术。",
+ name: "《阅读时长监控》",
+ body: "智能追踪您的阅读时长,帮助您合理安排阅读时间。设置休息提醒,保护视力健康。记录您的阅读进度,随时了解自己的阅读习惯和效率。",
},
{
- name: "《GPG加密实战:保护你的数字世界》",
- body: "本书详细介绍了GPG密钥的使用方法,包括电子邮件、文件、目录甚至整个磁盘分区的加密和解密过程。此外,还讲解了如何使用GPG密钥验证消息的真实性,为个人隐私和数据安全提供坚实保障。",
+ name: "《个性化阅读体验》",
+ body: "支持自定义字体大小、主题切换(亮色/暗色模式),打造最适合您的阅读环境。所有设置本地保存,保护您的隐私和数据安全。",
},
{
- name: "《比特币种子短语完全指南》",
- body: "种子短语是恢复比特币资金链上所需的所有信息的词汇列表。这本书全面解析了种子短语的重要性、生成方法及其在比特币钱包管理和资金恢复中的应用。",
+ name: "《书柜管理功能》",
+ body: "轻松管理您的阅读书单,支持添加、删除、批量操作。自动保存阅读进度,下次打开时无缝续读。历史记录清晰,方便回顾和继续阅读。",
},
];
@@ -43,14 +43,15 @@ function ControlPanelPage() {
href: "",
cta: "继续阅读",
background: "",
- className: "lg:row-start-1 lg:row-end-4 lg:col-start-2 lg:col-end-3",
+ className: "lg:row-start-1 lg:row-end-3 lg:col-start-1 lg:col-end-2",
},
{
Icon: InputIcon,
- name: "更换新书",
- description: "当前书籍已经阅读完毕",
+ name: "管理书架",
+ description:
+ "轻松管理您的阅读书单,支持添加、删除、批量操作。自动保存阅读进度,下次打开时无缝续读。历史记录清晰,方便回顾和继续阅读。",
href: "/bookshelf",
- cta: "替换",
+ cta: "管理",
background: (