轻小说查询页

文档编号 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 新增,仅登录态)
novel-intro-api.php(根目录)简介读取/编辑保存接口:GET 公开读取生效简介,POST 仅登录态保存到 novel_ai_summary
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 的低置信记录)
  • 标签:每个标签一个 EXISTS (SELECT 1 FROM novel_tags) 子查询,全部 AND
  • 分页:先 COUNT(*) 取总数,再 LIMIT/OFFSET 取当页数据,每页 20 条;排序优先级为:可信评分(score>0,按分数倒序)→ score=0(人工无收录/黑名单)→ score=-1(评分人数过少,低置信)→ 未评分 NULL;同级按 bookid DESC
  • 响应字段:列表查询只返回卡片渲染所需基础字段,不返回 intro / intro_html;简介只在进入详情页后通过 novel-intro-api.php 懒加载
  • 安全:所有参数 PDO 预处理绑定;标签白名单过滤;评分仅接受 0-10 整数;简介 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() 处理(HTTP/1.1 + Connection: close,规避 Cloudflare 持久连接挂起;统一 User-Agent、Bearer token、状态码解析),被 cover-cache.phpnovel-rating-override-api.php 共享复用;普通评分读取接口 novel-rating-api.php 已改为 DB-only,不再请求 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> 可点击徽章,新标签页打开 https://bgm.tv/subject/{id}
    > 0NULL有评分但无条目 ID<span> 纯文本徽章(不跳转)
    0NULL无 Bangumi / 无评分 / 黑名单不渲染徽章
    -1任意评分人数过少,低置信不渲染徽章
  • 防御性列自检:后端 ensureRatingColumns() 通过 PRAGMA table_info(novels) 同时校验并补齐两列
  • 跳转 URLhttps://bgm.tv/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 的登录态校准入口处理;直接 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 当前数据。

  • 触发入口:详情卡片的"连载状态徽章"(连载中/已完结)。仅在 $this->user->hasLogin() 为真时绑定点击事件;未登录用户点击无反应。
  • 登录态注入novel-search.php 顶部(这是 E01 的受控例外,仅取布尔值,不拉取业务数据)<script>window.__novelCanEdit = true|false;</script>
  • 交互prompt() 输入 Bangumi 条目 ID(从 https://bgm.tv/subject/{ID} URL 末尾数字获取),预填当前 bangumi_id;提交后成功就地刷新评分徽章 + Toast,失败 Toast 展示错误原因。
  • 接口 POST /novel-rating-override-api.php

    • require config.inc.php\Widget\Init::alloc() 初始化 Typecho(设置 Cookie prefix),随后 \Widget\User::alloc()->hasLogin() 硬校验,未登录 401(安全边界在后端,前端仅优化交互)
    • 参数:bookid(正整数)+ bangumi_id(非负整数)
  • 两条语义分支

    输入 bangumi_id行为DB 结果用途
    0不请 Bangumi,直接写库score=0.0, bangumi_id=NULL人工黑名单:标记 Bangumi 没收录或始终匹配不到的书
    >0GET https://api.bgm.tv/v0/subjects/{id},校验 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)

  • 不调用任何 Widget::alloc()(遵循 E01 规则)
  • 标签选择器:5 分类 × N 标签,展开/收起,选中计数,JS 动态生成 DOM
  • 查询 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;该区块使用浅色卡片、左侧强调线和独立 .ai-summary-body 样式,Markdown 标题在区块内降级显示;已登录用户始终显示弱化 ghost 图标编辑按钮,可新建或修改 AI 总结
  • 评分徽章:详情视图异步拉取 /novel-rating-api.php?bookid={id};有 bangumi_id 渲染为可点击 <a>(新标签页打开 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"
  • 权限:前端仅对已登录用户显示 AI 总结编辑按钮,且无论 AI 总结为空或非空都显示;POST 保存接口后端再次校验 Typecho 登录态,未登录返回 401。
  • 视觉结构: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 展示错误。

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

  • 列表视图仍使用 /novel-cover.php?id={aid}(wenku8 200px 小图,沿用原缓存 {aid}.jpg
  • 详情视图改用 /novel-cover.php?id={aid}&size=l(bangumi 350×500 大图,缓存 {aid}_l.jpg
  • 大图缓存写入路径:点击状态徽章校准 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() 本地大图,避免陈旧图片与"无评分"语义冲突
  • 校准成功后前端用 &v=<Date.now()> 查询串强制刷新 <img>,绕过浏览器缓存
  • 大图命中失败时,novel-cover.php?size=l 透明降级为小图输出 HTTP 200;前端无需额外分支
  • 约束:EPUB 转换(10 号文档)仅读取本地 {aid}_l.jpg触发 Bangumi API 抓取

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

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

  • novel-rating-api.php 只读 DB,不写评分、不抓大图、不返回 source=live
  • novel-cover.phpsize=l 仍按本地大图缓存 → DB bangumi_id → Bangumi 大图抓取 → 小图降级的顺序处理
  • 大图降级到小图时仍按 DB 状态区分缓存时长:

    • score IS NULL(离线批量尚未处理,过渡态)→ max-age=300,避免锁死
    • score IS NOT NULL AND bangumi_id IS NULL(已知无 Bangumi 条目,最终态)→ immutable,不再重试
    • bangumi_id 非空但大图临时失败(过渡态)→ max-age=300
  • 只有人工校准 bangumi_id 成功时,novel-rating-override-api.php 会写入 score / bangumi_id 并调用 fetchAndCacheCoverLarge();前端随后用 &v=<Date.now()> 刷新详情大图

使用方式

在 Typecho 后台新建独立页面:

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

注意事项

  1. usr/covers/ 首次访问时按需下载,冷启动时封面加载较慢属正常
  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. 根目录 PHP 文件(novel-api.phpnovel-cover.phpnovel-intro-api.php)遵循与 activity-api.php 相同的约定,Typecho 升级不会覆盖这些文件