我是如何把自己的书过度工程化的

查看原文 HN 讨论

文章摘要

Ben Balter(前 GitHub 员工)在 2026 年 8 月 17 日发表了这篇约 38 分钟阅读量的长文,记录他如何用软件工程的整套基础设施写出并自出版了一本名为《Open and Async》的书。他自己给的 TL;DR 是:用 Markdown 和 Git 写书,搭了一条 CI 流水线,把同一份源码变成五种格式,并运行数千项自动检查——从文风 linter 到一个抓出他在五个不同章节里重复同一论点的 LLM。AI 审阅了每一页,但每一个字都是他写的。开篇的自嘲很到家:「我的书有一个 linter,会因为我把 open source 写成连字符形式而对我大喊大叫。它运行一整套自动检查,每次 git push 都重建所有格式,而且只要我稍微暗示自己还在一份已经离开的工作岗位上,构建就会失败。为了一本书。一个人写的书。」

内容层。 每一章是一个独立的 Markdown 文件,一个 index.yml 定义章节顺序,便于重排或新增章节。编辑器是 VS Code 加若干文风扩展。一个值得注意的实践细节:他大部分内容是在 iPad 上写的——浏览器标签里的 Codespaces 加一个蓝牙键盘,经常是在离开办公桌的夜晚和周末,Git 仓库保证不管在哪里打开都同步;「一个坏主意距离消失只有一个 git revert」。

测试层。 本地实时运行的 VS Code 扩展有六个:Markdownlint(Markdown 语法与格式一致性)、Harper(语法与措辞,完全在设备上运行)、LanguageTool(语法、标点、文风)、Vale(他自己的家规与禁用词)、Alex(不敏感或排他性措辞)、Write-good(弱文笔:被动语态、模糊词、陈词滥调)。这六个在 CI 里也全部运行(Alex 和 Write-good 在 CI 里被折进 Vale)。合起来是「数百条精选文风规则叠加在一个完整语法引擎之上」。

CI 里还有他自己写的测试套件:一个独立的 Node 内容校验脚本、一个 Vitest 套件、以及 Playwright 规格。校验器是最有意思的部分,每个只有几行,读取 Markdown 并推出带文件与行号指针的错误。他最喜欢的那个:他已经不在 GitHub 工作了,所以任何声称他还在的句子都会让构建失败(用正则匹配「is/are … at GitHub」和「works/leads/runs/manages/directs at/for GitHub」两类现在时雇佣声明)。这个校验器的存在源于他犯过一次这个错,而这正是整套模式:第一次错误逃过他的眼睛时,他不只修那一句,而是写一条规则,「这样我就永远不必再靠肉眼去抓它」。他把这叫做「文稿的牛群而非宠物」(cattle, not pets for prose)——不要手工照护每一章,用策略治理整个牛群。这类校验器约有三十个,他点名的其他几个包括:validateOpenSourceHyphenation(open source 是名词不是动词,且永不加连字符)、validateHypotheticalHooks(段首出现公式化的 AI 味开场,如「Picture this…」「Imagine…」「Consider a…」)、validateSentenceStarters(连续三句以上用同一个词开头)、validateNoBareUrlLinkText(链接可见文字不能只是原始 URL)、validateCalloutBalance(书同时面向管理者和一线贡献者,所以一个「For managers」提示框附近必须有对应的「For ICs」)、validateCrossReferences(每个锚点交叉引用都必须指向真实存在的标题),另外还有两打左右针对小型大写、破折号、重复词、连接号范围、TL;DR 长度等等的规则。

有一条校验器抓到了他本人:validateHypotheticalHooks 标出了一段他确信是自己写的段落开头——而它确实是他写的。「但冷读一遍,它真的听起来像代笔的;我从读太多生成文本中吸收了那种韵律,产出了一份对虚无的流畅模仿。」他的结论是:linter 分不出好文笔和坏文笔,但它能标出你停止思考时会伸手去抓的那些模式,而这恰恰是你在自己的草稿里看不见的东西。CI 套件的规模是:约 70 个测试文件、2204 个测试用例、约 3900 个 expect() 断言,再加上上述校验器带来的约 1600 项逐章结构检查。

审计层。 linter 抓错误,但无法告诉他书是否在自我重复、某一章是否写得好。为此他建了三层重复检测:jscpd(token 级复制粘贴检测,能抓到较长的逐字块);n-gram(把 index.yml 里每一章分词、剥掉 Markdown/Pandoc 语法、构建词 n-gram,标出出现在多于一章的短语,并给出「最常重复的套话」排名;有跨章和章内两种模式);semantic(前两者做不到的那件事——同一个观点用不同的词重说一遍)。语义层是按需触发的 LLM 审计,设计上从不把整本书喂给模型,而是三次小单元遍历:intra(每章一次调用,「这一章在哪里重述了自己?」)、cross(对所有章节的 TL;DR 做一次调用,产出概念重叠章节的地图——「一次调用的代价换来全书扫描」)、arguments(逐章一次调用抽取核心主张,然后用最后一次调用把跨章的相同论点聚类)。

结果是:两个机械层最终跑干净了,但那是因为它们已经先抓出了复制粘贴和回收的措辞并被修掉了。语义层抓到的是更微妙的那类——已经没有逐字重复,只是同一个观点换了词说。它抓到他在五个不同章节里做了同一个论点:「把办公室习惯搬到线上,不等于远程优先地工作」;此外还有散布在 51 个章节中的 166 处更小的自我重述。「我把自己转述得太好了,便宜的手段抓不住我。」

内容审计还分概率性(LLM 读章节并给意见,跑两次结论可能变)与确定性(规则与算术,同一输入同一输出)两类。概率性审计里最有价值的是 Claims(主张)这一「镜头」:它在一句关于 GitHub 每月全员大会「大约把每场一半时间用于现场问答」的话上停住了——这个数字他本来确信无疑,但其实是错的,实际更接近三分之一,而且这个比例多年来一直在变。「没有 linter 会标出这个:这是干净、自信而恰好为假的散文。只有一个真正在问『这是真的吗?』的读者才能抓住它,否则我就会把它发出去。」Claims 是「文稿审计」测试里 20 个单一用途镜头之一,每个镜头只问一个狭窄的问题,好让模型无法用模糊的「看起来不错」蒙过去;--lens=all 跑所有镜头,--models / --rounds 增加去重后的多模型与自一致性小组,让一个发现必须在多于一个模型(或多于一次运行)中存活才算成立。文中列出的其他镜头包括:AI-tells(读起来像机器生成而非他的声音的措辞)、Hook(开场是否在做实事还是清嗓子)、Legal(法律或声誉风险,如点名、未经核实的主张)、Dated(会老得难看的时效性表述,如「最近」「今年」)、Global(会让非美国读者绊倒的习语和文化假设)、Dual-audience(一章是否同时服务管理者和一线贡献者)、Promise(一章是否兑现了全书的核心承诺)。此外还有一个特质分析测试,为每章的说服力与吸引力特质打分,抓出技术上干净但平淡的段落以便他再做一次人工修订。确定性审计包括章节与段落长度分布、全书阅读时长、一个硬性的 EPUB 体积预算(Kindle 会对超大文件的投递做惩罚)、全书一致性检查器,以及一个统计仪表盘,其 --check 模式要求每一项「注意事项」(缺 TL;DR 的章节、不平衡的提示框)都为零,否则构建失败。仪表盘上《Open and Async》的数字是:总词数 99651,阅读时长 399 分钟,72 章,平均每章 1384 词,103 个管理者提示框、102 个 IC 提示框、123 条 pro tip、62 个「异议」(objections)

设计层。 他坦承自己远不是设计师,也从未出版过电子书。两个「顿悟」改变了他对出版的理解:「电子书就是穿着风衣的网站」——电子书只是 HTML 和 CSS,只不过是极度精简的版本,能做网站就能做电子书;以及只要肯下功夫,印刷书也可以是网站——CSS 原生就有强大的 print 媒体查询和分页规则,包括左右页样式、标题页、页码等等。他用 Tailwind CSS 做内文设计(不是因为它为书而生,而是因为这是他每天在用的东西),Typography 插件提供了排版基线。封面则特意雇了人类设计师:「对于一本讲真诚的书,第一印象必须是真诚的。」

有一个「看不见的泄漏」故事很典型。为了把电子阅读器的调整隔离在平装本之外,他把这些规则限定在「非 screen」媒体下;但绘制平装本 PDF 的引擎 WeasyPrint 以 print 媒体类型渲染,而 print 正是「非 screen」——完全符合规范。于是两条电子阅读器规则渗进了印刷版:提示框段落更紧的行高,以及一处 box-decoration-break 的改动,导致跨页的提示框丢掉了重复的内边距。两处都不可见,什么都没坏,但每个提示框都被削掉了一丝垂直空间,整本书从 576 印刷页慢慢瘪到了 567 页。他之所以发现,是因为印刷 PDF 的页数被钉死在 576 页、构建变红了——一个为保护封面书脊而写的页数闸门,抓到了一个它从未被设计去抓的 CSS bug。修复方式是在仅限印刷的区块里用 !important 大声地重新钉住原值。

设计与排版也有测试套件,由 Playwright 针对构建好的 HTML 运行,检查真正渲染出来的东西而不是他以为 CSS 做到的事。两类:可访问性(axe-core 在明暗两种模式下按 WCAG 2.1 AA 扫描整本书——无 critical 或 serious 违规、两种配色下都满足 AA 对比度、每张图都有 alt 文本、标题顺序合理、链接文字可辨识、声明 lang 属性以便屏幕阅读器选对发音);视觉与布局回归(断言实际计算样式符合设计意图——标题页居中且副标题字重更轻、目录是带 doc-toc role 的真实导航且链接可用、每种提示框有独一无二的左边框颜色和自动生成的标签前缀、小型大写是真正的 font-variant 而不只是一个类名)。「这些都不光鲜。这恰恰是那种在周五重构 CSS 时无声无息坏掉、直到读者发邮件来才浮现的东西。」

构建层。 核心是 Pandoc。但 Pandoc 只是引擎,完整构建还需要 WeasyPrint 画 PDF、Ghostscript 做转换,以及一堆 Noto 字体来覆盖完整 Unicode——一整套难搞的原生依赖,所以整个环境装在一个 Docker 镜像和 dev container 里,并钉死渲染器的精确版本(这个细节比想象中更重要,见上文页数闸门)。这个容器也是他能在 iPad 上写书的原因:重型工具链跑在 Codespaces 里,而不是他膝上。预处理阶段在源码树的一次性副本上进行(所以这些变换从不触碰真实章节):给每次构建打上 commit SHA、把行内链接转成印刷版的编号脚注(纸上超链接没用,但带完整 URL 的脚注有用)、按格式重排章节(EPUB 得到可分享的引文链接,印刷版和 Kindle 换成一个二维码分享页)。CSS 流水线是一份 Tailwind 样式表经 PostCSS 编译,屏幕、印刷、EPUB 共用同一份真相来源,再各取其一片:一个 strip-page-rules.js 为 EPUB 导出精简版,剥掉 Kindle 和 EPUBCheck 会呛住的 CSS 分页媒体特性。

他说自己没想到会爱上的部分是 Pandoc 的 Lua 过滤器:Pandoc 把一切解析成抽象语法树,允许你在渲染前用小 Lua 脚本重写这棵树,于是格式相关的调整活在代码里而不是抹在文稿里——删掉一个节点只需返回一个空表。他举的例子有:strip-comments.lua(丢掉编辑用的 HTML 注释,让笔记永不出现在已发布的 xhtml 里)、add-div-titles.lua(注入提示框标签和 DPUB-ARIA 可访问性 role,标签不必硬编码在每一章里,还能按格式不同)、三种 emoji 处理(彩色 emoji 在屏幕上没问题,strip-emoji-kindle.lua 为 Kindle 换成安全字形,因为电子墨水没有 emoji 字体;body-emoji-images.lua 为印刷 PDF 把它们变成行内 Twemoji 图片)、tagline-share-links.lua(在 EPUB 里每条「保险杠贴纸式」金句后追加一个「分享这个想法」永久链接)。

由此产生了「黑盒子」这个更微妙的 bug:Twemoji 的 PNG 是透明的,但透明像素底下的 RGB 是深板岩色——渲染器尊重 alpha 通道时不可见,不尊重时就是一个丑陋的炭灰方块,而是否尊重 alpha 完全取决于书在哪里被打开。他在 Kindle 上的第一版修法是把提示框背景色烤进每个 PNG 并干脆丢掉 alpha:在浅色模式的 Paperwhite 上完美,在确实尊重透明度、又不在图片后面绘制提示框底色的 Kindle for iOS 上糟糕——每个 emoji 变成了彩色矩形,「我把一个盒子换成了另一个盒子」。最终发布的版本保留 alpha,但把每个完全透明像素的 RGB 改写成白色:尊重 alpha 的渲染器得到干净透明,把它压平的渲染器得到白色,在白页上消失。印刷 PDF 需要的却是相反的修法:Ghostscript 用 -dNOTRANSPARENCY 把平装本转成 PDF/X-1a(为了让文字保持可选中的矢量而不是 300 DPI 位图,这个标志是必需的),而它总是丢掉 alpha;所以在那里透明才是敌人,每个 emoji 都被压平到它所处的确切颜色上(提示框的灰,或正文的白),完全没有 alpha,也就没有东西可丢、没有盒子会露出来。「同一个 bug 需要两种相反的修法——一个运行时可能丢掉 alpha,另一个保证会丢。」一个 Python 脚本在构建时把两套都烤好。「这是世上最普通的 bug,只不过我的这个恰好在一本书里。」

渲染与后处理按格式各走一条路,平装本走得最远:HTML 和 PDF 都经 Pandoc 渲染,但 PDF 由 WeasyPrint 绘制,「我用和做网页一样的盒模型排完了整本书」;EPUB 走另一条路,Pandoc 嵌入 WOFF2 字体子集,然后 postprocess-epub.js 重新打包以保持有效且小巧;平装本在别人都停下后继续走到 Ghostscript,变成 PDF/X-1a:2001——CMYK 色彩、嵌入 USWebCoatedSWOP ICC 配置文件、压平透明度——按需印刷发行商 IngramSpark 离不开的那个古老印刷标准。check-pdf-page-count.js 钉住页数,一旦平装本偏离 576 页构建就失败,「便宜的保险,防止过时的页数变成尺寸错误的封面书脊」。

构建的测试。 文风 linter 让文字诚实,Vitest 套件中更大的一片让构建诚实。一次 Pandoc 升级或一行乱入的 CSS 都可能静默地弄坏某个格式,而他要到读者的 Kindle 渲染出错误字体时才会知道。测试检查机械部分——每个变换是否做了它声称的事,以及(数量最多的一簇)排版是否在小型大写、表格边框、印刷对比度、深色模式和电子墨水 emoji 回退上悄悄漂移。「每一项都是一个测试,因为每一项都是我曾经见过构建看起来没问题却渲染错误的一种方式。」但单元测试只证明他的代码对,不证明输出有效,所以每个产物还要过一遍商店自己用的那些校验器,「拒绝发生在我的笔记本上,而不是上传日」:每个 EPUB 都过 EPUBCheck(每个商店在上传时都会跑的校验器)和 Ace by DAISY(专为电子书打造的可访问性审计);构建出的 HTML 由 lychee 爬取检查链接腐烂,并在畸形标记变成畸形电子书之前通过 HTML 校验器。这一切都不需要他记得去跑:每次 push 到 main 都会启动 14 个并行 CI 作业——先构建 CSS,然后并排构建 EPUB、Kindle EPUB、印刷 PDF、PDF/X-1a、HTML 和 DOCX,并在每个产物就绪时校验它。文中截图显示的作业清单是 lint-and-test、detect-duplicates、build-css、build-epub、build-kindle、build-pdf、build-html、build-docx、Build PDF/X-1a,然后是 validate-epubcheck、validate-ace、validate-links、validate-html、validate-playwright,全绿,总耗时 5 分 8 秒。因为每次构建都把 commit 戳在标题页上,这本书像软件一样有版本:他当时在 1.0.1 版,「有 tag 和 changelog,而不是一片 final_v3_revised_ACTUALLY_FINAL.docx 的坟场。一处错字修订就是一个补丁版本」。

出版层却几乎完全是手工点击。没有 git push 到生产。每个商店——亚马逊 KDP、面向书店和图书馆的 IngramSpark、面向 Apple Books 和 Kobo 的 Draft2Digital——都要你登录网页后台、手工上传 EPUB 和印刷 PDF,并把同样的元数据(标题、描述、BISAC 分类、关键词、价格)重新填进一个每次都略有不同的表单。「我的流水线构建出一个完美无缺的产物,然后我像 2009 年那样把它上传上去。」不过有三件事让这部分也保持诚实:他成立了 LLC 并自己买了 ISBN(每种格式一个),这样目录里列的出版者是他自己,而不是带零售商名字的免费平台 ISBN,也避免了免费 ISBN 只能通过发放它的平台出版所带来的锁定;选择「广泛发行」而非独占,跳过了给钱更多但把书锁死在亚马逊的 KDP Select;元数据也进了版本控制,描述、分类和关键词都在仓库里作为单一真相来源,可以 diff,并从中生成 ONIX feed 供接受这种格式的渠道使用,「我仍然手工把它粘进网页表单,但至少我粘的是一份受版本控制的文件」。

展望。因为内容是结构化的 Markdown,翻译更接近本地化一个应用而不是重新打一遍手稿:他已经有一条流水线(巴西葡萄牙语、拉美西班牙语、德语),会起草译稿、钉住每个标题 ID 以免交叉引用断掉、强制执行术语表以保持关键术语一致,并把结果回译以对照原文。有声书方面,如果他去录,把十万字变成朗读会带来一整类新 bug——发音错误——所以有声书有自己的 QA 套件:把生成的音频转写回文本并与脚本 diff、检查棘手词的音素、审计发音,全部接进各自的 CI 工作流,「一个瞄准我自己声音的快照测试」。他还想把这些工具(校验器、Lua 过滤器、整条流水线)清理后开源,「这样下一个人不必从零造」,并说这一切之所以存在,全靠它已经站在的那些开源项目——「整本书就是别人的慷慨编译而成」。

结论与数字表。词数约十万、约 70 章与小节;印刷长度 576 页(6 英寸 × 9 英寸);已发布格式 5 种(另加一个不发行的 DOCX);测试约 70 个文件、2204 个用例、约 5500 项自动检查;每次 push 14 个并行 CI 作业;文风规则约 300 条、覆盖 500 多个禁用词,之上还有一个 5000 条规则的语法引擎;提交 5000 多次;书 1 本(「坦白说过度工程化了,但很好玩」)。面对「这不就是把拖延症打扮成严谨吗」的自问,他的回答是:过度工程化,绝对是;但拖延不是——这五千次提交里只有大约五分之一碰到了构建工具,另外四分之四是书本身,「每一项检查都抓到了我否则会发出去的东西。会再来一次吗?毫不犹豫」。他还把整套装置指向自己这个博客十五年的存档,抓到了一篇 2011 年的文章一直把「it’s clear」写成「its clear」——正是他的 linter 现在每次敲键都会标出的那个缺失的撇号。「成千上万人读过它,没人提过。你不会知道。你必须去找。」他强调习惯也一起迁移了:把抓到过一次的错误当成永久抓它的规则;对任何机器起草的东西保持人在环中;让构建成为真相来源。最后的立场很克制:「我不会叫你为你的小说写 5500 个测试。对大多数书来说,这里的大部分都极不相称——而这正是重点。」他做是因为多一项检查、多一种格式、多一个校验器的边际成本只是几分钟和一点好奇心,也因为用开发者的方式做,让他能在离开办公桌的 iPad 上写作并信任 main 永远可发布。「出版业从文字处理器开始。我从一个 Git 仓库开始——我还会再从那里开始。」

HN 评论精华

这条帖子只拿到 53 分、47 条评论,是这一组里分数最低的一篇,而且讨论几乎完全跑偏到了一个作者本人没预料到的方向:评论者们认定这篇文章(以及这本书和它的营销落地页)读起来像 AI 生成的。围绕这一点爆发了本帖最长、最激烈的争论,作者 benbalter 全程在场并做了相当坦诚的回应。另一条支线是善意的「这是结构化拖延症的教科书案例」的调侃,以及几个人分享自己类似的工具链。