半路手记 · 踩坑实录
页面全绿,分享卡全错
发现问题的那会儿,站上有三十四篇文章,每篇的 frontmatter 里都写了 cover,页面上也都规规矩矩显示着各自的封面。构建四十个页面,零报错,谁都看不出问题。(这篇文章是第三十五篇。前面几层的数字都是当时那一版的口径;末尾另有一组是写这篇时重新数出来的,到时会说明。)
直到我想看看某篇文章被转发出去长什么样,才去读了一眼 <head>:
<meta property="og:image" content="https://…/og-default.svg" />
<meta name="twitter:image" content="https://…/og-default.svg" />
三十四篇,一模一样。
这两个标签不由我的浏览器渲染,不由我的构建校验,也不由我的页面呈现。它们唯一的读者是别人家的爬虫:有人贴了链接,爬虫来抓 <head>,按里面的地址取一张图,拼成卡片。而页面上的封面走的是另一条完全不同的路——<img src={cover}>。
页面显示正常,和分享卡正确,从来就不是一回事。 只是我一直在验前者。
第一层:默认值让它永远“看起来有”
布局组件里,ogImage 这个参数和它的默认值很早就写好了,两个 meta 标签也拼得规规矩矩:
---
interface Props {
title: string;
description?: string;
ogImage?: string;
}
const { title, description = '…', ogImage = '/og-default.png' } = Astro.props;
---
<meta property="og:image" content={new URL(ogImage, Astro.site)} />
<meta name="twitter:image" content={new URL(ogImage, Astro.site)} />
问题出在调用方。四个栏目各自的详情页模板,一个都没传:
<!-- 改之前,四个模板全这样 -->
<BaseLayout title={entry.data.title} description={entry.data.summary}>
<!-- 改之后 -->
<BaseLayout title={entry.data.title} description={entry.data.summary}
ogImage={entry.data.cover ?? '/og-default.png'}>
一行,改四处。
默认值这东西特别容易让人放心:标签永远在,地址永远合法,构建永远通过,页面上永远有封面。它把「这个字段有没有值」变成了一个恒真命题,于是三十四篇文章安静地吃了很久的兜底图,没有一处报错。
修完我写了个脚本,把构建产物里四十个页面逐页打开,读 og:image:三十四篇指向自己的封面,六个非文章页(首页、关于页、四个栏目列表页)用默认卡,缺字段的零个。三十四个 cover 逐条核到文件,全在。
注意这时候那六张默认卡还是 SVG——它归下一层管,第一层修完是看不出来的。
这是这轮里第一次真正去读那个字段,而不是去看页面。
第二层:我这边能打开,不等于对方肯收
那张默认卡是一份 630 字节的 SVG,1200×630,画得挺周正。用浏览器打开,一点毛病没有。
平台自己的卡片文档里却有一行原文:
JPG, PNG, WEBP and GIF formats are supported. Only the first frame of an animated GIF will be used. SVG is not supported.
同一页还有一条我原先不知道的:图片应当是「代表这个页面的一张独特的图」,并且明确写着不该用「跨多个页面共用的通用图」。
也就是说,我把三十四篇共用一张默认图当成了一个待修的故障,可在平台那头,这本来就是不被接受的做法。格式换对了也还是不对。
不过这条我没有做到底。修完之后,六个非文章页仍然共用同一张默认卡——按上面刚引的那句「不该用跨多页共用的通用图」,它们严格说也不合规。我没改,是因为首页和栏目列表页本来就没有自己的内容可配图,拿一张通用卡兜底是我眼下能接受的办法。拿一条规则支持自己的结论时,得连它对自己不利的那半边一起写出来,否则规则就只是修辞。
做法不复杂:用无头浏览器把同一份 SVG 连着自托管的字体一起渲染成 1200×630 的 PNG,29,779 字节,画面不变,只换容器。原来的 SVG 留着没删——站上两篇旧文的正文提到过它,那是当时的记载,不该为了现在看起来一致去改。
顺手踩了一个:这台机器上无头截图不加 --no-sandbox 会静默失败,产物 0 字节,不报错、不非零退出。第一次跑完我还以为成了。
这一层最骗人的地方是「本地打开没问题」。这只说明我的浏览器能读这个文件;决定它对不对的,是别人家的爬虫肯不肯收。拿自己的渲染结果去验一个给别的程序读的格式,验得再漂亮也不相干。
第三层:能取到,不等于取得动
前两层修完,三十四张封面第一次真的成了分享图。一量体积,七张超过 300 KB,最大的两张各 2.3 MB 出头,七张合计 9.46 MB。(这篇里 KB、MB 一律按 1024 进位,先说清楚,免得自己也犯第五层那个毛病。)
平台文档写的是单张 5 MB,严格说没超。但爬虫来抓的时候要等你把两兆多的东西吐完,慢一点、超时一点、或者干脆放弃,都不会回过头告诉我。
压缩不能一套参数走天下。这批图分了三类:
| 载体 | 处理 | 为什么 |
|---|---|---|
| 铅笔线稿(纸纹噪点) | 宽 1200 → 灰度 → 3×3 中值 → PNG 优化 | 噪点是高频,中值能抹掉而线不断 |
| 扁平矢量(1–2 px 细轮廓) | 不缩放、不滤波,只转灰度 | 试过 5×5 中值,人物轮廓和脸直接碎出缺口 |
| 彩色卡通 | 不缩放不滤波,保留颜色,调色板 192 色 | 转灰度就没了,只能压颜色数 |
if mode == "L_med":
out = im.filter(ImageFilter.MedianFilter(3)).convert("L")
elif mode == "L":
out = im.convert("L")
else:
out = im.convert("P", palette=Image.ADAPTIVE, colors=192)
out.save(dst, optimize=True)
七张从 9.46 MB 到 1.74 MB,降了 81.6%。那一版全站三十四张封面合计 5.11 MB,单张最大 296.985 KB,超过 300 KB 的零张。
中间试过一招「提白 240」,一张浅色的图从 194 KB 掉到 30 KB。这个数字当时看着像成绩,开图一看画面被洗掉大半。体积骤降要当警报看,不要当优化看——它更可能意味着内容没了,而不是编码变聪明了。
第四层:体积达标,不等于画面干净
压缩要逐张开图看,这一看,四张早年做的封面右下角还留着淡淡的生成工具字样。
我一开始的印象是「几张」,于是把三十四张的右下角全裁出来拼成一张,逐格数过去:四张。「几张」是猜的,「四张」是数出来的,这两个说法的可靠程度差得远。
清除没用纯色填——带纸纹的图上,纯色会留一块塑料补丁。做法是取水印正上方同尺寸的一块空白纸当供体,十像素羽化边缘后贴进去。贴之前先验供体空不空:不先验空,就是用另一个错误盖住这一个。
下面这几个数都是写这篇时按统一口径重算的,不是当天随手记下的那组(取窗不同,数会差一点,混着用就成了假数据):四块供体的标准差是 1.0、1.7、0.8、4.4,都是空白纸,没把别处的内容克隆过来。
自检给的假信号,比水印本身更值得记。
高通差分复检:右下 260×120 窗口、d = 原图 − GaussianBlur(6)、数 d < −4 的暗点。三张归零,剩一张还有 3532 个,看着像没修干净。可那张的供体标准差本身就是 4.4——纸纤维自带这么多高频,供体取自同一张纸,不是没盖住的字。
换个不依赖差分的粗判据再扫一遍:右下角灰度低于 200 的像素,四张分别是 12、0、0、0。肉眼 1:1 放大对照,没有残影、没有接缝。
指标没归零不等于没修好,得先知道这个指标测的到底是什么。 一个分不清「文字」和「纸纹」的计数器,归零了也不能证明什么,没归零也不说明什么。
第五层:目录里有,不等于站里在用
写这篇之前我又数了一遍封面,撞出一个跟爬虫无关、但模样很像的问题:
# 按文件数
imgs = glob("public/images/banlu/*.png") # 40 张
# 按 frontmatter 实际引用数
covers = {re.search(r"^cover:\s*(\S+)", t, re.M).group(1)
for t in article_texts} # 34 张
目录里躺着 40 张 PNG,站里真正被引用的只有 34 张。剩下 6 张是早年做人物动作时留下的素材(站、坐、蹲、指、帽子之类),合计 6.45 MB,没有任何一篇文章指向它们。
我之前记的「全站 34 张封面」没错,那是按引用口径数的。可如果哪天顺手用一句 ls public/images/banlu/*.png | wc -l 来报数,就会多出 6 张、多出 6.45 MB,还会以为这 40 张都验过了。
计数之前先定口径。这句话听着像废话,但前四层里,「页面上有图」「标签里有地址」「文件存在」「体积达标」也是四个不同口径,我就是把它们当成一个用,才一路全绿。这一层跟前面几层原因不一样,毛病却长得一样:拿一个口径的答案,去回答另一个口径的问题。
这几层,其实不是同一个原因
摆在一起看,很容易顺手写成一句「都是验证没做到位」。但那样说太省事了,也经不起拆。
前两层是同一个原因:og:image 的读者是别人家的爬虫,不在我这条链上。我这边所有能随手做的检查——肉眼看页面、本地构建、看 HTTP 200——全落在生产侧,跟对面收不收货没有因果关系。这一层光靠「多加一条检查」补不上,得换个身份去检查:假装自己是那个爬虫,只拿 HTML,读 <head>,把每个地址指向的文件取回来,看格式、看体积、看画面,逐页做,不抽样。
第三层算半只脚在外面:文件确实要由对面的爬虫来取,但「两兆多算不算大」是我自己这边的判断,平台给的是上限,不是体感。
后两层干脆跟消费者无关。第四层是我拿一个分不清文字和纸纹的指标判自己的修复,第五层是我数文件时没定口径——两件都发生在我自己桌上,谁也没拦着我。把它们也算进「消费者在别人那侧」,读起来整齐,实际是把不利的证据顺进了想要的结论。
所以这篇能带走的其实只有一句:字段写给谁读,就得站到谁那边去验一遍;剩下那些,靠的是开图看、数之前先说清数的是什么,没有更漂亮的解释。
如果你也在给静态站配分享卡
- 逐页读
<head>里的og:image/twitter:image,和 frontmatter 的cover逐条对;别看页面。 - 凡是有默认值兜底的字段,单独验一遍「它到底有没有被覆盖过」。
- 用平台自己的调试工具看真实抓取结果,别用本地浏览器预览当验收。
- 读一遍平台文档的格式条款和「一页一图」条款,别只查尺寸和大小。
- 图片四项分开验:存在、格式、体积、画面。一项达标不带动另一项。
- 体积骤降先开图,确认画面还在,再记成绩。
- 报数前先定口径:按引用数还是按文件数,按目录还是按构建产物。
- 自检指标没归零时,先说清它测的是什么,再决定要不要继续修。
- 修完换个身份再跑一遍(只拿 HTML 的那一侧),别在自己屏幕上收工。
一句话
og:image 是写给别人的程序读的;我在自己屏幕上验得再全,也证明不了对面那头会不会收货。