5818 words
29 minutes
博客更新日志:标签分页、Umami 统计、B 站集成与文章文件夹方案

Static Badge Static Badge Static Badge Static Badge Static Badge

这次攒了一波改动,四件事都挺有代表性,所以单独写一篇日志。 不只是「改了什么」,也把过程中踩到的坑记下来 —— 其中有两个坑的表现极具迷惑性, 不写下来下次一定还会再踩一遍。

这次更新做了什么#

#改动一句话说明
1标签分页左栏只显示 12 个标签,其余全在独立的 /tags/ 页面
2Umami 访问统计侧栏新增「访问统计」卡片,两层能力可独立开关
3B 站集成追番 + 最近投币 + 粉丝勋章 + 数字收藏集 + 文章内 ::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
需要填shareUrlwebsiteId + 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> → 404
GET https://cloud.umami.is/analytics/us/api/share/<id> → 200 ✓

症状极具迷惑性:分享链接在浏览器里能正常打开、能看到图表,站内卡片却永远是 --, 控制台也不报错(请求失败被静默收敛成 null 了)。

修法是不猜,把几种可能都列出来按顺序探测:

const candidates = [`${origin}${prefix}/api`];
// 短链没有区域段时,真正的接口在 /analytics/<region>/api
if (!prefix.includes("/analytics/")) {
candidates.push(
`${origin}${prefix}/analytics/us/api`,
`${origin}${prefix}/analytics/eu/api`,
);
}

第一个 404 就试下一个,命中即停。这样自部署、带区域段、短链四种形态都能用。

数字是怎么到页面上的#

纯静态站点读第三方接口有个天然的矛盾:构建期抓的数据会过期,运行时抓的数据可能被挡住。

所以两条路都走,先到先得、后到的覆盖:

  1. 构建期 —— 在 Node 里抓一次,把数字直接写进 HTML。首屏就有真实数值, 访客端即使被 CORS 或网络问题挡住也不会是一片 --。请求 5 秒超时, 失败不影响构建;同一次构建里只真正请求一次(Promise 缓存), 不会每个页面都打一遍 Umami。
  2. 运行时 —— 浏览器里再拉一次覆盖,成功就换成带千分位的实时值, 失败就保留构建期那份。

两端共用同一份接口代码。这不是为了少写几行,而是因为「接口地址怎么推导、 返回的 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 在构建前显式抓取生成:

Terminal window
pnpm anime:sync --provider bilibili

astro 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": [ ...视频对象 ] }

这个接口有三处和追番不一样,其中第三处直接把分页逻辑写崩过一次:

#差异后果
1data 是裸数组,不是 { list, total }没有 total 可比对,只能靠「这页够不够一页」判断结束
2不需要登录,但要求投币列表设为公开私密时返回 code 53013(和追番私密时同一个码)
3pn 参数被忽略实测两个账号,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>/favlistApp「我的 → 个性装扮 → 收藏集」
接口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-collections1500+

首次跑一次全量,之后日常只刷新那十几个 —— 又快又不会触发风控。

顺带修掉一个会静默丢数据的 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否nonenone 点击才加载;auto 快滚到时提前加载

实际效果(点播放按钮之前,这个页面和 B 站之间没有任何连接):

示例视频:点播放才会连上 B 站Bilibili

参数写错时(比如 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同样支持,但引用路径更难维护

迁移时守的两条规矩#

  1. 文件夹名沿用原来的文件名,所以 15 篇文章的 URL 一字未变 —— 已有的链接和 giscus 评论全部继续有效。
  2. 迁移脚本必须跳过围栏代码块与行内代码。 站内有两篇文章正是在用代码块 演示「图片该怎么写」,那些示例路径不能被改写 —— 否则示例就变成了错的。

顺带把文档里所有 ./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.md
  • docs/umami.md
  • docs/bilibili.md
  • docs/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 图片路径的正则没有处理括号

倒数第二条尤其阴:![4](./img/a(1).png) 这种路径,用 [^)]+ 去匹配会在第一个右括号处截断 —— 文本替换看起来是对的(因为剩下的 .png) 恰好补回去了),但按截断后的名字去复制文件就会失败。

最后一条是这次最值得记的:它不报错、不崩溃,只是几篇文章的「投币日期」 和排序一起静静失灵。同步脚本先把接口数据归一化再落盘,页面读快照时又归一化一遍 (防的是有人手工改快照),而时间戳函数只认 Unix 秒数 —— 第二遍拿到已经是 ISO 字符串的 coinedAt,Number("2026-09-30T…") 得到 NaN,然后被丢掉。

修法是让归一化幂等:时间函数同时接受 Unix 秒和 ISO 字符串, 字段取值按「原始嵌套 → 新字段 → 已归一化的扁平结构」逐级兜底。 这条也解释了为什么值得给每一步都写一个能跑的验证 —— 光看构建成功是发现不了的。

七、写在最后#

这次改动本身都不算复杂,但暴露了一个挺典型的教训:

「功能没生效」和「接口返回 404」是两件事,表现却可以一模一样。

Umami 那个坑就是典型:默认关闭是设计如此,接口地址推错是 bug, 两者叠在一起时,你看到的现象都是「卡片空白」。 所以修完之后把四种链接形态都测了一遍,而不是让手上这一条跑通就收工。

配置项、实现原理和错误处理都写进 docs/ 了,以后再动这块可以先翻那几篇。

博客更新日志:标签分页、Umami 统计、B 站集成与文章文件夹方案
https://007912.xyz/posts/site-update/
Author
Xieluyang
Published at
2026-10-06
License
CC BY-NC-SA 4.0
Comments