Notion 博客整站变空白,一个 User-Agent 引发的生产事故
type
status
date
slug
summary
tags
category
icon
password
wechat_gate
打开自己的博客,左边栏还在,头像、简介、搜索框、导航全都正常,右边「最新文章」四个大字底下——空的。一篇文章都没有。
浏览器标签页上写着
undefined。这个博客跑在 Notion 上,用 NotionNext 那套方案。前一天还好好的,中间我只提交过一个改 CSS 动画的 commit。代码没动数据层,依赖四个月没升级,Notion 后台的文章一篇没少。
从看到空白页到定位根因,花了大约四十分钟。真凶是一个请求头。
下面是完整的排查过程。比结论更值得留下来的是过程——同一类问题("我什么都没改,它自己挂了")以后还会再来。
一、先别猜,先看服务端到底吐了什么
空白页最容易走的弯路,是打开 DevTools 看 Console 报错,然后一头扎进前端。
这是 SSG/SSR 应用,页面内容由服务端在构建或请求时注入。所以第一件事不是看渲染,是看服务端塞进 HTML 里的数据本身。
Next.js 会把 props 原样写进
__NEXT_DATA__ 这个 script 标签。一条 curl 就能拿到:结果:
到这一步,前端的嫌疑全部洗清了。渲染没问题,是给它的数据本来就是空的。
二、最关键的一个判据:缺失的字段比空数组更有信息量
很多人看到
posts: len=0 就会得出"数据没拿到"然后开始改代码。但这里还藏着一个更精确的信号。注意上面的 keys 列表里,没有
siteInfo。NotionNext 在数据层失败时有两条不同的退路,落在
lib/db/getSiteData.js:两条路的产物长得完全不一样:
现象 | 走的分支 | 含义 |
有 siteInfo,有 1 篇占位文章 | EmptyData() | 请求成功了,但页面结构/配置不对 |
没有 siteInfo,0 篇文章 | return {} | 请求本身失败,网络层就没通 |
我遇到的是后者。这一个观察直接砍掉一半排查空间:不用去查 Notion 数据库结构、字段名、权限配置了,问题在网络请求这一层。
<title>undefined</title> 是同一个原因的外在表现——标题取自 siteInfo.title,siteInfo 都不存在,自然是 undefined。这个思路可以推广:空数组和字段缺失是两种不同的失败。前者说明代码跑到了那一步但没结果,后者说明代码根本没跑到那一步。别把它们混为一谈。
三、确认它是"现在还在坏",而不是"旧构建的残骸"
这一步很多人会跳过,然后浪费大量时间。
首页是 ISR 静态页,你看到的可能是几小时前生成并缓存的结果。就算 Notion 现在恢复了,页面也不会自己变好。你必须找到一条绕过所有缓存、实时打后端的路径。
我的博客里,
/sitemap.xml 用的是 getServerSideProps——每次请求都实时跑一遍数据获取。带上随机参数打穿 CDN:拿到:
MISS + age: 0 意味着这是刚刚现算的。而 sitemap 里只剩 2 条——是代码里硬编码的首页和关于页,文章一条没有。结论很硬:此刻线上仍在失败,不是历史遗留。
顺带一个反面教材:我当时看到
/rss/feed.xml 还有 249KB,差点误判成"数据还在"。那是上一次成功构建时写进 public 目录的静态文件,构建产物不能作为数据可用性的证据。四、本地复现,拿到原始报错
线上日志当时够不着(Vercel CLI 还没装,这也是后面的教训之一)。那就本地跑。
本地同样只有 3 条。故障在本地环境复现了,这一步排除了"服务器 IP 被封"这个很容易先入为主的假设。
日志里躺着真凶:
403 Forbidden。而且重试机制完整跑了 5 轮,全军覆没。
五、变量隔离:为什么 curl 通,Node 不通
诡异的地方来了。同一台机器、同一个网络,我用 curl 手动打那个接口:
HTTP 200。数据完整返回。同样的请求换成 Node:
同 IP、同 body、同 endpoint,一个 200 一个 403。差异一定在请求本身。
排查方向有三个:HTTP 版本、TLS 指纹、请求头。先排最便宜的。
curl 默认走 HTTP/2,undici 走 HTTP/1.1,看起来很可疑。测一下:
排除。那就是请求头。先看 Node 到底发了什么——起一个本地 HTTP server 把请求头打出来:
Node 内置 fetch(undici)默认发送
User-Agent: node。拿这个值做对照实验,只改 UA,其他全部不动:
User-Agent | 状态码 |
node | 403 |
undici | 200 |
curl/8.7.1 | 200 |
Mozilla/5.0 | 200 |
只有
node 被拒。根因锁定:Notion 把 User-Agent: node 加进了拦截名单。反向验证:给 Node 的 fetch 手动加一个浏览器 UA,立刻 200。
六、修复
问题在
notion-client 的初始化处。它内部用 ofetch,而 ofetch 在 Node 环境下就是 undici。改动只有几行:
动手前我确认了一件事:这个 headers 会不会被逐次调用时传入的配置覆盖掉。翻了
notion-client 的源码,它的合并逻辑是这样:headers 是单独深合并的,而其余选项走的是
{...this._ofetchOptions, ...ofetchOptions} 浅覆盖。所以在构造函数里设一次,能覆盖 getPage、getBlocks、fetchInBatches 所有调用路径。一处改动,全线生效。这类细节值得花两分钟去翻源码确认,比改完发现只修好了一半要划算。
七、验证:同一套路径跑前后对照
"测试通过"不算验证。要的是同一条复现路径上的前后对照。
用之前那条 sitemap 实时探测:
检查项 | 修复前 | 修复后 |
本地 sitemap 条数 | 3 | 181 |
线上 sitemap 条数 | 2 | 176 |
首页 posts | 0 | 10 |
allNavPages | 0 | 173 |
tagOptions | 0 | 126 |
标题 | undefined | 沐风的博客 |
dev.log 里的 403 | 5 次递归全挂 | 0 次 |
生产构建:
221 个静态页全部生成(故障状态下只会有 2 个)。线上首页的 buildId 也从旧值换成了新值,确认是新构建在服务。
八、沉淀:下次再遇到同类问题的检查清单
这是这篇文章里最值得收藏的部分。
1. 判断故障层级——先看服务端注入的数据,不是前端渲染
拿
__NEXT_DATA__(或你框架的等价物)。数据是空的,前端就无罪。2. 区分"空值"和"字段缺失"
空数组 = 跑到了那一步但没结果。字段整个不存在 = 根本没跑到。后者通常意味着更靠上游的失败。
3. 找一条绕过所有缓存的实时探测路径
SSR 路由 + 随机 query + 检查
x-vercel-cache: MISS / age: 0。没有这一步,你无法区分"正在坏"和"坏过"。4. 构建产物不是数据可用性的证据
RSS、sitemap 静态文件、上次的 JSON 快照,全都可能是上一次成功时留下的。
5. 本地复现,用来切分环境变量
本地也挂 → 不是服务器 IP / 环境问题。本地好、线上挂 → 才去查环境差异。这一刀能省掉一半工作量。
6. 客户端指纹问题的隔离顺序
curl 通而代码不通时,按成本从低到高排查:请求头 → HTTP 版本 → TLS 指纹。八成停在第一步。
7. 改依赖库的配置前,翻一眼它的合并逻辑
尤其是 headers、retry 这类既能全局设也能逐次传的选项。浅合并和深合并的差别,决定你的改动是全线生效还是只修好一个入口。
九、我的判断:这事还会再来
需要说清楚两件事的性质。
已验证的:同 IP、同 body,只改 UA,
node 403 而其他 200。这是事实。推断的:这条规则是那两天才生效的。我没有直接证据——拿不到 Notion 侧的变更记录,当时也没搜到第三方的同期报告。这个结论是从"代码四个月没动 + 之前一直正常 + 现在必现"反推出来的。间接证据很强,但它不是直接证据,我不打算把它说成事实。
更重要的是这个判断:这不是一次性事件,是趋势。
这类 Notion 博客方案走的是
www.notion.so/api/v3 这个私有接口——Notion 网页版自己用的那套。它没有文档、没有版本承诺、变更从不公告。跟官方那个需要 integration token 的 api.notion.com/v1 完全是两回事。所以浏览器 UA 只是当下够用的应对,不是长期保障。下一次他们可能改成校验 TLS 指纹、cookie、或者带 space id 的签名头,那时候换 UA 就不管用了。
彻底的解法是迁到官方 API,代价是拿不到
blockMap 那种富渲染结构,react-notion-x 整套就用不了了。这个取舍很大,我评估下来现在不值得做。但它得躺在待办里。顺带一个操作层面的教训:故障当时我没装部署平台的 CLI,看不到线上运行时日志,只能靠本地复现反推。事后补上之后,同样的问题两条命令就能定位。这类"平时用不上、出事时救命"的工具,别等出事了才装。
排查这件事最大的收获,不是知道了 Notion 封
User-Agent: node——这条信息半年后大概率就过期了。是那个流程本身:先确认故障还在发生,再区分失败的层级,再逐个隔离变量,每一步都要有能贴出来的原始输出。 中间任何一步换成"我觉得应该是",后面全是白跑。
你踩过哪种"代码没动却突然挂了"的坑?是依赖方悄悄改了规则,还是别的?评论区聊聊。觉得这套排查思路有用,点个"在看",让更多人少走弯路。
写 AI,写成长,偶尔写投资。
关注沐风,不定期更新,全是干货。
2026.08.05 19:52
沪·赵巷
声明:本文由 AI 辅助完成