← 全部文章

我为什么做 Tweet to Image —— 以及差点让它夭折的那个细节

· 约 6 分钟

我做这个东西,是因为要把一条推文放进一张幻灯片,结果花了四十分钟也没弄好。整个起因就这一句。剩下的都是后来发生的事,包括我差点把仓库删掉的那一周。

那四十分钟

一条三行的推文、一张幻灯片,以及把前者放进后者的每一条路都差那么一点。

我先用手机截了图。1170px 的手机屏,推文大概占一半,外面裹着我自己的时间线:下面的回复框、露出头的下一条推文、角落里我的头像、剩 23% 的电量。

那就裁。裁完是 900px 的发糊灰字压在一个白矩形上,放在深色背景的幻灯片里,外面套着一个我去不掉的白框。我又在笔记本上试了一次,更锐一点,还是白框。于是我打开 Figma,开始手工重画这张卡片 —— 名字、用户名、时间、下面那排小图标。

就是在那一刻我停下了。如果我这次要手工排一遍,那我以后每次都得手工排一遍,而且每次都会排得不太一样。所以我关掉 Figma,写了这个工具的第一版。

第一版能跑,而且是错的

一个下午就能渲染出一条推文。它也确凿无疑地像「一张推文的画」。

我给谁看,谁都说同一句没用但正确的话:「看着有点怪。」没人说得出为什么,包括我自己。搞清楚「怪」其实是四个各自独立的数值,花的时间比我愿意承认的长。

一张带标注的推文卡片,标出决定它像不像真的四个数值:598px 正文栏、40px 头像、名称/蓝V/用户名/时间同处一行、以及正文下方的操作栏。 40px 头像 名称 · 蓝V · 用户名 · 时间 全在同一行上 操作栏;填 0 只留图标 598px 正文栏 —— 不是「六百左右」
这几个都不难做对,但只有做错时才看得见 —— 这正是「看着有点怪」这句反馈如此昂贵的原因。

差点夭折的那一周

字体我对不上,有几天我以为这就意味着「这里根本没有产品」。

X 的正文用的是一款专有字体。我打包不了它,也不打算在字形明显不一样的情况下宣称「像素级还原」,而且这件事没有什么聪明的绕法。那大约一周里,这个项目就那么停在「对了 90%」的状态上 —— 而正是那 90% 让剩下的 10% 变得无法忍受。

然后我去看了中文是怎么回事。X 的正文字体根本不含 CJK 字形 —— 也就是说,X 上的中文本来就在 fallback 到系统字体:苹果设备上是 PingFang SC,其他平台是 Noto Sans SC。如果我用同一条 fallback 链,中文推文就不是「大致正确」,而是同一种渲染 —— 因为浏览器做的事,和 X 那边的浏览器做的事一模一样。

这件事把整个项目重新定了性。诚实的说法从来就不是「像素级还原」,而是:在浏览器能达到的范围内尽量接近,并且在由浏览器说了算的地方完全一致。所以我改的是说法,不是产品,把这条局限写进了关于页让任何人都能读到,然后发布。

我觉得这是这篇文章里最有用的一条。失败的不是技术,是我给自己许了一个兑现不了的说法;而解法是不再许那个说法,而不是继续在字体上磨。

花时间最长、也最要紧的那个细节

零。在 X 上,一条没人转发的推文不显示 0 —— 它只显示图标,别的什么都没有。

我的第一版在每张卡片下面都端端正正写了个 0,这是最大的一个破绽 —— 现在互动数据把 0 当作「隐藏这个数字」。修掉之后,同一类细节接二连三地冒出来:

我最不自在的那部分

一个能做出可信推文卡片的工具,就是一个能做出可信假推文的工具。这个项目不存在让这句话不成立的版本。

我想过不发布。反对不发布的理由并不让人舒服,但我认为它是对的:这个能力在每个截图工具、每个图像编辑器和大约四十个别的网站里都已经存在了。压下这一个不会让这个能力消失,只会让「试图守规矩的那一版」消失。

所以它实际上做了这些事。每一张导出的 PNG 都在文件元数据里带一条声明,标明这是合成图像;它肉眼不可见、不改变画面的任何一个像素,任何标准图片工具都能读出来。工具不从 X 抓取任何东西 —— 文字是你自己敲进去的,所以不存在「贴个链接就得到一份真推文的完美复制」这条路,而那条路才是让批量伪造变容易的路。服务条款禁止冒充他人,也禁止去掉这条元数据。

还有边界,因为只说前半句是不诚实的:元数据是可以被剥掉的。真想干的人一条命令就能删掉。它是一个减速带和一个态度声明,不是一个保证。比起宣称一个我根本无法执行的保证,我宁愿做一个减速带,并且如实说它就是个减速带。

为什么没有服务器

因为另一条路意味着我要保管一堆别人说过的话,而我不想要它。

一切都在你的浏览器里渲染。卡片就是普通的 HTML 和 CSS,导出就是对同一个页面的一次截取。出图链路上没有图片服务器,也没有任何 API 调用。

带来的结果几乎全是好的。页面加载完之后断网也照样能用。运行成本基本为零,所以它可以真的免费,而不是「先免费以后再说」。它泄不了你输入的东西,因为你输入的东西从没离开过那个标签页 —— 这也是隐私政策很短的原因:实在没多少可披露的。

代价是浏览器差异得我自己扛。比如 Safari 的剪贴板要求写入必须和你的点击处在同一个任务里,所以渲染结果得作为一个 Promise 交出去,而不能先 await。这个问题曾经存在过,永远不会有人察觉 —— 而这正是重点。

第二件差点没成的事

站点当时是个单页应用,抓取器来了只看到一个空 div。

有一段时间首页是纯客户端 React。人看到的是完整页面,Googlebot 大致也能应付。但 GPTBot、ClaudeBot、PerplexityBot 这些不执行 JavaScript 的抓取器,看到的是一个空的根元素,别的什么都没有:没有标题、没有描述、没有任何可以引用的东西。

一个用抓取器读不到的文字来解释自己的工具,是一个没人找得到的工具。解法是在构建时把页面渲染成真正的 HTML —— 一点也不光彩,花了一个下午,对「有多少人能走到这里」的作用比我在卡片本身上做的任何事都大。

同一个首页的两种样子:一个空的根元素 —— 不执行 JavaScript 的抓取器只能看到这些 —— 以及构建时渲染成真 HTML 之后的同一页。 纯客户端渲染 <div id="root"> </div> 没有标题,没有正文 可引用内容:0 词 构建时预渲染 <h1>Tweet to Image … <p>A Twitter screenshot … <h2>What is Tweet to … 整页都在,就是 HTML 约 2,900 词的真实标记
Googlebot 大致能应付客户端渲染,但 GPTBot、ClaudeBot、PerplexityBot 根本不执行 JavaScript。对它们来说,构建时渲染就是「可被引用」和「完全看不见」之间的差别。

不打算加的东西

推文串、媒体、引用推文、投票。

不是因为难,而是因为每加一样,「可信的假货」的面积就大一圈。单张纯文字卡片是一句带着头像的引文;一整条配了图的伪造推文串是另一种东西,我不想成为让那件事变容易的人。

还有一个不那么高尚的理由:单卡片这一版就是我当初为那张幻灯片需要的东西,而它已经做完了。做完的工具很少见,我挺想有一个。

如果你也在做类似的东西

四条我想对开工那天的自己说的话。

这一切最后做出来的东西就在首页,免费。

打开工具

这篇用到的设置

这篇文章依赖的使用说明小节:

继续阅读