Skip to main content

MDX 里的一行 HTML 属性导致文章返回 500:589 条 5xx 的排查记录

September 18, 2026
记录一次真实的 5xx 排查:覆盖率报告列出 589 条受影响 URL,其中 584 条其实是无须收录的社交分享图端点,真正的故障是一篇文章因正文混入 WordPress 原始 HTML,在 MDX 编译阶段抛错。完整还原报告拆解、本地复现、数据定位、修复与线上验收。
MDX 里的一行 HTML 属性导致文章返回 500:589 条 5xx 的排查记录

更新日期:2026-09-18

Search Console 的"网页编入索引"覆盖率报告里出现一条告警并不罕见,但"服务器错误 (5xx)"这一类值得立刻处理:它和 404、已抓取未索引不同,5xx 意味着 Googlebot 到达了你的站点却拿不到有效响应。它消耗抓取预算、拉低整站信任度,且会在 Google 的判定里被当作站点不稳定的证据。

本文记录一次真实排查的完整过程。站点是自建的 Next.js 内容站,正文数据来自一次 WordPress 全站导入,共 384 篇已发布文章(其中 346 篇带 WordPress 原始 ID)。导出的覆盖率报告显示受影响网页 589 条,最终定位到的却是一篇正文里的几个 HTML 属性。整个排查过程里最有价值的部分不是修复本身,而是报告标题描述的问题和实际存在的问题并不一致——如果照着报告字面去优化,会修错地方。

站内此前已有 AI 爬虫治理与引用监测实战 讨论过抓取策略层面的治理,本文聚焦的是另一个方向:当爬虫已经正常到达、但拿不到响应时,如何从报告反推到代码缺陷。若你的站点也是从 WordPress 迁移而来,站内 WordPress 7.1 生产升级指南为 WordPress 开启 LS-Cache 缓存 分别覆盖程序升级与访问性能,可与此处的数据缺陷治理互为补充。

适用范围:使用无头 CMS 或框架(Next.js、Nuxt、Astro 等)承载内容的站点,正文存在从 WordPress/WXR 或其他富文本来源批量导入的历史数据,且正文最终经由 MDX 或 JSX 类模板渲染。不适用于:纯静态 HTML 站点(没有编译阶段,原始 HTML 不会被当作 JSX)、以及正文始终以 HTML 字符串经 innerHTML 类接口渲染的站点——后者的风险点完全不同,属于 XSS 治理而非编译失败。

先看结论

  1. 报告里的"受影响网页数"是去重后的 URL 计数,不等于受损内容数。本次 589 条里 584 条是社交分享图端点,真正的故障内容只有 1 篇。
  2. 导出报告的分页清单必须和"受影响网页数"时间序列对照看。本次清单 589 行与时间序列末尾的 589 完全吻合,这个等式是判断"清单是否完整、故障是否仍在持续"的关键依据。
  3. 5xx 的 URL 里出现带查询参数的资源型地址(如 ?hash 结尾的图片接口)时,先怀疑这些 URL 是否本就无需收录,再用 robots.txt 与实际响应码双重确认。
  4. 复现 500 必须看服务端渲染日志,不要只依赖浏览器。本次浏览器端只看到通用错误页,服务端日志直接给出了 The style prop expects a mapping from style properties to values, not a string.
  5. 富文本导入内容经 MDX 渲染时,HTML 专有属性是系统性雷区:style="..." 传字符串会直接抛错,class 会被报为无效 DOM 属性。
  6. 修复要修一类问题,不要只修那一篇。本次最终改的是渲染前的预处理函数,全站 384 篇同时受益,未来导入也不再复现。
  7. robots.txtDisallow 只阻止抓取,不等于修复:已经抓取并被记录为 5xx 的条目不会消失。而且按 Google 规范,被 Disallow 的 URL 仍可能被索引(只是没有摘要),想做去索引化必须用 noindex 或 404/410。
  8. 改完必须重新部署才能生效——本次修复提交后,线上同一 URL 由 500 变为 200,才算闭环。

第一步:先把报告读对

导出 Search Console 覆盖率下钻清单后,不要急着从第一条 URL 开始点。先做三件事:统计 URL 结构分布、对照时间序列、确认清单完整性。

# 报告为 CSV,首行是表头。统计受影响 URL 的路径模式分布
f="gsc-drilldown.csv"

# 去掉表头后的总条数
total=$(( $(wc -l < "$f") - 1 ))
echo "受影响 URL 总数: $total"

# 按"末段路径"归类,快速看出是内容页还是资源端点
tail -n +2 "$f" | cut -d, -f1 \
  | sed -E 's#\?.*$##' \
  | awk -F/ '{print $(NF-1)"/"$NF}' \
  | sort | uniq -c | sort -rn | head -20

本次输出立刻暴露了问题:绝大多数 URL 的末段是 opengraph-image-<hash>?<构建哈希>。也就是说,报告里 584/589 是社交分享图接口,不是文章页。

再看时间序列。覆盖率报告可以导出"受影响网页数"的每日曲线,把它与清单条数对照:

# 时间序列 CSV:日期,受影响的网页数
ts="gsc-history.csv"
echo "时间序列末尾值: $(tail -1 "$ts" | cut -d, -f2)"
echo "下钻清单条数:   $total"

两者都是 589。这个等式带来两个结论:清单是完整的(没有被截断);故障在当时是稳定存在而非偶发——如果是间歇性故障,曲线会上下波动而不是长期停在同一个数字。

判据小结:当"受影响网页数"长期保持不变,通常意味着某个确定性错误路径,而不是负载或资源问题。间歇性 5xx 才会表现为锯齿状曲线。

第二步:分辨真假问题

清单里的 5 条非图片 URL 才是内容页。对它们逐个实测状态码,并跟随重定向链:

# 逐条实测,记录状态码与重定向终点
for u in \
  "https://example.test/en/some-article" \
  "https://example.test/some-article" ; do
  printf '%-60s ' "$u"
  curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' \
       -m 30 -A "Mozilla/5.0 (compatible; Googlebot/2.1)" "$u"
done

同时抽样验证那些图片端点。这里有个关键判断:如果这些端点实测返回 200,说明它们是历史遗留错误,不是当前故障。 抽样时务必确认命中的是源站而不是 CDN 缓存,否则结论会完全反过来:

# 确认是源站响应而不是缓存命中:观察 age 与缓存状态头
curl -sI -m 30 "https://example.test/en/product/some-item/opengraph-image-ab12cd" \
  | grep -iE 'http/|age|cache-control|x-cache|cf-cache-status|server'

age: 0 且缓存状态为 MISS,则这次响应确实来自源站,可以进行下一步判断。若 age 很大,需要先绕过缓存或换个查询参数再测。

还有一个容易被忽略的点:这类接口是否本就不希望被收录。检查 robots.txt

curl -s https://example.test/robots.txt | grep -nE 'Disallow|Sitemap'

本次确认这些路径已被 Disallow 覆盖。但它们仍留在 5xx 报告里——因为 Disallow 只阻止后续抓取,之前已经抓取并记录为错误的条目不会因此消失。很多人以为加了 Disallow 就等于修好了,实际报告中那条记录会一直在。 正确的收尾是在确认源站已恢复正常后,用报告里的"验证修复"流程让 Google 重新抓取。

这里还有一个必须说清的概念,它决定了你该用 Disallow 还是 noindex:按 Google 的 robots.txt 规范,被 Disallow 的页面内容无法被抓取因而无法索引,但该 URL 本身仍可能被索引,并以不带摘要的形式出现在搜索结果里。原因很直接:爬虫没有抓取页面,也就读不到页面里的 noindex 标签。所以:

  • 想让一个 URL 完全不出现在搜索结果中 → 不要用 robots.txt,要么让它返回 404/410,要么允许抓取并在页面里输出 noindex
  • 只想节省抓取预算、不关心它是否被索引 → 用 Disallow

本次的对象是社交分享图接口,本来就不需要出现在搜索结果里,但真实诉求是"别把爬取预算浪费在它们身上且别报错",因此 Disallow 是合适的选择。如果你的场景是内容页要去索引化,请改用 noindex,否则会得到一个"搜得到标题、点进去没有内容"的尴尬结果。

第三步:在本地复现 500

线上返回通用错误页,看不到有效信息。要把站点在本地跑起来复现,才能拿到真实堆栈。前提是本地能连到与线上一致的数据源。

# 本地启动开发服务器(示例端口)
pnpm dev --port 3111

# 另开一个终端,用与线上一致的路径请求
curl -s -o /tmp/repro.html -w 'http=%{http_code}\n' \
     -m 120 "http://127.0.0.1:3111/en/the-broken-article"

本次本地复现得到 http=500,与线上一致。此时关键动作是去看服务端日志,而不是翻响应体:

# 服务端日志里会打印框架级错误事件。本次日志给出的关键信息是:
#   - 一个无效 DOM 属性警告:class 应为 className
#   - 一个渲染期致命错误:style 属性收到的是字符串,
#     而 JSX 要求它必须是"属性到值的映射"(即对象)
# 具体措辞随框架版本而异,但错误类型与上述两点一致。

这条信息把范围从"某处 500"直接缩小到"某个 JSX 属性的类型不对"。排查 5xx 时,服务端日志的优先级永远高于响应体:响应体为了安全通常被替换为通用错误页,而日志里有完整消息与 digest。

第四步:定位到数据,而不是代码

拿到错误消息后,容易误判为"框架或组件写错了"。本次的实际情况正相反:渲染代码没有问题,是数据里混进了不该出现的原始 HTML

这批文章在数据库里的正文格式标记是 markdown,但正文内容来自 WordPress 区块编辑器,其中夹带了原始 HTML(表格、以及一个带内联样式的 mark 标签)。MDX 会把 markdown 里的原始 HTML 当作 JSX 处理,于是:

  • style="background-color:rgba(0, 0, 0, 0)" 作为字符串传给 JSX 的 style 属性 → 类型不符,直接抛错;
  • class="has-inline-color has-blue-color" 被识别为无效 DOM 属性 → 产生警告。

一条数据缺陷,导致整篇文章对所有访问者返回 500,包括所有搜索引擎爬虫。

排查这类"数据里混入标记"的问题,最高效的方式是把整库内容拉下来做模式扫描,而不是逐篇打开:

# 思路示意:把正文导出后统计 HTML 专有属性的出现次数
# 关键是比较"出现在代码块内"与"出现在代码块外"的数量差异
#
# 1) 全文匹配(会把代码示例里的内容也算进来,用于初筛)
grep -c 'style="' content-dump.txt

# 2) 先剔除围栏代码块与行内代码,再匹配(用于确认真实风险量)
#    只有代码块之外的命中才是会被当作 JSX 编译的部分

本次扫描的结论很有价值:全库 384 篇里,代码块之外含有 HTML 表格与内联样式的只有 1 篇。这解释了为什么问题长期只影响一个 URL,也说明故障范围是可控的——但同时也是危险的,因为只要再导入一批同样格式的内容,受影响文章数会立刻放大。

判据:当扫描显示"全文命中很多、代码块外命中极少"时,不要急着批量改写数据。 先确认哪些命中位于代码块之外,那才是真正会触发编译失败的部分。贸然批量替换会破坏文章里本应保留的代码示例。

第五步:修复要覆盖一类问题

正确的修复位置是渲染前的正文预处理函数,而不是那一篇文章的数据。这样做的收益是:现有全部文章、以及未来任何一次导入,都被同一条规则保护。

修复的核心逻辑是:在把正文交给 MDX 编译之前,移除 HTML 专有的 styleclass 属性。实现时有两个必须注意的细节,它们都是本次实际踩到的坑:

坑一:不能连带吞掉属性后面的空白。 如果正则把属性及其后空白一起删除,src="a.png" class="x" alt="y" 会变成 src="a.png"alt="y",属性被粘在一起,MDX 编译器会报 Unexpected character '=' in name——修复引入了一个新的编译错误。正确做法是只吃属性前面的空白。

坑二:必须保护代码块内容。 文章里可能有专门的代码示例在讲解这些属性本身。如果预处理函数无差别剔除,示例会被破坏,读者看到的代码就错了。实现上要在处理前先把围栏代码块与行内代码替换为占位符,处理完再还原。

修复后重新验证同一条路径:

# 修复后再次请求,应与预期一致地返回 200
curl -s -o /tmp/fixed.html -w 'http=%{http_code} size=%{size_download}\n' \
     -m 120 "http://127.0.0.1:3111/en/the-broken-article"

# 确认正文里的表格与文本完整,且没有残留被误删的属性痕迹
grep -c '<tr' /tmp/fixed.html
grep -o '<mark[^>]*>[^<]*</mark>' /tmp/fixed.html | head -3

本次结果为 http=200,表格行数完整,mark 标签正常渲染且内联属性已被清除。

第六步:部署与线上验收

本地通过不等于线上生效。必须重新构建部署,然后用与 Googlebot 相同的入口再验证一次:

# 部署后,用爬虫 UA 复测原故障 URL
curl -s -o /dev/null -w 'en http=%{http_code} time=%{time_total}s\n' \
     -m 60 -A "Mozilla/5.0 (compatible; Googlebot/2.1)" \
     "https://example.test/en/the-broken-article"

# 跟随重定向确认最终落地页也是 200(多语言站常见:无前缀路径 307 到带前缀路径)
curl -sL -o /dev/null -w 'final=%{url_effective} http=%{http_code}\n' \
     -m 60 "https://example.test/the-broken-article"

验收清单:

  • 原故障 URL 返回 200,且响应体包含预期正文(不是空壳页面)
  • 无前缀语言路径经重定向后最终也是 200
  • 页面的 robots meta 为可收录(不是 noindex
  • 同批次其他文章未受影响(本次另 3 篇含原始 HTML 的文章中英文路径均为 200)
  • 健康检查接口正常
  • 站点内多个代表性路由抽查无 5xx
  • 部署产物指纹已更新(确认新构建真的生效,而不是旧实例仍在服务)

本次部署后观察了十分钟以上,目标 URL 持续返回 200,无回归。

完整排查时间线

把这次排查的步骤、产出和时间占比列出来,便于你估算自己的同类问题需要多久。总耗时约两小时,其中一半以上花在"读对报告"上,而不是改代码。

阶段关键动作产出时间占比
读报告URL 结构分布、时间序列对照确认 589 条中 584 条是图片端点约 25%
分辨真假逐条实测状态码、跟随重定向、检查缓存头锁定 5 条内容页里唯一真实的 500约 15%
本地复现起本地服务、请求同一路径、读服务端日志拿到渲染期错误消息约 10%
定位数据全库扫描正文,区分代码块内外命中确认 384 篇中仅 1 篇触发约 15%
实施修复改写预处理函数、处理两个衍生坑、补测试修复覆盖全部历史与未来内容约 20%
部署验收构建部署、爬虫 UA 复测、观测十分钟目标 URL 稳定 200约 15%

两个值得记住的比例:读报告与分辨真假合计约 40%,这部分工作看起来"没在修东西",但它决定了后面 60% 的精力是否用在正确的地方。如果跳过这一步直接去优化社交分享图接口,会花掉大量时间却完全不解决那篇文章的 500。

另一个比例是修复本身只占约 20%。真正花时间的是确认范围——扫描全库、区分代码块内外、验证另外 3 篇含原始 HTML 的文章是否受影响。这也解释了为什么"先量化再动手"在运维排查里总是更快:量化的成本固定,而盲目修改的成本随代码库规模增长。

同类缺陷的预防:把检查前置到导入环节

这次修复解决的是结果,不是原因。只要还有一次从富文本平台导入内容的动作,同类缺陷就会重新出现。可持续的做法是把检查前置,而不是每次出问题再排查。

第一道防线:导入时校验正文格式。 导入脚本在处理每一篇内容时,应当先判断"代码块之外是否存在 HTML 块级元素或 HTML 专有属性",命中就拒绝导入或进入人工复核队列,而不是先写库再等线上报错。判断时务必先剔除代码块与行内代码——文章里讲解 HTML 本身就是常见需求,把代码示例里的标签算作风险会造成大量误报。

第二道防线:把渲染预处理写成纯函数并加测试。 本次修复之所以能一次性覆盖全部 384 篇,是因为它落在一个无副作用的纯函数里。这类函数值得配一组固定用例:属性剔除是否彻底、代码块是否被保护、花括号转义是否仍然生效、空输入是否安全。测试用例应包含真实的失败样本,这样将来有人重构时会被立刻拦住。

第三道防线:让导入与发布走同一条校验路径。 本次的发布脚本在写库前会先对正文做一次真实的 MDX 编译,编译失败直接中止。这比任何正则检查都可靠,因为它是真正执行渲染链路。如果你的发布流程还没有这一步,补上它的成本很低,收益是"编译不过的内容根本进不了库"。

第四道防线:上线后主动验证,而不是等报告。 覆盖率报告的更新有明显延迟,等到报告出现告警时,故障可能已经存在数周。本次的故障 URL 最后抓取日期在数月之前,说明它长期处于错误状态而未被察觉。建议在部署后自动抽查一批关键页面的状态码,覆盖多语言路径与内容类型,任何非 200 都作为部署失败处理。

这四道防线的共同点是:都不依赖人去记住这件事。运维里的教训只有变成代码或流程的一部分才算真正被吸收,否则三个月后同一类问题会以另一个形式回来。

收尾:报告里剩下的东西怎么处理

修复完成不代表报告会立刻清零。本次 584 条图片端点经过抽样实测全部返回 200,属于历史遗留记录。对它们的正确做法是:

  1. 确认源站响应正常(不是 CDN 缓存造成的假象);
  2. 确认这些 URL 本就无需收录(robots.txt 已覆盖);
  3. 在报告中走"验证修复"流程,让 Google 重新抓取;
  4. 不要为了消除报告而删除这些接口——社交分享图对分享卡片的呈现有价值。若确实希望这类 URL 不再进入报告,需要把它们改为稳定地址(不带每次构建变化的哈希参数),而不是屏蔽。

最后回到最初那句判断:报告标题描述的问题,和实际存在的问题,经常不是同一个。 本次报告说"589 个网页服务器错误",实际情况是"1 篇文章因数据缺陷返回 500,外加 584 条无需收录的资源型历史记录"。先花十分钟把报告的构成拆开,比直接照着标题去优化要省下几天。

官方资料与继续阅读

站内相关文章: