轻小说查询页
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=31536000;size=l降级过渡态使用max-age=300(见竞态规避) - 下载失败:返回 404,前端显示 📚 占位符
- 下载逻辑抽取到共享模块
usr/themes/classic-22/inc/cover-cache.php(downloadCoverFromWenku8()/fetchAndCacheCover()),被 EPUB 转换器(10 号文档)复用 - Bangumi HTTP 通信统一由
usr/themes/classic-22/inc/bangumi-client.php的bangumiRequest()处理(HTTP/1.1 +Connection: close,规避 Cloudflare 持久连接挂起;统一User-Agent、Bearer token、状态码解析),被cover-cache.php、novel-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.db的novels.score/novels.bangumi_id,不再实时请求 Bangumi,不负责写库;评分与bangumi_id由离线批量任务或人工校准接口维护 - 接口参数:
GET /novel-rating-api.php?bookid=<int>,不再传title 返回来源:
source=db表示 DB 已有score;source=db-miss表示该书存在但score IS NULL;source=not-found表示 DB 中无此bookidscorebangumi_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)同时校验并补齐两列 - 跳转 URL:
https://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显示占位符 - 列表 → 详情:基础数据缓存于
novelCacheMap,简介和 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()渲染;渲染结果在后端经过白名单清理,仅保留段落、粗斜体、代码、引用、列表、标题、链接等基础标签。 - 链接安全:仅允许
http、https、相对路径和站内锚点;外链统一补充target="_blank" rel="noopener noreferrer"。 - 权限:前端仅对已登录用户显示 AI 总结编辑按钮,且无论 AI 总结为空或非空都显示;POST 保存接口后端再次校验 Typecho 登录态,未登录返回 401。
- 视觉结构:AI 总结正文包裹在
.ai-summary-box中,使用浅色背景、细边框、左侧强调线和圆角;编辑按钮使用.ai-summary-edit-btnghost icon 样式;.ai-summary-body h1/h2/h3单独降级,避免 AI 总结 Markdown 标题压过详情页主标题。 - 保存接口:
POST /novel-intro-api.php,参数为bookid和intro;这里的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=livenovel-cover.php的size=l仍按本地大图缓存 → DBbangumi_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 - 父页面:无(独立出现在导航栏),或设为某个父页面的子页面
注意事项
usr/covers/首次访问时按需下载,冷启动时封面加载较慢属正常usr/novels.db由 wenku8-novel-store 项目维护,不由本站代码管理- AI 总结编辑依赖手动维护的
novel_ai_summary表;站点代码不自动建表,建表 SQL:CREATE TABLE IF NOT EXISTS novel_ai_summary (bookid INTEGER PRIMARY KEY, intro TEXT NOT NULL); - 评分筛选中的
0表示所有非负评分记录,包含人工校准写入的score=0.0(Bangumi 无收录/黑名单),但不包含score=-1(评分人数过少,低置信) - 根目录 PHP 文件(
novel-api.php、novel-cover.php、novel-intro-api.php)遵循与activity-api.php相同的约定,Typecho 升级不会覆盖这些文件