Day 199 — 技术写作
阶段:Phase 12 — 综合实战(总复习第 4/5 天)
主题:信息结构 · 可复现实验 · Markdown 静态站 · 把真机实验写成可发表文章
代码:3 个可独立运行的脚本(全部带--self-test,离线可跑),共 830 行
实测环境:Linux 7.0.0-34-generic / Python 3.12.3 / Intel Xeon Gold 6326 @2.90GHz / 4 核 / load1=5.35 / git94165cf/ 2026-10-29 06:00 CST
今天在 200 天里的位置
Day 196 知识体系全景复习 → 把学过的变成地图 ✅
Day 197 面试准备 → 把地图变成口头能力 ✅
Day 198 开源贡献 → 把能力放进别人的仓库 ✅
Day 199 技术写作 → 把经验变成可传抄的文字 ← 今天
Day 200 持续成长路径 → 把方法固化成长期系统
前 198 天,输出基本是给自己看的:代码能跑、笔记能查,就够。
今天换一个更苛刻的读者模型——一个不在你工位上、不知道你的机器、也不会问你问题的人。
他读你的文章时只有三个问题:
- 你说的这件事,凭什么成立?
- 换到我的机器上,能不能复现?
- 我要多花多少时间才能判断值不值得读?
技术写作的全部技巧,都可以还原成对这三个问题的回答。
概念速览
| 概念 | 一句话 | 今天的落点 |
|---|---|---|
| 读者模型 | 不在场、无上下文、可随时离开 | 概念 1.1 |
| 信息结构 | 结论先行,证据分层 | 概念 1.2 |
| 可复现性契约 | 环境 + 命令 + 样本量 + 统计量 + 输出哈希 | 概念 1.3 / 代码 02 |
| 断言分级 | 事实 / 推断 / 观点,各自需要不同证据 | 概念 1.4 |
| 代码围栏状态机 | 围栏内的字符不参与结构解析 | 原理 2.2 |
| 固定成本 | 解释器启动约 20 ms,小样本里占比巨大 | 原理 2.4 |
| 锚点一致性 | TOC 的 href 必须等于标题的 id | 原理 2.5 |
| 先转义后替换 | 否则用户内容会变成真标签 | 原理 2.6 |
| 静态站 | 构建产物即站点,无运行时依赖 | 概念 1.6 |
一、概念解释
1.1 技术写作的读者模型:一个不在场的人
写代码时,你和执行环境在同一个闭环里:跑一下就知道对不对。
写作打破了这个闭环——读者执行的是你的文字,不是你的程序。
所以技术写作的所有规范,本质上都在补偿"作者不在场"这件事:
- 环境指纹 → 补偿"我不知道你的机器"
- 完整命令 → 补偿"我看不到你的终端"
- 真实输出 → 补偿"我没法验证你说的是不是想象"
- 结论先行 → 补偿"我随时可能关掉页面"
一句话:技术文章是把一次私人实验,改写成别人可执行的指令。
1.2 信息结构:结论先行,证据分层
技术读者不是来听故事的,是来取结论的。所以结构上要满足"三层可退出":
第 1 层 标题 + 首段 → 30 秒判断:这篇和我有关吗
第 2 层 小标题 + 表格 → 3 分钟扫读:结论是什么
第 3 层 实验手册 + 代码 → 30 分钟精读:怎么复现
任何一层都应该是自洽的:只读第 2 层的人,也能拿走可用的结论。
这就是为什么"实验手册"要独立成章,而不是散落在正文叙述里。
1.3 可复现性契约:五个字段缺一不可
"我测过"不是证据,"这些条件下我测到这些数字"才是。最小契约:
| 字段 | 回答什么 | 缺失后果 |
|---|---|---|
| 环境 | 哪台机器、哪个版本 | 读者无法迁移结论 |
| 命令 | 到底跑了什么 | 读者只能猜 |
| 样本量 | 跑了几次、取哪个统计量 | 单次数字可能是噪声 |
| 原始输出 | stdout/stderr 原文 | 无法判断是否被美化 |
| 输出指纹 | 哈希是否稳定 | 无法判断结果是否随机 |
02-repro-card.py 就是把这张表变成一条命令:它执行命令 N 次,记录环境指纹、
耗时分布、每次 stdout 的 sha256,并给出"输出是否可复现""计时是否可信"两个判定。
1.4 断言分级:不是所有句子都需要实验
写作最耗时的误区,是给每句话都找证据。实际上句子分三级,证据要求不同:
| 级别 | 例子 | 需要什么 |
|---|---|---|
| 事实 | "Python 3.12 的 dict 保持插入序" | 出处或一行可跑的验证 |
| 推断 | "这个写法在热路径上更省" | 说明推理链 + 至少一次测量 |
| 观点 | "我不喜欢这个 API" | 说清立场即可,不要伪装成事实 |
体检器拦不住你写观点,但它能拦住把观点写成事实——这正是"显然"这类词的危害:
它把一个未经证明的判断,伪装成了共识。
1.5 为什么用 Markdown 而不是 Word
不是因为好看,而是因为可计算:
- 结构可解析 → 能做标题层级检查、TOC、锚点
- 纯文本 → diff 可读、能进 git、能被 lint
- 语法少 → 渲染器可以自己写,零依赖
今天的体检器、构建器都建立在"Markdown 是可解析的结构,而非排版格式"这个前提上。
1.6 静态站:把发布成本压到零
技术博客的访问曲线是长尾的:发布当天几十次,之后每月几次。
为这种负载上数据库和框架,是典型的过度工程。
静态站的取舍很清楚:
- 得到:零运行时、零漏洞面、CDN 直接托管、构建产物可 diff
- 放弃:评论、搜索、动态聚合(都能用外部服务补)
03-article-build.py 用 380 行做到:front matter 解析、Markdown 子集渲染、
TOC 锚点、阅读时长、首页、RSS、.nojekyll。
1.7 写作的反馈回路
写 → 体检(结构) → 实测(数字) → 发布 → 读者复现 → 修正
↑______________________________________________|
体检器和实验卡片只覆盖前两步。真正的质量来自第四步:有人照着你写的跑了一遍。
所以文章里要留下"怎么告诉我它跑不通"的入口(仓库 issue、邮箱),这是写作的一部分,不是客服。
二、原理解释
2.1 为什么"显然"是一种认知负担转移
"显然 X" 的信息量不是 X,而是"我判断 X 不需要解释"。读者收到的是两件事:
- 一个未证明的结论;
- 一个"你不该问"的暗示。
第二件更贵:它让读者要么接受一个黑箱,要么自己补证明。技术写作里,
任何让读者补证明的句子,都应该被删掉或改写。
改写模板:
- 差:
显然这样更快(读者无法据此行动) - 好:在 30KB 输入、重复 5 次、取中位数的条件下,方案 B 比 A 快 12.4 ms
2.2 代码围栏状态机:一个 continue 决定工具的死活
Markdown 的 # 既是标题标记,也是 Python/shell 的注释符。所以解析器必须是状态机:
+--------------+ 遇到围栏 +--------------+
| 普通文本态 | -----------> | 代码块态 |
| 解析标题/列表 | <----------- | 原样收集 |
+--------------+ 遇到围栏 +--------------+
实现只有六行,但漏掉 if in_fence: continue 就会让代码里的每个 # 注释 变成一级标题:
m = FENCE_RE.match(line)
if m:
if not in_fence:
in_fence = True
if not m.group(2):
add("E003", "error", i, "代码块没有标注语言")
else:
in_fence = False
continue
if in_fence:
continue # 这一行是地基
体检器的自测里专门有一条反例:代码块内的 # 这是注释,不是标题 不允许触发 E001。
2.3 为什么数字必须带条件:噪声的三个来源
任何一次计时都混着三种成分:
| 来源 | 表现 | 对策 |
|---|---|---|
| 固定成本 | 解释器启动、import | 报告时说明含不含启动 |
| 调度抖动 | 同机其他进程抢占 | 重复 N 次取中位数 |
| 真实差异 | 你要测的那部分 | 用相对差而不是绝对值 |
今天的实测里,30KB 输入的极差/中位是 22.8%,636KB 只有 12.6%。
如果只跑一次 30KB,完全可能把调度抖动当成"优化生效"。
2.4 实测:固定成本约 17 ms
三档输入的中位数:44.06 / 59.18 / 130.84 ms,字符数 30,483 / 156,684 / 636,684。
- 30KB → 156KB(同一台机器、5 次取中位数):字符 ×5.1,耗时 ×1.34
- 156KB → 636KB(同样条件):字符 ×4.1,耗时 ×2.21
第一段明显"不线性",差值就是固定成本:设 t = a + b * chars,
第二段给出 b ≈ 0.155 微秒/字符,代回第一段得 a ≈ 17 ms(含解释器启动与 import)。
这个估算不精确,但它解释了为什么小文件上的百分比噪声更大——分母太小。
2.5 锚点一致性:TOC 和标题必须由同一个函数生成
第一版我用 slugify() 同时生成文件名和标题 id,结果中文标题被转成 post-a15a2f6c:
技术上能用,但读者拿到的 URL 是一串哈希,且自测里 TOC 与标题 id 对不上
(TOC 用原文、id 用哈希)。
修正:拆成两个函数,职责分开。
anchor_id():给页面内锚点用,保留中文,只折叠空白、去掉标点slugify():给文件名/URL 用,ASCII 化,纯中文时用稳定哈希兜底
教训:一个函数服务两种用途,最后一定有一种被牺牲。
2.6 转义顺序:先 escape,再替换标记
行内渲染必须严格按这个顺序:
原文 → html.escape() → 识别行内代码/粗体/链接 → 输出 HTML
反过来(先替换再转义)会把自己的 <code> 标签转义掉;而只转义正文不转义代码块,
则代码里的 <script> 会真的执行。自测里有两行专门验证这件事:
check("<script>" in frag, "正文里的 <script> 被转义(不执行)")
check("x = 1 < 2" in frag, "代码块里的 < 被转义")
2.7 阅读时长公式:把假设写出来
weighted = 正文字符数 + 代码行数 * 20,再除以 400 字/分钟。
代码行按 3 秒折算成 20 字,这个系数是拍的——但它写在代码里,读者可以改。
这比"约 5 分钟"这种黑箱数字诚实:读者能算出你凭什么说 5 分钟,也能按自己的速度调整。
三、定义与使用方法(速查)
3.1 三件套
cd days/day-199-技术写作
# 1) 结构体检:单篇 / stdin / JSON / 严格模式
python3 code/01-article-lint.py article/post.md
cat article/post.md | python3 code/01-article-lint.py -
python3 code/01-article-lint.py article/post.md --json
python3 code/01-article-lint.py article/post.md --strict # warning 也算失败
# 2) 实验卡片:跑 N 次,出可粘贴的 Markdown
python3 code/02-repro-card.py --repeat 5 -- python3 -c "print(sum(range(1000)))"
# 3) 静态站:构建 / 本地预览
python3 code/03-article-build.py article --out dist
python3 code/03-article-build.py article --out dist --serve 8899
三个脚本都支持 --self-test,不需要网络、不需要外部服务。
3.2 体检规则表
| 代码 | 级别 | 检查内容 | 为什么 |
|---|---|---|---|
| E001 | error | 多个 H1 | 一篇文章一个主题 |
| E002 | error | 标题跳级(H1→H3) | 破坏目录层级语义 |
| E003 | error | 代码块无语言 | 读者失去高亮与复制信心 |
| E004 | error | 空标题 | 结构占位符会误导 TOC |
| E005 | error | 缺少 H1 | 无法确定文章标题 |
| W101 | warning | 段落 > 400 字 | 移动端阅读负担 |
| W102 | warning | 模糊断言词 | 举证责任转移 |
| W103 | warning | 数字无条件 | 结论不可迁移 |
| W104 | warning | 遗留 TODO: | 半成品不该发布 |
| W105 | warning | 图片无 alt | 无障碍与弱网 |
| W106 | warning | 表格单列 | 分隔符写错 |
| W107 | warning | 链接无可读文字 | 屏幕阅读器只读 URL |
| W108 | warning | 全文无代码块 | 技术文章应有可执行内容 |
评分:100 - 10 * error - 3 * warning,下限 0。一个 error 抵三个 warning,
这是产品判断而不是科学:结构错误直接毁掉可读性。
3.3 实验卡片字段
| 字段 | 来源 | 用途 |
|---|---|---|
| 命令 | shlex.join(cmd) |
可复制 |
| 环境 | platform + /proc/cpuinfo + os.getloadavg |
可迁移 |
| 时间 | UTC + git HEAD | 可追溯 |
| 耗时 | min / median / max / 极差比 | 可判断噪声 |
| 判定 | 输出哈希是否全等 | 可判断随机性 |
| 原始输出 | 最后一次 stdout/stderr | 可核对 |
3.4 发布前检查(人工部分)
体检器能查大部分机械项,最后一条得自己读:
- 只有一个 H1,层级不跳
- 代码块都有语言
- 没有"显然/众所周知",每个断言有出处
- 每个数字带环境、样本量、重复次数、统计量
- 至少一段命令能照着跑
- 图片有 alt,没有遗留 TODO
- 一个不在场的读者,能不能只靠文字复现你的结论
四、图解
4.1 从实验到文章的流水线
真实实验 写作 机械检查 发布
+----------+ +----------+ +------------+ +----------+
| 跑命令 | ----> | 结论先行 | --> | 结构体检 | --> | 静态站 |
| 抓环境 | | 证据分层 | | 误报修复 | | RSS/锚点 |
| 存哈希 | | 实验手册 | | 评分 >= 90 | | HTTP 200 |
+----------+ +----------+ +------------+ +----------+
| |
+--------- 读者复现失败 -> 回到第一步 ------------------+
4.2 体检器的两遍扫描
第一遍:行级状态机
line --+-- 围栏? --> 切换 in_fence(内容不解析)
+-- 标题? --> 记录层级、检查 H1/跳级
+-- 表格? --> 检查列数
+-- 其他 --> 累积成段
第二遍:段落级措辞
段落 --> 剥离行内代码与引号 --> 查模糊词 / 数字条件 / 长度
4.3 锚点与目录的一致性
## 为什么要写
|
+-- anchor_id("为什么要写") --> id="为什么要写"
+-- TOC 条目 href="#为什么要写"
^
同一个函数,必然一致
更多 Mermaid 图见 diagrams/README.md。
五、实战代码案例
5.1 01-article-lint.py:把写作规范写成可执行规则
245 行,核心是一个 for 循环 + 一个段落缓冲区。真实输出(对故意写坏的稿子):
📝 days/day-199-技术写作/article/draft-with-issues.md
行数 19 / 字符 370 / 标题 4 / 代码块 1
评分 65/100 错误 2 警告 5
❌ L3 E001 出现第二个 H1:一篇文章只能有一个标题
⚠️ L7 W102 模糊断言「显然」:给出证据或删掉
⚠️ L7 W102 模糊断言「众所周知」:给出证据或删掉
⚠️ L7 W102 模糊断言「大家都知道」:给出证据或删掉
❌ L9 E003 代码块没有标注语言(读者失去语法高亮)
⚠️ L15 W104 遗留 TODO/TBD:发布前处理掉或明确标注为邀请协作
⚠️ L19 W103 性能/数量断言缺少测量条件(样本量、环境、重复次数)
改完之后的终稿:
📝 days/day-199-技术写作/article/post.md
行数 133 / 字符 2835 / 标题 10 / 代码块 6
评分 100/100 错误 0 警告 0
✅ 没有发现问题
5.2 02-repro-card.py:把"我测过"变成可核对的卡片
205 行。关键设计是"两次判定":输出是否稳定、计时是否可信,分开判断。
stable_output = len(hashes) == 1 # 结果可复现
spread = (max(times) - min(times)) / median
noisy_timing = spread > 0.20 # 计时不可信
真实卡片(30KB 输入,5 次):
- 命令:python3 01-article-lint.py /tmp/day199/small.md
- 环境:Python 3.12.3 / Linux 7.0.0-34-generic / x86_64 / 4 核 / load1=5.35
- 时间:2026-10-28T22:01:45Z(UTC),commit 94165cf
- 重复:5 次,取 median
- 耗时:min 40.66 ms · median 44.06 ms · max 50.72 ms(极差/中位 22.8%)
- 判定:✅ 输出可复现;⚠️ 计时噪声大
注意"输出可复现"和"计时噪声大"可以同时成立——这正是它比单次计时诚实的地方。
5.3 03-article-build.py:380 行零依赖静态站
真实构建输出:
post-a15a2f6c.html 1 分钟 370 字符
verifiable-tech-writing.html 5 分钟 2664 字符
✅ 构建完成:2 篇 → /tmp/day199/dist/
产物 24KB:文章页、首页、feed.xml、.nojekyll。
本地起服务后 curl 验证:
HTTP=200
id="起因一篇自我感觉良好的稿子"
id="一技术文章的三种失败"
id="11-断言不可验证"
id="12-数字没有条件"
id="13-结构没有入口"
5.4 建议运行顺序
python3 code/01-article-lint.py --self-test
python3 code/02-repro-card.py --self-test
python3 code/03-article-build.py --self-test
python3 code/01-article-lint.py article/draft-with-issues.md # 感受 65 分
python3 code/01-article-lint.py article/post.md # 100 分
python3 code/03-article-build.py article --out /tmp/day199/dist
六、实战实验手册
六个实验都在本机真实执行过,输出是本次运行的 stdout。实验之间不依赖彼此残留状态,
每个都写了清理方式。
实验 1:环境指纹(先固定机器,再谈数字)
目的:确认本机环境,后续所有数字都以它为基准
环境:本机 shell,无依赖
准备:无
执行:
python3 -VV
uname -r
grep -m1 'model name' /proc/cpuinfo
nproc
cd ~/code/Learn-Python && git rev-parse --short HEAD
实际结果(2026-10-29 06:00 CST):
Python 3.12.3 (main, Aug 31 2026, 10:18:26) [GCC 13.3.0]
7.0.0-34-generic
model name : Intel(R) Xeon(R) Gold 6326 CPU @ 2.90GHz
4
94165cf
结论:4 核共享机器,load1=5.35 说明它并不空闲——后面测出 22.8% 的极差就是它的
直接后果。记录 load1 不是形式,它解释了你为什么不能只跑一次。
清理:无。
实验 2:把坏稿子体检一遍
目的:验证规则能抓到真实写作问题,并且评分可复现
准备:仓库内已含 article/draft-with-issues.md(故意写坏:两个 H1、无语言代码块、
模糊断言、无条件数字、TODO、无 alt 图片)
执行:
cd days/day-199-技术写作
python3 code/01-article-lint.py article/draft-with-issues.md; echo "exit=$?"
实际结果:
📝 days/day-199-技术写作/article/draft-with-issues.md
行数 19 / 字符 370 / 标题 4 / 代码块 1
评分 65/100 错误 2 警告 5
❌ L3 E001 出现第二个 H1:一篇文章只能有一个标题
⚠️ L7 W102 模糊断言「显然」:给出证据或删掉
⚠️ L7 W102 模糊断言「众所周知」:给出证据或删掉
⚠️ L7 W102 模糊断言「大家都知道」:给出证据或删掉
❌ L9 E003 代码块没有标注语言(读者失去语法高亮)
⚠️ L15 W104 遗留 TODO/TBD:发布前处理掉或明确标注为邀请协作
⚠️ L19 W103 性能/数量断言缺少测量条件(样本量、环境、重复次数)
exit=1
结论:两个结构错误被判 error,退出码 1,可以进 CI 卡住发布。同一段落里的三个
模糊词各报一条 warning,没有去重——这是刻意的,因为每个词都要被单独改掉。
清理:无。
实验 3:体检器压测(三档规模 × 5 次)
目的:拿到真实耗时,验证吞吐是否线性、噪声有多大
准备:用仓库内夹具生成三档语料(400 / 2000 / 8000 小节)
执行:
cd days/day-199-技术写作
python3 code/04-make-corpus.py /tmp/day199
for f in small mid big; do
python3 code/02-repro-card.py --repeat 5 -- python3 code/01-article-lint.py /tmp/day199/$f.md
done
实际结果(逐档的真实卡片,节选关键行):
small.md 400 节 53693 字节
min 40.66 ms · median 44.06 ms · max 50.72 ms(极差/中位 22.8%)
判定:✅ 输出可复现;⚠️ 计时噪声大
stdout sha256 前缀 0344fd047196b83e
mid.md 2000 节 272694 字节
min 56.75 ms · median 59.18 ms · max 69.46 ms(极差/中位 21.5%)
判定:✅ 输出可复现;⚠️ 计时噪声大
stdout sha256 前缀 9f31c928f545fdc5
big.md 8000 节 1100694 字节
min 116.93 ms · median 130.84 ms · max 133.38 ms(极差/中位 12.6%)
判定:✅ 输出可复现;✅ 计时稳定
stdout sha256 前缀 f504041230f1c085
结论:字符数 ×20.9,中位耗时 ×2.97——存在明显固定成本;三次的 stdout 在 5 次
重复里哈希完全一致,说明输出(而不是时间)可复现。小文件极差 22.8% 超过 20% 阈值,
被判"计时噪声大",这正是 load1=5.35 的可测量后果。
清理:rm -rf /tmp/day199(实验 6 统一执行)。
实验 4:体检器初审自己的文章 → 7 条误报 → 修复 → 100 分
目的:验证"引用规则"不等于"违反规则"
执行:
python3 code/01-article-lint.py article/post.md; echo "exit=$?"
实际结果(修复前,第一版规则):
评分 79/100 错误 0 警告 7
⚠️ L1 W102 模糊断言「显然」:给出证据或删掉
⚠️ L31 W102 模糊断言「显然」:给出证据或删掉
⚠️ L31 W102 模糊断言「众所周知」:给出证据或删掉
⚠️ L31 W102 模糊断言「大家都知道」:给出证据或删掉
⚠️ L125 W102 模糊断言「显然」:给出证据或删掉
⚠️ L125 W102 模糊断言「众所周知」:给出证据或删掉
⚠️ L130 W104 遗留 TODO/TBD:发布前处理掉或明确标注为邀请协作
exit=0
7 条全是误报:L1 是 front matter 里的标题(文章就叫《别写「显然」》),L31/L125 是
用引号引用这些词来讨论它们,L130 是检查清单里"没有遗留 TODO"这一条。
修复三处:跳过 front matter;先剥离行内代码与引号片段再查措辞;TODO 规则要求后面跟冒号。
中间态(只剥离了「」引号、漏了 ASCII 双引号)是 85 分、2 条残留。
写这篇 README 时又暴露两条过宽规则:
耗时被当成性能断言词,导致"写作最耗时的误区"这种句子中枪;- 行内代码里的
被当成真图片(翻车 1 的示例文本)。
改成用 ×/倍/快了 作为比较记号,并让 TODO/图片/链接检查同样先剥掉行内代码。
实际结果(修复后):
📝 days/day-199-技术写作/article/post.md
行数 133 / 字符 2835 / 标题 10 / 代码块 6
评分 100/100 错误 0 警告 0
✅ 没有发现问题
结论:规则的难点不是"能抓到",而是"只抓该抓的"。一条对讨论者开火的规则,
在真实写作里就是噪音源。修复后自测仍 8/8 通过,说明没有放过原有缺陷。
清理:无。
实验 5:构建静态站 + 真实 HTTP 验证
目的:确认发布链路真的可用,而不是"应该能用"
执行:
python3 code/03-article-build.py article --out /tmp/day199/dist
python3 code/03-article-build.py article --out /tmp/day199/dist --serve 8899 &
sleep 2
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8899/verifiable-tech-writing.html
curl -s http://127.0.0.1:8899/verifiable-tech-writing.html | grep -o 'id="[^"]*"' | head -6
curl -s http://127.0.0.1:8899/feed.xml | head -3
实际结果:
post-a15a2f6c.html 1 分钟 370 字符
verifiable-tech-writing.html 5 分钟 2664 字符
✅ 构建完成:2 篇 → /tmp/day199/dist/
HTTP=200
id="起因一篇自我感觉良好的稿子"
id="一技术文章的三种失败"
id="11-断言不可验证"
id="12-数字没有条件"
id="13-结构没有入口"
id="二把检查写成代码"
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>技术写作实验室</title>
结论:锚点是中文原文而不是哈希,TOC 与 id 同源;RSS 含两条 item;产物 24KB。
注意构建把 draft-with-issues.md 也一起发布了——真实教训:构建目录里有什么,
站点上就有什么,草稿要放单独的目录。
清理:停服务并验证端口,见实验 6。
实验 6:收尾卫生检查
目的:确认没有残留进程/端口/临时目录
执行:
ss -ltnp 2>/dev/null | grep 8899 || echo 'port free'
pgrep -af 'serve 8899' || echo 'no server process'
rm -rf /tmp/day199
ls /tmp/day199 2>/dev/null || echo 'tmp clean'
实际结果:
port free
no server process
tmp clean
结论:端口 8899 已释放,临时目录已删。注意不要用 pkill -f 03-article-build.py
收尾——它会把执行这条命令的 shell 一起杀掉(见翻车 4)。
清理:本实验本身就是清理。
翻车记录(真实踩坑)
翻车 1:自测报 W105 失败,我以为是规则坏了
- 现象:
--self-test输出❌ W105 被检出,退出码 1。 - 假设:图片 alt 检查的正则写错了。
- 验证:把夹具打印出来看,图片那行是
——alt 不为空,规则本就该放过。 - 根因:夹具写错了。我想测"缺失 alt",却写了一个有 alt 的图片。
- 修复:夹具改成
。 - 复测:8 条全过,
SELF-TEST PASS。
教训:自测失败时,先怀疑测试,再怀疑代码。这次如果直接改规则,会把一条正确规则改坏。
翻车 2:中文标题被 slug 成哈希,TOC 与 id 对不上
- 现象:自测
❌ 标题生成了与目录一致的锚点 id。 - 假设:
render_markdown没用同一函数生成 href 和 id。 - 验证:读代码确认两者都调
slugify()——一致,但输出是哈希post-a15a2f6c。 - 根因:
slugify()为文件名设计(ASCII 安全),纯中文标题折叠后只剩哈希;
而 TOC 想显示原文,两者必然分叉。 - 修复:拆出
anchor_id()保留中文做锚点,slugify()只管文件名。 - 复测:
id="为什么要写",自测 14 条全过。
翻车 3:跨层引号把脚本改坏两次
- 现象:
SyntaxError: unterminated string literal;修完又出现
closing parenthesis ']' does not match。 - 假设:正则里的引号没转义。
- 根因:改动同时穿过四层引用:shell heredoc → Python 字符串 → 正则 → 文件内容。
每层都在吃引号,最终写进文件的代码少了一个反斜杠。 - 修复:放弃内联字符串替换,改成"按行定位、整行重写",并用
\x22表示双引号。 - 复测:自测 8/8,两篇文章体检全部通过。
教训:一次改动穿过三层以上引用时,不要拼字符串,直接重写整行/整块。
翻车 4:pkill -f 把自己的会话杀了
- 现象:清理命令执行到一半,exec 返回
SIGTERM,后面的ss检查没跑。 - 假设:服务进程没起来,
pkill空跑。 - 验证:重新查端口,
port free,说明服务确实停了;但没有"残留: 0"的输出,
因为执行它的 shell 已经死了。 - 根因:
pkill -f '03-article-build.py'的模式匹配到了执行这条命令的 shell
自己的命令行,而命令行里就含这个字符串。 - 修复:按端口判断(
ss -ltnp | grep 8899)+ 按 PID 杀,不用模糊命令行匹配。 - 复测:
port free+no server process。
翻车 5:文章自称 168 行,实际 245 行
- 现象:写完正文后
wc -l一看,体检器是 245 行,不是正文里写的 168。 - 根因:行数是边写边估的,中途加了 front matter 跳过、引号剥离、TODO 冒号规则,
估的数字没跟着改。 - 修复:发布前用
wc -l校对,正文改成实测值(245 / 205 / 380,加上夹具 47 行)。 - 教训:文章里每个数字都要有来源命令;这也是"可复现"的一部分——读者真的会数。
七、思考题
- 你的读者在什么场景下会关掉页面?如果只能保留文章的三段话,你保留哪三段?
- 体检器把"段落 > 400 字"设为警告。如果文章是 API 文档,这条规则还成立吗?该怎么改?
- 实验卡片记录 stdout 的 sha256。什么情况下"哈希不同"不代表实验失败?举两个例子。
- 为什么
noisy_timing和stable_output必须分开判定?合成一个布尔值会丢什么信息? - 你最近一次"我觉得这样更快"的判断,如果现在补一次 5 次重复的测量,最可能推翻什么?
- 静态站放弃了评论和搜索。如果你的文章主要靠讨论传播,这个取舍还合理吗?替代方案是什么?
八、验收与清理
读者在本机的验收命令
cd days/day-199-技术写作
# 四个脚本离线自证(不需要网络,不需要外部服务)
python3 code/01-article-lint.py --self-test
python3 code/02-repro-card.py --self-test
python3 code/03-article-build.py --self-test
python3 code/04-make-corpus.py /tmp/corpus-check --self-test
# 两篇文章的分数应当分别是 65 和 100
python3 code/01-article-lint.py article/draft-with-issues.md
python3 code/01-article-lint.py article/post.md
# 构建并在本地看效果
python3 code/03-article-build.py article --out /tmp/day199-check
# 浏览器打开 /tmp/day199-check/index.html,或用 --serve 8899
本次实验清理了什么
- 停止
--serve 8899预览服务,确认端口释放(port free) - 删除
/tmp/day199(三档合成语料、旧版体检器备份、构建产物) - 没有残留后台进程,没有修改系统配置,没有安装任何依赖
九、参考资料
- 本仓库
days/day-198-开源贡献/:用真实 patch 与 review 支撑结论的写法 - Python 官方文档:
platform/statistics/hashlib/html/shlex - HTML5 规范:
id属性允许非 ASCII 字符 - RSS 2.0 规范:
channel/item最小字段 - 本日可发表成稿:
article/post.md(体检 100 分,可独立作为博客发布)