轻小说查询页
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.php | wenku8 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|1,0表示连载中,1表示已完结 - 动画化状态:
animation=0|1,1表示动画化,0表示未动画化 - AI 总结状态:
ai_summary=0|1,1表示存在非空novel_ai_summary.intro,0表示无记录或 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=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()处理(支持BANGUMI_API_BASE_URLS多 API base fallback;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 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.one、bgmimg.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或 Bangumiimages.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.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>可点击徽章,新标签页打开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.__novelCanEdit、window.__novelCanAiSummary、window.__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_URLSfallback,校验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.css与static/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显示占位符 - 列表 → 详情:基础数据缓存于
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;前端以顶层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()渲染;渲染结果在后端经过白名单清理,仅保留段落、粗斜体、代码、引用、列表、标题、链接等基础标签。 - 链接安全:仅允许
http、https、相对路径和站内锚点;外链统一补充target="_blank" rel="noopener noreferrer"。 - 权限:前端仅对 editor 及以上用户显示 AI 总结编辑按钮,且无论 AI 总结为空或非空都显示;POST 保存接口后端再次校验 Typecho editor 权限,未登录返回 401,非 editor 返回 403。
- 视觉结构: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 展示错误。
AI 总结启动(novel-ai-summary-api.php,2026-06-06 新增)
- 入口:详情页“AI总结”标题区的
AI按钮;仅editor及以上权限用户显示,后端也会再次校验权限。 - 交互:点击后弹出对话框,先选择 AI 总结模型,再输入 wenku8 全本 TXT 下载链接;前端先校验 HTTPS、
dl.wenku8.com、/down.php、id和编码参数,并要求链接中的id与当前详情页bookid一致。 - 模型列表:弹窗打开时请求
action=models,后端通过opencode models获取可用模型并缓存 24 小时到AI_SUMMARY_CACHE_DIR;AI_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=start的model参数,未传时回退到config.inc.php中的AI_SUMMARY_OPENCODE_MODEL。接口只确认任务已发起,不等待最终总结完成;若后续检查失败,TXT 仍会保留在缓存目录,便于排查。 - 临时提示:成功发起任务后 upsert
novel_ai_summary.intro为Y-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=livenovel-cover.php的size=l决策顺序:查 DBbangumi_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_URLS:bangumiRequest()按顺序尝试,失败后自动 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 - 父页面:无(独立出现在导航栏),或设为某个父页面的子页面
注意事项
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); } }'
- 清理历史假大图缓存可在服务器执行一次:
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(评分人数过少,低置信) - AI 总结启动依赖服务器可执行
opencode,且 Web 进程需要对AI_SUMMARY_CACHE_DIR有写入权限;最终总结写库由 opencode 侧流程负责 - 根目录 PHP 文件(
novel-api.php、novel-cover.php、novel-intro-api.php、novel-ai-summary-api.php)遵循与activity-api.php相同的约定,Typecho 升级不会覆盖这些文件 - 写接口共用
usr/themes/classic-22/inc/api-helpers.php中的CUSTOM_API_WRITE_CSRF_SUFFIX;如果调整 suffix,需同时更新页面注入和后端校验