三十四篇文章的分享卡,用的是同一张默认图

三十四篇文章的分享卡,用的是同一张默认图

半路手记 · 踩坑实录

页面全绿,分享卡全错

发现问题的那会儿,站上有三十四篇文章,每篇的 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 是写给别人的程序读的;我在自己屏幕上验得再全,也证明不了对面那头会不会收货。