轻小说查询页

文档编号 09

09 - 轻小说查询页面

背景

为展示 usr/novels.db(wenku8 元数据,4193 条记录)新增一个独立查询页面,支持多条件 AND 组合查询,结果以列表/详情卡片切换展示。


架构

novel-search.php   (Typecho 模板,纯前端)
       │  fetch() 请求
       ▼
novel-api.php      (JSON API,项目根目录)
       │  PDO SQLite
       ▼
usr/novels.db

novel-search.php 中 <img src="/novel-cover.php?id=xxx">
       │
       ▼
novel-cover.php    (封面图代理 + 缓存,项目根目录)
       │  命中 usr/covers/{id}.jpg → 直接输出
       │  未命中 → 下载 wenku8.com → 写缓存 → 输出
       ▼
usr/covers/        (本地封面缓存目录,约 4000 张,58MB)

新增 / 修改文件

文件说明
novel-search.php(主题目录)Typecho 自定义页面模板,含搜索表单 + CSS + JS
novel-api.php(根目录)查询 API:参数校验 → SQL 构建 → 分页 → JSON 响应
novel-cover.php(根目录)封面图代理:缓存命中直接服务;未命中限流下载并缓存
novel-rating-api.php(根目录)评分 DB-only 读取接口(2026-05-19 改造)
novel-rating-override-api.php(根目录)评分人工校准接口(2026-05-07 新增;2026-06-10 改为 editor + CSRF)
novel-intro-api.php(根目录)简介读取/编辑保存接口:GET 公开读取生效简介,POST 仅 editor + CSRF 保存到 novel_ai_summary
novel-ai-summary-api.php(根目录)AI 总结启动接口:仅编辑权限可用,下载 wenku8 全本 TXT 后后台启动 opencode,并写入启动提示
usr/themes/classic-22/inc/api-helpers.php自定义 JSON API 公共 helper:响应、POST 校验、editor 权限、CSRF、SQLite 打开
usr/themes/classic-22/inc/wenku8-txt.phpwenku8 TXT 链接解析、下载、编码转换共享模块,被 EPUB 转换与 AI 总结启动复用
usr/themes/classic-22/static/css/pages/novel-search.css轻小说查询页外置 CSS
usr/themes/classic-22/static/js/pages/novel-search.js轻小说查询页外置 JS
usr/covers/(目录)封面图本地缓存,已加入 .gitignore
.gitignore追加 usr/covers/*!usr/covers/.gitkeep
.maintenance/README.md维护原则第 1 条措辞修正

关键实现说明

查询 API(novel-api.php)

  • 书名/作者:LIKE %keyword% 模糊匹配
  • 最低评分:rating=0..10 整数过滤,语义为 score >= rating;选择 0 时仅返回非负评分(排除 score=-1 的低置信记录)
  • 连载状态:status=0|10 表示连载中,1 表示已完结
  • 动画化状态:animation=0|11 表示动画化,0 表示未动画化
  • AI 总结状态:ai_summary=0|11 表示存在非空 novel_ai_summary.intro0 表示无记录或 AI 总结为空白;本地/部署库缺少 novel_ai_summary 表时,ai_summary=1 返回空结果,ai_summary=0 等价于不额外限制
  • 标签:每个标签一个 EXISTS (SELECT 1 FROM novel_tags) 子查询,全部 AND
  • 分页:先 COUNT(*) 取总数,再 LIMIT/OFFSET 取当页数据,每页 20 条;排序优先级为:可信评分(score>0,按分数倒序)→ score=0(人工无收录/黑名单)→ score=-1(评分人数过少,低置信)→ 未评分 NULL;同级按 bookid DESC
  • 响应字段:列表查询只返回卡片渲染所需基础字段,不返回 intro / intro_html / ai_summary / ai_summary_html;简介和 AI 总结只在进入详情页后通过 novel-intro-api.php 懒加载
  • 安全:所有参数 PDO 预处理绑定;标签白名单过滤;评分仅接受 0-10 整数;连载、动画化和 AI 总结状态仅接受 0/1;简介 Markdown 渲染与 HTML 白名单清理集中在 novel-intro-api.php

封面图代理(novel-cover.php)

  • bookid 强制转 int,防路径穿越
  • 令牌桶限流:/tmp/novel_cover_rl.json 文件锁,最多 2 req/s 对外请求
  • 命中缓存:默认小图和成功的大图使用 Cache-Control: immutable, max-age=31536000size=l 降级到小图时使用 max-age=300
  • 下载失败:返回 404,前端显示 📚 占位符
  • 下载逻辑抽取到共享模块 usr/themes/classic-22/inc/cover-cache.phpdownloadCoverFromWenku8() / fetchAndCacheCover()),被 EPUB 转换器(10 号文档)复用
  • Bangumi HTTP 通信统一由 usr/themes/classic-22/inc/bangumi-client.phpbangumiRequest() 处理(支持 BANGUMI_API_BASE_URLS 多 API base fallback;HTTP/1.1 + Connection: close,规避 Cloudflare 持久连接挂起;统一 User-Agent、Bearer token、状态码解析),被 cover-cache.phpnovel-rating-override-api.php 共享复用;普通评分读取接口 novel-rating-api.php 已改为 DB-only,不再请求 Bangumi
  • novel-cover.php 会先加载 config.inc.php,确保 BANGUMI_API_BASE_URLS / BANGUMI_IMAGE_HOST_MAP / NOVELS_DB_PATH 等配置对封面代理生效
  • Bangumi 图片 URL 通过 bangumiRewriteImageUrl()BANGUMI_IMAGE_HOST_MAP 改写 host,保留 path/query;大图下载会基于图片 path 尝试多个镜像候选(当前包含 lain.bangumi.onebgmimg.anibt.net),并优先使用 PHP cURL,失败再回退 stream;详情页大图下载 Referer 使用 BANGUMI_WEB_BASE_URL,评分徽章和人工校准提示中的条目页链接也使用该 Web base
  • 大图有效性默认要求 ≥ 300×420(可通过 COVER_LARGE_MIN_WIDTH / COVER_LARGE_MIN_HEIGHT 调整);历史 {aid}_l.jpg 或 Bangumi images.large 实际低于阈值时不会作为大图长期缓存,而是写入 {aid}_l.miss.json 负缓存(默认 7 天,可通过 COVER_LARGE_MISS_TTL 调整),避免后续反复请求 Bangumi
  • 封面 loading shimmer 颜色使用 color-mix(in srgb, var(--pico-color) 18%, transparent),亮深主题均可见(修复前为固定 rgba(255,255,255,.3),亮色模式不可见)

评分徽章(novel-rating-api.php,2026-05-06 新增;2026-05-19 改为 DB-only)

仅在详情大卡片内展示;列表不展示,避免额外接口请求。

  • 数据来源novel-rating-api.php 只读取 usr/novels.dbnovels.score / novels.bangumi_id,不再实时请求 Bangumi,不负责写库;评分与 bangumi_id 由离线批量任务或人工校准接口维护
  • 接口参数GET /novel-rating-api.php?bookid=<int>,不再传 title
  • 返回来源source=db 表示 DB 已有 scoresource=db-miss 表示该书存在但 score IS NULLsource=not-found 表示 DB 中无此 bookid

    scorebangumi_id含义徽章渲染
    NULL任意离线批量尚未处理接口返回 score=0, source=db-miss,不渲染徽章
    > 0> 0有可信评分和 Bangumi 条目<a> 可点击徽章,新标签页打开 BANGUMI_WEB_BASE_URL/subject/{id}
    > 0NULL有评分但无条目 ID<span> 纯文本徽章(不跳转)
    0NULL无 Bangumi / 无评分 / 黑名单不渲染徽章
    -1任意评分人数过少,低置信不渲染徽章
  • 防御性列自检:后端通过 PRAGMA table_info(novels) 校验 score / bangumi_id 两列;缺列时返回 rating schema missing,不在公开 GET 读请求中执行 ALTER TABLE
  • 跳转 URL:由 BANGUMI_WEB_BASE_URL 配置生成,默认 https://bgm.tv/subject/{id};可切换为镜像站如 https://bangumi.one/subject/{id}target="_blank" rel="noopener noreferrer"
  • 前端样式.rating-badge 胶囊形琥珀色(#eab308 + color-mix),深色模式文字色切为 #facc15<a> 形态需 color: ... !important 绕过 pico.css 对 <a>--pico-color 重写(见 E11)
  • 手动修正:应通过 novel-rating-override-api.php 的 editor 校准入口处理;直接 SQL 只作为维护兜底,例如 UPDATE novels SET score = 7.8, bangumi_id = 12345 WHERE bookid = ?;

评分人工校准(novel-rating-override-api.php,2026-05-07 新增)

离线批量或历史匹配可能出现错条目(同名、系列、改编书)。此机制给管理员一个兜底入口;校准 bangumi_id 时仍实时请求 Bangumi 条目详情,以保证写入的 score 来自 Bangumi 当前数据。

  • 触发入口:详情卡片的"连载状态徽章"(连载中/已完结)。仅在 editor 及以上权限用户访问时绑定点击事件;未授权用户点击无反应。
  • 权限与 token 注入novel-search.php 顶部(这是 E01 的受控例外,仅取权限布尔值和 Typecho 安全 token,不拉取业务数据)注入 window.__novelCanEditwindow.__novelCanAiSummarywindow.__novelWriteToken
  • 交互prompt() 输入 Bangumi 条目 ID(从 BANGUMI_WEB_BASE_URL/subject/{ID} URL 末尾数字获取),预填当前 bangumi_id;提交后成功就地刷新评分徽章 + Toast,失败 Toast 展示错误原因。
  • 接口 POST /novel-rating-override-api.php

    • require config.inc.php 再加载共享 helper / 封面缓存模块,随后 \Widget\Init::alloc() 初始化 Typecho(设置 Cookie prefix),后端硬校验 editor 权限,未登录 401,非 editor 403
    • 参数:bookid(正整数)+ bangumi_id(非负整数)+ _CUSTOM_API_WRITE_CSRF_SUFFIX=novel-write 对应 token)
  • 两条语义分支

    输入 bangumi_id行为DB 结果用途
    0不请 Bangumi,直接写库score=0.0, bangumi_id=NULL人工黑名单:标记 Bangumi 没收录或始终匹配不到的书
    >0通过 bangumiRequest('/v0/subjects/{id}') 请求 Bangumi subject API,按 BANGUMI_API_BASE_URLS fallback,校验 type===1 && rating.score>0score=<真实分>, bangumi_id=<输入>校正错匹配或手动补 id,并同步抓取大图
  • 不接受前端传入 score:score 必须从 Bangumi 实时取,避免人工填假数据。
  • 错误码:401 未登录 / 400 参数错或条目非书籍或无评分 / 404 bookid 不存在 / 502 Bangumi 失败 / 500 DB 错。
  • 抽取函数 renderRatingBadge(score, bangumiId):供 loadRating(首次加载)与校准成功回调共用,处理三态渲染(无/仅分/可跳转)。

前端(novel-search.php)

  • 模板顶部只读取权限布尔值与写操作 CSRF token(E01 的受控例外);业务数据仍全部由 JS fetch 获取
  • 页面 CSS/JS 已拆到 static/css/pages/novel-search.cssstatic/js/pages/novel-search.js,模板仅保留权限/config 注入、HTML 骨架和资源引用
  • 标签选择器:5 分类 × N 标签,展开/收起,选中计数,JS 动态生成 DOM
  • 三段式状态筛选:连载状态、动画化状态、AI 总结状态均复用 .status-toggle / .status-opt;按钮切换逻辑限制在当前控件组内,避免多个筛选项互相清空选中态
  • 查询 loading:150ms 延迟显示(避免快速响应的闪烁),使用 pico.css aria-busy
  • 封面 loading:CSS shimmer 动画;onload 移除;onerror 显示占位符
  • 列表 → 详情:基础数据缓存于 novelCache Map,简介和 AI 总结不随列表结果返回;进入详情后异步请求 /novel-intro-api.php?bookid={id} 懒加载
  • 详情 → 列表:保存 listState.paramStr,返回时重新 fetch 当页数据
  • 分页:smart range(总页数 > 7 时显示省略号)
  • 简介:默认完整展示,无展开/收起按钮(2026-05-06 移除);详情页先显示“简介加载中…”,GET 接口返回后使用 intro_html 渲染 novels.intro,不提供编辑入口
  • AI 总结:简介下方展示独立“AI总结”区块,先显示“AI总结加载中…”,GET 接口返回后使用 ai_summary_html 渲染 novel_ai_summary.intro;前端以顶层 h1 标题将总结拆成分页,底部用轻量 上一卷 | 卷数选择 | 下一卷 控件切换;未识别到多页时保持完整展示;该区块使用浅灰色卡片、统一细边框、圆角和独立 .ai-summary-body 样式,Markdown 标题在区块内降级显示;editor 及以上用户显示弱化 ghost 图标编辑按钮和 AI 按钮,可新建/修改 AI 总结或启动服务器侧 AI 总结任务
  • AI 总结标题区的编辑 / AI 启动按钮使用透明 PNG 作为 CSS mask,由 currentColor 着色;浅色、深色与 hover 状态均跟随 Pico 主题变量,无需额外维护反色 PNG
  • 评分徽章:详情视图异步拉取 /novel-rating-api.php?bookid={id};有 bangumi_id 渲染为可点击 <a>(新标签页打开 BANGUMI_WEB_BASE_URL 对应 Bangumi 条目),仅有 score 的历史数据渲染为纯文本 <span>,无分值保持隐藏;普通读取不再触发 Bangumi 实时请求

简介与 AI 总结(novel-intro-api.php,2026-05-26 新增;2026-05-26 改为分离展示)

  • 职责划分novels.intro 是原始简介,固定展示为“简介”,不支持编辑;novel_ai_summary.intro 是独立 AI 总结,展示在简介下方的“AI总结”区块。
  • 读取接口GET /novel-intro-api.php?bookid=<int> 不要求登录,返回 intro / intro_html 以及 ai_summary / ai_summary_html / ai_summary_source
  • 简介来源intro / intro_html 始终来自 novels.intro,不会被 novel_ai_summary 覆盖。
  • AI 总结来源ai_summary / ai_summary_html 仅来自非空 novel_ai_summary.intro;没有记录或 TRIM(novel_ai_summary.intro) = '' 时返回空字符串,并标记 ai_summary_source=empty
  • 列表与详情novel-api.php 列表查询不返回 intro / intro_html / ai_summary / ai_summary_html;进入详情页后调用 GET 接口懒加载详情长文本。
  • Markdown:简介和 AI 总结都使用 Typecho 内置 \Utils\Markdown::convert() 渲染;渲染结果在后端经过白名单清理,仅保留段落、粗斜体、代码、引用、列表、标题、链接等基础标签。
  • 链接安全:仅允许 httphttps、相对路径和站内锚点;外链统一补充 target="_blank" rel="noopener noreferrer"
  • 权限:前端仅对 editor 及以上用户显示 AI 总结编辑按钮,且无论 AI 总结为空或非空都显示;POST 保存接口后端再次校验 Typecho editor 权限,未登录返回 401,非 editor 返回 403。
  • 视觉结构:AI 总结正文包裹在 .ai-summary-box 中,使用浅灰色背景、统一细边框和圆角;编辑按钮使用 .ai-summary-edit-btn ghost icon 样式;.ai-summary-body h1/h2/h3 单独降级,避免 AI 总结 Markdown 标题压过详情页主标题。
  • 保存接口POST /novel-intro-api.php,参数为 bookidintro_;这里的 intro 参数表示 AI 总结文本,最大 100000 字符。非空文本 upsert 到 novel_ai_summary;空字符串删除对应 AI 总结记录;保存后返回与 GET 一致的简介和 AI 总结结构。
  • 失败处理:保存失败保留编辑态,避免输入内容丢失,并通过 Toast 展示错误。

AI 总结启动(novel-ai-summary-api.php,2026-06-06 新增)

  • 入口:详情页“AI总结”标题区的 AI 按钮;仅 editor 及以上权限用户显示,后端也会再次校验权限。
  • 交互:点击后弹出对话框,先选择 AI 总结模型,再输入 wenku8 全本 TXT 下载链接;前端先校验 HTTPS、dl.wenku8.com/down.phpid 和编码参数,并要求链接中的 id 与当前详情页 bookid 一致。
  • 模型列表:弹窗打开时请求 action=models,后端通过 opencode models 获取可用模型并缓存 24 小时到 AI_SUMMARY_CACHE_DIRAI_SUMMARY_OPENCODE_MODEL 若存在于列表中则默认选中,否则默认选中第一项。模型列表加载失败时启动按钮保持禁用。
  • 安全:启动前先请求 action=token 获取 Typecho 安全 token;action=start 必须携带 token,后端使用 hash_equals() 校验,防止跨站诱导触发服务器命令。
  • 下载:后端复用 usr/themes/classic-22/inc/wenku8-txt.php 解析和下载 TXT,仅允许全本链接;分卷链接用于 EPUB 转换,AI 总结启动不接受分卷链接。
  • 缓存:TXT 写入 {TYPECHO_ROOT}/usr/cache/ai_summary_cache/(可通过 config.inc.php 配置),文件名来自数据库中的小说标题并经过安全清理和长度限制;写入时先写临时文件再重命名,避免半文件被读取。
  • 启动:TXT 下载完成并写入缓存后,再检查 novel_ai_summary 表、opencode 配置和可执行文件,然后后台启动,命令语义为 opencode run -m <model> "/novel-summary <txt文件路径> <bookid>"<model> 优先使用 action=startmodel 参数,未传时回退到 config.inc.php 中的 AI_SUMMARY_OPENCODE_MODEL。接口只确认任务已发起,不等待最终总结完成;若后续检查失败,TXT 仍会保留在缓存目录,便于排查。
  • 临时提示:成功发起任务后 upsert novel_ai_summary.introY-m-d H:i:s 启动AI总结,详情页随后刷新 AI 总结区,让访客能看到任务已启动。
  • 并发控制:同一本书短时间内重复启动会被 lock 拦截,避免多个任务互相覆盖;启动失败会清理 lock。

详情页大图封面(2026-05-07 新增)

  • 列表视图仍使用 /novel-cover.php?id={aid}(wenku8 200px 小图,沿用原缓存 {aid}.jpg
  • 详情视图改用 /novel-cover.php?id={aid}&size=l;有 bangumi_id 时优先使用 Bangumi 350×500 级别大图并缓存为 {aid}_l.jpg,无 bangumi_id 或 Bangumi 只有小尺寸图片时降级为 wenku8 小图
  • 大图缓存写入路径:点击状态徽章校准 bangumi_id 成功后,novel-rating-override-api.php 同步调用 fetchAndCacheCoverLarge() 抓取 api.bgm.tv/v0/subjects/{bid}images.large 并落盘 usr/covers/{aid}_l.jpg
  • 校准清零分支(bangumi_id=0)会 @unlink() 本地大图并清理 {aid}_l.miss.json,避免陈旧图片与"无评分"语义冲突
  • 校准成功后前端用 &v=<Date.now()> 查询串强制刷新 <img>,绕过浏览器缓存
  • bangumi_id 但本地没有有效 {aid}_l.jpg 且没有新鲜 miss 时,novel-cover.php?size=l 会请求 Bangumi 大图并写入本地缓存;大图请求失败或尺寸过小时透明降级为小图输出 HTTP 200,前端无需额外分支
  • 约束:EPUB 转换(10 号文档)仅读取本地 {aid}_l.jpg触发 Bangumi API 抓取;因此 Bangumi 镜像配置只影响详情页校准/抓图阶段,EPUB 侧通过已缓存大图间接受益

竞态规避(2026-05-07 补充)

普通评分读取已改为 DB-only,不再与封面请求竞态写入 bangumi_id 或抓图:

  • novel-rating-api.php 只读 DB,不写评分、不抓大图、不返回 source=live
  • novel-cover.phpsize=l 决策顺序:查 DB bangumi_id → 有 ID 则有效本地大图 / 新鲜 miss / 远程补齐 → 无 ID 则直接小图
  • size=l 降级为小图时使用 max-age=300:无 bangumi_id 时方便后续人工补 ID 后重新请求,有 bangumi_id 但大图临时失败或已写 miss 时避免锁死这次失败;本地有效大图命中使用长期缓存
  • 只有人工校准 bangumi_id 成功时,novel-rating-override-api.php 会写入 score / bangumi_id 并调用 fetchAndCacheCoverLarge();前端随后用 &v=<Date.now()> 刷新详情大图

Bangumi 镜像 / 反代配置(2026-06-01)

相关常量放在 config.inc.php,该文件不入 Git。示例:

define('BANGUMI_API_BASE_URLS', [
    'https://api.bangumi.one',
    'https://bgmapi.anibt.net',
    'https://api.bgm.tv',
]);
define('BANGUMI_WEB_BASE_URL', 'https://bangumi.one');
define('BANGUMI_IMAGE_HOST_MAP', [
    'lain.bgm.tv' => 'lain.bangumi.one',
    'fast.bgm.tv' => 'fast.bangumi.one',
]);
  • BANGUMI_API_BASE_URLSbangumiRequest() 按顺序尝试,失败后自动 fallback 到下一项
  • BANGUMI_WEB_BASE_URL:评分徽章跳转和人工校准 prompt 示例地址
  • BANGUMI_IMAGE_HOST_MAP:将 Bangumi API 返回的图片 URL host 改写到镜像图片域名
  • 第三方 API 反代会接收 Bangumi token;仅在信任反代提供方时配置到优先级靠前的位置

AI 总结启动配置(2026-06-06)

相关常量放在 config.inc.php,该文件不入 Git。示例:

define('AI_SUMMARY_OPENCODE_MODEL', 'your-model-name');
define('AI_SUMMARY_CACHE_DIR', __TYPECHO_ROOT_DIR__ . '/usr/cache/ai_summary_cache/');
define('AI_SUMMARY_OPENCODE_BIN', 'opencode');
  • AI_SUMMARY_OPENCODE_MODEL:opencode 默认模型名;弹窗模型列表中存在该模型时默认选中,旧调用未传 model 时也会回退使用
  • AI_SUMMARY_CACHE_DIR:TXT 缓存目录,建议位于 {TYPECHO_ROOT}/usr/cache/
  • AI_SUMMARY_OPENCODE_BIN:opencode 可执行文件;默认使用 opencode
  • 可选配置:AI_SUMMARY_OPENCODE_CWD(opencode 工作目录)、AI_SUMMARY_TASK_LOCK_TTL(同书重复启动锁定时间)

使用方式

在 Typecho 后台新建独立页面:

  • 模板:选"轻小说查询"
  • Slug:建议 novels
  • 父页面:无(独立出现在导航栏),或设为某个父页面的子页面

注意事项

  1. usr/covers/ 首次访问时按需下载,冷启动时封面加载较慢属正常

    • 清理历史假大图缓存可在服务器执行一次:php -r '$dir=__DIR__."/usr/covers/"; foreach (glob($dir."*_l.jpg") ?: [] as $f) { $i=@getimagesize($f); if (!$i || $i[0] < 300 || $i[1] < 420) { echo "delete {$f} ".($i ? "{$i[0]}x{$i[1]}" : "invalid").PHP_EOL; @unlink($f); } }'
  2. usr/novels.db 由 wenku8-novel-store 项目维护,不由本站代码管理
  3. AI 总结编辑依赖手动维护的 novel_ai_summary 表;站点代码不自动建表,建表 SQL:CREATE TABLE IF NOT EXISTS novel_ai_summary (bookid INTEGER PRIMARY KEY, intro TEXT NOT NULL);
  4. 评分筛选中的 0 表示所有非负评分记录,包含人工校准写入的 score=0.0(Bangumi 无收录/黑名单),但不包含 score=-1(评分人数过少,低置信)
  5. AI 总结启动依赖服务器可执行 opencode,且 Web 进程需要对 AI_SUMMARY_CACHE_DIR 有写入权限;最终总结写库由 opencode 侧流程负责
  6. 根目录 PHP 文件(novel-api.phpnovel-cover.phpnovel-intro-api.phpnovel-ai-summary-api.php)遵循与 activity-api.php 相同的约定,Typecho 升级不会覆盖这些文件
  7. 写接口共用 usr/themes/classic-22/inc/api-helpers.php 中的 CUSTOM_API_WRITE_CSRF_SUFFIX;如果调整 suffix,需同时更新页面注入和后端校验