这次攒了一波改动,四件事都挺有代表性,所以单独写一篇日志。 不只是「改了什么」,也把过程中踩到的坑记下来 —— 其中有两个坑的表现极具迷惑性, 不写下来下次一定还会再踩一遍。
这次更新做了什么
| # | 改动 | 一句话说明 |
|---|---|---|
| 1 | 标签分页 | 左栏只显示 12 个标签,其余全在独立的 /tags/ 页面 |
| 2 | Umami 访问统计 | 侧栏新增「访问统计」卡片,两层能力可独立开关 |
| 3 | B 站集成 | 追番 + 最近投币 + 粉丝勋章 + 数字收藏集 + 文章内 ::bilibili{} 视频门面 |
| 4 | 文章文件夹方案 | 15 篇文章从平铺改成一文一文件夹,配图就近存放 |
另外顺手补了几个小东西:新建文章的脚手架改成生成文件夹结构、 加了配置覆盖层、给每个功能补了说明文档。
一、标签:左栏只留一部分
为什么改
文章多起来之后标签会涨到十几个甚至几十个。全部铺在左侧栏那张小卡片里有两个后果:
- 左栏被撑得比正文还长,首页首屏几乎全被标签占满;
- 想找某个标签时,在一堆按钮里翻反而更难找。
现在怎么分
| 位置 | 内容 |
|---|---|
| 左侧栏 | 只放最热的 12 个(带文章数),底部一个「查看全部标签 · 还有 N 个」入口 |
/tags/ | 完整列表,字号随文章数在 0.95rem ~ 1.35rem 之间缩放 |
/tags/ 页面是纯静态直出,没有任何客户端脚本:构建时把标签排好、
渲染成一组链接就完事。想按首字母分组也可以,改一个布尔值就行
(中文标签会全落到「其它」一组,所以默认关着)。
标签链接落在哪
这里做了一个刻意的选择:不为每个标签生成独立路由。
getTagUrl() 统一返回归档页的筛选参数:
export function getTagUrl(tag: string): string { if (!tag) return url("/archive/"); return url(`/archive/?tag=${encodeURIComponent(tag.trim())}`);}好处是「标签」只有一种落地方式 —— 不用为几十个标签各生成一个页面,
也不会出现「标签页和归档页筛选结果不一致」的问题。
这个函数是标签系统与路由之间唯一的接缝,哪天想改成 /tags/<标签>/,
只需要替换它一个。
配置
export const tagsConfig: TagsConfig = { sidebarLimit: 12, // 左栏显示上限;0 = 不限制(退回旧行为) sidebarSort: "count", // 左栏排序:count 按文章数 / name 按字母 pageSort: "count", // /tags/ 页面排序 pageGroupByLetter: false, // 是否按首字母分组 showCount: true, // 标签后面显示文章数 cloudSizing: true, // /tags/ 页面字号是否随热度缩放 description: "",};排序在文章数相同时都会退化成字母序,保证每次构建结果一致 —— 否则同样的内容可能生成顺序不同的 HTML,git diff 里全是噪声。
二、Umami 访问统计
Umami 是一个开源的、可自托管的网站分析服务。 侧栏多了一张「访问统计」卡片,显示浏览量 / 访客数 / 访问次数。
两个能力层,别混在一起
这是接入时最容易搞混的地方,两者的数据流向完全相反:
| 公开分享统计 | 访问采集 | |
|---|---|---|
| 方向 | 读:从 Umami 取数字显示给访客 | 写:把访客行为上报给 Umami |
| 需要填 | shareUrl | websiteId + scriptUrl |
| 不填的后果 | 卡片不存在 | 后台永远是 0,卡片上全是 0 |
| 隐私影响 | 无(只是显示一个数字) | 访客的访问会被记录 |
解析规则集中在 resolveUmamiOptions() 一个函数里,所有消费方共用同一个结论 ——
不会出现「脚本加载了但卡片没渲染」这种半开状态。
enable: true,shareUrl: "https://cloud.umami.is/share/<id>",websiteId: "<website-id>",scriptUrl: "https://cloud.umami.is/script.js",只填一个 websiteId 或只填 scriptUrl 不会加载采集脚本:
与其加载一个注定上报失败的脚本,不如干脆不加载。
只要公开分享统计的话,把后两行清空即可,页面上不会出现任何第三方脚本。
踩到的坑:cloud.umami.is 的区域路径
这个坑值得单独记一笔。
Umami 的分享链接是一套只读的公开接口,读数据分两步:
GET {apiBase}/share/{shareId} → { websiteId, token }GET {apiBase}/websites/{websiteId}/stats?... → { pageviews, visitors, visits }问题出在 apiBase 怎么算。官方文档里的链接长这样:
https://cloud.umami.is/analytics/us/share/<id> ^^^^^^^^^^^^^ 区域段但从控制台复制出来的实际是短链:
https://cloud.umami.is/share/<id>短链里没有区域段。按「把 /share/ 之前的路径保留、后面接 /api」这条规则推导,
会算出 https://cloud.umami.is/api —— 而这个地址返回 404:
GET https://cloud.umami.is/api/share/<id> → 404GET https://cloud.umami.is/analytics/us/api/share/<id> → 200 ✓症状极具迷惑性:分享链接在浏览器里能正常打开、能看到图表,站内卡片却永远是 --,
控制台也不报错(请求失败被静默收敛成 null 了)。
修法是不猜,把几种可能都列出来按顺序探测:
const candidates = [`${origin}${prefix}/api`];
// 短链没有区域段时,真正的接口在 /analytics/<region>/apiif (!prefix.includes("/analytics/")) { candidates.push( `${origin}${prefix}/analytics/us/api`, `${origin}${prefix}/analytics/eu/api`, );}第一个 404 就试下一个,命中即停。这样自部署、带区域段、短链四种形态都能用。
数字是怎么到页面上的
纯静态站点读第三方接口有个天然的矛盾:构建期抓的数据会过期,运行时抓的数据可能被挡住。
所以两条路都走,先到先得、后到的覆盖:
- 构建期 —— 在 Node 里抓一次,把数字直接写进 HTML。首屏就有真实数值,
访客端即使被 CORS 或网络问题挡住也不会是一片
--。请求 5 秒超时, 失败不影响构建;同一次构建里只真正请求一次(Promise 缓存), 不会每个页面都打一遍 Umami。 - 运行时 —— 浏览器里再拉一次覆盖,成功就换成带千分位的实时值, 失败就保留构建期那份。
两端共用同一份接口代码。这不是为了少写几行,而是因为「接口地址怎么推导、 返回的 JSON 怎么解析」如果各写一份,迟早会漂移成「构建期能出数、运行时出不来」 这种极难排查的状态。
还有一个坑:域名绑定
Umami 后台为该网站配置的 Website Domain,必须与站点实际域名一致。
不一致时采集脚本会被 Umami 实例的策略静默拦截:脚本能加载、控制台不报错、
后台就是一条数据都没有。排查方法是打开开发者工具的 Network 面板,
看 /api/send 请求的响应状态 —— 被拦截时它不会返回 2xx。
三、B 站集成
B 站这块有几件互不相干的能力,唯一的共同点只是都叫 Bilibili:
| 能力 | 用途 | 何时访问外网 |
|---|---|---|
| 追番列表同步 | 抓取追番列表,渲染「追番」区块 | 只在显式执行 pnpm anime:sync 时 |
| 投币视频同步 | 抓取最近投过币的视频,渲染「最近投币」区块 | 只在显式执行 pnpm anime:sync 时 |
| 粉丝勋章同步 | 抓取直播间勋章墙,渲染独立的 /medals/ 页面 | 只在显式执行 pnpm anime:sync 时 |
| 数字收藏集同步 | 抓取装扮体系里的数字卡牌,渲染独立的 /collections/ 页面 | 只在显式执行 pnpm anime:sync 时 |
| 视频门面嵌入 | 在文章里用 ::bilibili{} 嵌入播放器 | 只在访客点击播放按钮时 |
这四块共用同一个同步命令,但走的是四个不同的接口、四份独立的快照 —— 追番抓不到的时候其它几块照样显示。
它们对登录态的要求也各不相同,这张表是动手前最该看的:
| 能力 | 需要 SESSDATA? | 说明 |
|---|---|---|
| 追番 | 仅私密列表需要 | 公开列表匿名可读 |
| 投币视频 | 不需要 | 但要求「投币视频」可见性设为公开 |
| 粉丝勋章 | 必须 | 匿名一律 -101 账号未登录 |
| 数字收藏集 | 必须 | 同上,而且首次还要一次 1500+ 请求的全量扫描 |
3.1 追番同步:构建期不联网
页面数据来自一份本地 JSON 快照,由 pnpm anime:sync 在构建前显式抓取生成:
pnpm anime:sync --provider bilibiliastro build 全程不碰网络,这是刻意的:
- 构建环境常常没有外网,把网络请求放进构建会变得很脆;
- B 站挂了不应该导致博客发布失败;
- 「今天部署的产物」和「昨天部署的产物」应当可复现。
代价是数据会停留在最后一次同步的时刻,所以快照过期后会在构建日志里提醒。
同步脚本里有几处是吃过亏才加的:
- 封面本地化。 B 站图床对外站有严格的 Referer 校验,直接把 URL 贴到页面里
大概率是破图。所以同步阶段把封面下载到
public/assets/anime/covers/, 页面只引用站内路径,访客端零第三方请求。 - 不跑本地图片转码。 给 URL 追加 B 站 CDN 自己的处理参数
@220w_280h.webp, 真正干活的是他们的图片服务,本地不依赖 sharp,拿到的就是裁好尺寸的 WebP。 - 扩展名按文件头魔数判断。 加了
@...webp之后返回的仍可能是 JPEG, 光看后缀会写错扩展名。 - 敏感串扫描。 落盘前扫
SESSDATA/cookie:/access_token之类的模式, 命中就中止写入。凭据只该存在于环境变量里,绝不该出现在要提交进 Git 的快照里。 - 原子写入 + 空结果保护。 先写临时文件再 rename;抓到空列表时不覆盖 已有的有效快照 —— B 站偶尔会限流,一次抖动不至于把线上番剧页清空。
接口本身是三天状态遍历(在看 / 看过 / 想看),翻页带间隔防限流:
GET https://api.bilibili.com/x/space/bangumi/follow/list ?type=1&follow_status=<1|2|3>&vmid=<UID>&ps=<10..50>&pn=<页码>Referer: https://space.bilibili.com/Cookie: SESSDATA=<...>; ← 仅当追番列表设为私密时才需要追番列表如果是私密的,接口会返回
code 53013,此时需要在项目根目录的.env里填上BILI_SESSDATA再同步一次 ——.env已在.gitignore里, 且脚本会扫描确认凭据没有混进快照。
3.2 最近投币的视频
/anime/ 页面下半部分是「最近投币」,数据来自另一个接口:
GET https://api.bilibili.com/x/space/coin/video?vmid=<UID>&pn=<页>&ps=<每页>→ { "code": 0, "data": [ ...视频对象 ] }这个接口有三处和追番不一样,其中第三处直接把分页逻辑写崩过一次:
| # | 差异 | 后果 |
|---|---|---|
| 1 | data 是裸数组,不是 { list, total } | 没有 total 可比对,只能靠「这页够不够一页」判断结束 |
| 2 | 不需要登录,但要求投币列表设为公开 | 私密时返回 code 53013(和追番私密时同一个码) |
| 3 | pn 参数被忽略 | 实测两个账号,pn=1 与 pn=2 返回完全相同的列表 |
第 3 条如果照常写 pn++ 循环,就会无限翻同一页。所以用「本页有没有新 BV 号」兜底:
const fresh = list.filter((item) => item?.bvid && !seen.has(item.bvid));if (fresh.length === 0) break; // 分页没生效,或者已经到底了再配合「返回条数不足一页就停」,两种行为都能正确收敛 —— 不用去猜服务端到底认不认这个参数。
排序用的是「我投币的时间」而不是视频发布时间。 这两个字段差得很远: 完全可能今天给一个五年前的老视频投币,按发布时间排它会沉到列表底部, 而「最近投币」看的就是我什么时候投的。
卡片上还会同时出现两个币数,别搞混:
- 封面左上角的
×2—— 我投了几个币(接口的coins) - 底部计数行的「投币」 —— 这个视频收到的总投币数(接口的
stat.coin)
3.3 粉丝勋章
独立的 /medals/ 页面。这块把前面几个坑的教训一次性用上了 —— 先探接口再动手,
一探就发现它是唯一一个彻底没法匿名访问的:
GET https://api.live.bilibili.com/xlive/web-ucenter/user/MedalWall?target_id=<mid>Cookie: SESSDATA=<...>;→ { "code": -101, "message": "账号未登录" }注意域名是 api.live.bilibili.com —— 勋章是直播体系的东西,不在 api.bilibili.com 上。
我照例试了换域名、补参数、加 buvid3 Cookie、加 WBI 签名,全部 -101。
所以这块没有「免凭据的用法」可以设计,只能老实配 SESSDATA。
代码上的处理是:没有凭据就不发请求,直接返回空并打印怎么配, 页面显示对应的提示 —— 而不是发一个注定 401 的请求再报个看不懂的错。
勋章的颜色字段有个值得记的细节:接口同时给了两套。
| 来源 | 字段 | 格式 |
|---|---|---|
| 新版 | v2_medal_color_start | "#5762A799"(8 位,带透明度) |
| 老版 | medal_color_start | 十进制整数 6067854 |
新版才是 B 站当前界面真正在用的配色(带透明度),所以优先用它,老版转成
#RRGGBB 兜底。页面直接把这些色值塞进 CSS 变量渲染成勋章 pill ——
每枚颜色都不一样,Tailwind 类名表达不了这个。
3.4 数字收藏集
独立的 /collections/ 页面。这块一开始我理解错了 —— 用户说的「收藏集」
我当成了「收藏夹」,做出了一个以文件夹分组的页面才发现是两回事:
| 收藏夹 | 收藏集 | |
|---|---|---|
| 是什么 | 收藏视频用的文件夹 | 装扮体系里的付费数字卡牌产品 |
| 在哪看 | space.bilibili.com/<mid>/favlist | App「我的 → 个性装扮 → 收藏集」 |
| 接口 | api.bilibili.com/x/v3/fav/... | api.bilibili.com/x/vas/dlc_act/... |
中文名长得像,实际毫无关系。
那个做错的收藏夹页面已经删掉了 —— 它本质就是一个视频列表, 和站点已有的归档页定位重复,没必要单独开一个页面。
没有「我拥有哪些收藏集」的接口
收藏集的接口全是按单个收藏集(act_id)设计的:
GET x/vas/dlc_act/act/list?scene=1&site=<偏移> 列出全部收藏集(目录,约 20/页)GET x/vas/dlc_act/asset_bag?act_id=&ruid= 我在某一个收藏集里的持有情况asset_bag 返回 owned_item_cnt / total_item_cnt,也就是「我拥有几张 / 一共几张」——
但它必须指定 act_id。App 里那个「背包」也是先选收藏集再展示的。
所以**「找出我拥有哪些收藏集」只能把整个目录逐个查一遍**。 实测目录有 1500+ 个。
然后真的被风控了
我跑了一次完整扫描:1561 个收藏集,查出 12 个是我有卡的。扫描结束时还行,
但紧接着所有 dlc_act 接口开始返回 412 Precondition Failed,
等了两分钟也没恢复。
这不是理论风险,是实际踩到的。所以最终设计成两条路:
| 模式 | 命令 | 请求量 |
|---|---|---|
| 刷新(默认) | pnpm anime:sync --provider bilibili | = 你拥有的收藏集数 |
| 全量扫描 | 加 --scan-collections | 1500+ |
首次跑一次全量,之后日常只刷新那十几个 —— 又快又不会触发风控。
顺带修掉一个会静默丢数据的 bug
第一版刷新写完跑了一次,日志说「查到 1 个」——因为风控还没解除,12 个请求里只有 1 个成功。 然后它把快照覆盖成了这 1 条,另外 11 个收藏集直接消失了。
刷新语义应该是合并而不是替换:查成功的用新数据,查失败的保留旧数据, 并在日志里告诉你有多少个没查成功。改完之后再跑,同样 12 个只成功 0 个, 但快照里 12 条一条没少。
这已经是这个项目里第三次遇到「不报错、但数据静静消失」了 (前两次是投币时间戳那次,和
$变量写在普通字符串里那次)。 共同点都是:操作「看起来成功了」,只有对比前后数据才发现不对。
3.5 视频门面:点击之前不连 B 站
以前在文章里嵌视频是直接手写 <iframe>。那样有个问题:
只要页面被打开,浏览器就会连上 player.bilibili.com,即使访客根本没打算看。
- 每个访客的 IP 都被 B 站记录一次,还会带上 Cookie;
- 白占首屏带宽,拖慢真正想看的内容;
- 在你不知情的情况下,把「谁看过这篇文章」交给了第三方。
所以做了一个「门面」(facade):构建期只输出一张静态卡片 —— 16:9 的占位区 + 播放按钮 + 一行标题,HTML 里没有 iframe、没有第三方脚本。 只有访客真的点了播放,才由浏览器现场创建播放器。
::bilibili{bvid="BV1GJ411x7h7" p=1 title="视频标题"}| 参数 | 必填 | 默认 | 说明 |
|---|---|---|---|
bvid | 是 | — | 视频 BV 号,不支持 av 号 |
p / part | 否 | 1 | 分 P 序号,两种写法等价 |
title | 否 | BV 号 | 卡片上显示的标题 |
preload | 否 | none | none 点击才加载;auto 快滚到时提前加载 |
实际效果(点播放按钮之前,这个页面和 B 站之间没有任何连接):
参数写错时(比如 BV 号少一位),这行语法会原样显示成文本而不是静默消失 —— 这样能立刻看出是哪里写错了,而不是对着一片空白猜。
安全性上,播放器 URL 用 URLSearchParams 构造,并且在激活时会重新校验
data-* 属性:DOM 是可控篡改的(浏览器插件、XSS、控制台手改),
而 iframe 的 src 一旦被改成任意地址,就成了开放重定向的跳板。
四、文章改成文件夹方案
改了什么
改之前 改之后src/content/posts/ src/content/posts/├── hello-world.md ├── hello-world/├── mizuki-relayout.md │ ├── index.md├── ... │ └── cover.jpg└── img/ ├── mizuki-relayout/ ├── cover-1.jpg │ ├── index.md └── photo-2.png │ └── cover.jpg └── ...一篇文章一个文件夹,正文入口是 index.md,配图就近放在同一层,
用 ./cover.jpg、./photo-1.png 这样的相对路径引用。
为什么值得改
| 维度 | 文件夹方案 | 平铺 + 公共图片目录 |
|---|---|---|
| 数据控制权 | 与文章一起进 Git,100% 自主 | 依赖图床或一个大杂烩目录 |
| 改 URL | 文件夹改名即可,配图相对引用完全不受影响 | 要手工检查所有 ./img/xxx 引用 |
| 清理 | 整个文件夹一起删,不留孤儿素材 | 废弃图片极难检索 |
| 构建优化 | Astro 自动压缩、转 WebP、生成 srcset | 同样支持,但引用路径更难维护 |
迁移时守的两条规矩
- 文件夹名沿用原来的文件名,所以 15 篇文章的 URL 一字未变 —— 已有的链接和 giscus 评论全部继续有效。
- 迁移脚本必须跳过围栏代码块与行内代码。 站内有两篇文章正是在用代码块 演示「图片该怎么写」,那些示例路径不能被改写 —— 否则示例就变成了错的。
顺带把文档里所有 ./img/xxx 的示例也改成了新写法,
新建文章的脚手架(pnpm new-post <名字>)现在直接生成文件夹结构。
五、顺手补上的几件小事
配置覆盖层
新增 src/user/user-config.ts。不想动主题自带的默认值时,把要改的字段写在这里:
export const userConfigOverrides: Readonly<Record<string, unknown>> = { umami: { enable: true, shareUrl: "https://…" },};合并规则是对象递归合并、数组整体替换。数组不做逐项合并,
因为 sidebarConfig.widgets 这类清单的语义是「这就是我要的全部条目」——
逐项合并会让「删掉一项」变得没法表达。
这样升级主题时,src/config/*Config.ts 可以整份替换而不会覆盖你的配置。
文档
四个功能各配了一篇说明,记的都是配置项、实现原理和坑:
docs/tags.mddocs/umami.mddocs/bilibili.mddocs/post-folders.md
六、踩坑清单
把这次真正花时间的几个点集中列一下:
| 坑 | 表现 | 原因 |
|---|---|---|
| Umami Cloud 短链 | 分享链接能打开,卡片永远是 -- | 接口在 /analytics/<region>/api,短链里没有区域段 |
| Umami 域名绑定 | 脚本加载正常、不报错,后台没数据 | Website Domain 与站点域名不一致,被静默拦截 |
B 站投币接口的 pn | 翻页翻来翻去都是同一批视频 | 该接口忽略 pn 参数,照常 pn++ 会原地打转 |
| B 站勋章墙接口 | -101 账号未登录 | 整个接口都校验登录态,没有任何绕过办法 |
| B 站收藏集接口 | 扫描完 1561 个之后开始返回 412 | 请求量太大触发风控;改成「默认只刷新已知 + 全量扫描显式开启」 |
| B 站收藏夹列表接口 | 连哔哩哔哩官方号都返回「0 个收藏夹」 | 该接口匿名一律返回空 —— 这个坑也是后来决定不做收藏夹的原因之一 |
| 刷新时静默丢数据 | 12 个收藏集刷新后只剩 1 个 | 拿部分成功的结果直接覆盖快照;改成按 id 合并、失败的保留旧值 |
| 快照的「二次归一化」 | 数据同步成功了,页面上的时间字段却全没了 | 同步写的是归一化后的数据,读取时又归一化一遍;时间戳函数只认 Unix 秒,遇到 ISO 字符串就丢 |
| B 站图床防盗链 | 封面全是破图 | hdslb.com 校验 Referer,需要本地化下载 |
| 快照目录写入 | 一次限流把线上番剧页清空 | 抓到空列表照常落盘(已加 keepLastValid) |
| 迁移脚本 | 文档里的示例路径被改写,示例变成错的 | 正则没有跳过围栏代码块 |
| 图片文件名带括号 | 迁移时复制失败 | markdown 图片路径的正则没有处理括号 |
倒数第二条尤其阴:.png) 这种路径,用 [^)]+ 去匹配会在第一个右括号处截断 ——
文本替换看起来是对的(因为剩下的 .png) 恰好补回去了),但按截断后的名字去复制文件就会失败。
最后一条是这次最值得记的:它不报错、不崩溃,只是几篇文章的「投币日期」
和排序一起静静失灵。同步脚本先把接口数据归一化再落盘,页面读快照时又归一化一遍
(防的是有人手工改快照),而时间戳函数只认 Unix 秒数 —— 第二遍拿到已经是
ISO 字符串的 coinedAt,Number("2026-09-30T…") 得到 NaN,然后被丢掉。
修法是让归一化幂等:时间函数同时接受 Unix 秒和 ISO 字符串, 字段取值按「原始嵌套 → 新字段 → 已归一化的扁平结构」逐级兜底。 这条也解释了为什么值得给每一步都写一个能跑的验证 —— 光看构建成功是发现不了的。
七、写在最后
这次改动本身都不算复杂,但暴露了一个挺典型的教训:
「功能没生效」和「接口返回 404」是两件事,表现却可以一模一样。
Umami 那个坑就是典型:默认关闭是设计如此,接口地址推错是 bug, 两者叠在一起时,你看到的现象都是「卡片空白」。 所以修完之后把四种链接形态都测了一遍,而不是让手上这一条跑通就收工。
配置项、实现原理和错误处理都写进 docs/ 了,以后再动这块可以先翻那几篇。