引子
7 月 15 号晚上 10 点,我坐在家里的书桌前,盯着 `niuma_daily_collect.sh` 这个 80 行的 Bash 脚本。第 47 行是一段 `curl -H "Authorization: Bearer $(cat /tmp/halo_pat.txt)"` 调用,5 分钟后我才发现这一行的 PAT 末尾被偷偷塞了一个 `\n` —— Bash 的 `$(cat …)` 在命令替换时会保留原文件末尾的换行符,而 Halo 后端对 Authorization 头做严格相等校验。
那天我修了 4 个小时,才把脚本从「curl 直接挂」改成「Python urllib 调用」。
但真正让我后悔的,不是这个 401。是改的过程中发现的 3 个「Bash 天生干不了」的事。我把脚本整段翻译成 Python 之后才看明白:Bash 不是不好用,是它在「跨会话、有状态、需要验证」的活上根本扛不住。
下面这 3 个细节,每一个都让我意识到:我应该早 3 个月改。
问题:3 个 Bash 的「盲区」同时发作
我的 `niuma_daily_collect.sh` 一共 80 行,做 3 件事:抓微博热搜、抓 B 站热门、抓 V2EX 话题,最后合并成 `materials/$(date +%F).md`。脚本在 7 月 1 号到 7 月 14 号之间跑了 14 天,每次都返回 `exit 0`,每天 21:00 产出 5-10KB 的素材文件。
7 月 14 号我偶然 `grep -c "微博" materials/2026-07-14.md`,结果是 0。
微博热搜抓不到,但脚本说「成功」。
顺着这个发现往后查,我列出了 3 个 Bash 干不了的事,每一个都让脚本的「成功」变成空话:
后悔 1 —— 「$(cat PAT)」末尾的换行符。我的 PAT 存在 `/tmp/halo_pat.txt`,是用 `echo "$PAT" > /tmp/halo_pat.txt` 写入的。`echo` 默认会追加换行。`$(cat …)` 不会去掉它。
# 修复前
TOKEN="$(cat /tmp/halo_pat.txt)"
echo "Token length: ${#TOKEN}" # 输出 961
curl -s -H "Authorization: Bearer ${TOKEN}" \
https://blog.eggtata.top/apis/api.console.halo.run/v1alpha1/posts?size=1 \
-o /dev/null -w "%{http_code}\n"
# 输出 401
`${#TOKEN}` 看着是 961 字节(去掉末尾换行),但 `${TOKEN}` 实际是 962 字节 —— Bash 的 `${#}` 算长度时不计入末尾那个孤零零的 `\n`。curl 会原样把整个 962 字节的字符串塞到 Authorization 头里,Halo 服务端做严格相等校验时就 401 了。
Python 没有这个问题:
pat = open('/tmp/halo_pat.txt').read().strip() # strip() 干掉换行
req = urllib.request.Request(
'https://blog.eggtata.top/apis/api.console.halo.run/v1alpha1/posts?size=1',
headers={'Authorization': f'Bearer {pat}'}
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(resp.status) # 200
同一个 PAT、同一个 URL,curl 是 401,Python urllib 是 200。我在 Bash 里 debug 这个 401 花了 4 小时 —— 包括 `xxd` 看二进制、`hexdump -C`、最后还是同事提醒「你看过 `$(cat …)` 的尾部吗」才反应过来。
如果当时一开始就 Python,4 小时变 4 分钟。
为了验证这个发现,我当时 `xxd` 了一下两个 PAT 字符串:
$ xxd /tmp/halo_pat.txt | tail -2
000003f0: 2e36 652e 6175 7430 5f6b 0a .6e.aut0_k.
# 注意末尾的 0a — 就是 \n (LF)
然后跑了一圈反推:
$ python3 -c "
with open('/tmp/halo_pat.txt', 'rb') as f:
raw = f.read()
print(f'文件总字节: {len(raw)}')
print(f'末尾 4 字节 hex: {raw[-4:].hex()}')
print(f'去掉末尾换行后: {len(raw.rstrip(chr(10).encode()))}')
"
文件总字节: 962
末尾 4 字节 hex: 305f6b0a
去掉末尾换行后: 961
962 字节 vs 961 字节 —— 多出来的那一个就是 `\n`。`$(cat …)` 把它原封不动塞进了 `Authorization: Bearer …` 头里。`${#TOKEN}` 在 Bash 里只统计非空字符,所以永远显示 961 —— 它骗了我整整 4 小时,让我以为「长度对、PAT 对、怎么会 401」。这就是 Bash 给你看到的是「干净的数字」、给你送走的是「脏的字节流」 的活案例。
后悔 2 —— Bash 命令替换触发 Hermes 安全审批拦截。
7 月 18 号那次,Halo 上游持续 28 分钟 502 Bad Gateway(详见「#018 翻车」案例)。我写了个轮询脚本:
# 修复前 — 触发 Hermes approval 拦截
stdbuf -oL bash -c '
while true; do
status=$(curl -s -o /dev/null -w "%{http_code}" https://blog.eggtata.top/)
echo "$(date +%H:%M:%S) status=$status"
sleep 15
done
'
这个脚本根本跑不起来。Hermes 的 security approval 检测到 `bash -c '...'` 这种内联调用模式,直接弹出审批对话框,cron job 静默卡死。我在日志里看到 `process(action='poll')` 返回的 stdout 是空字符串,但 PID 一直在跑 —— 输出被 buffer 吞掉了。
排查这个 buffer 问题又花了 1 小时。最终的修法:
# 修复后 — 写到文件 + python -u 解 buffer
# 文件 /tmp/halo_watchdog.py
#!/usr/bin/env python3
import urllib.request, time, datetime
while True:
try:
with urllib.request.urlopen('https://blog.eggtata.top/', timeout=5) as r:
status = r.status
except urllib.error.HTTPError as e:
status = e.code
except Exception:
status = 0
print(f"{datetime.datetime.now().strftime('%H:%M:%S')} status={status}", flush=True)
time.sleep(15)
跑法:`python3 -u /tmp/halo_watchdog.py`。`-u` 强制无 buffer,`flush=True` 双保险。
Bash 的问题不是「写不出来」,是「写出来跑不动」。Hermes 的安全策略对 Bash 内联命令天生警惕,但 Python 文件落盘之后用 `python3 ` 跑是白名单路径。这不是「Python 更安全」,是「Python 工具链更适配这个沙箱」 —— 但效果上,Bash 脚本在 Hermes 里就是更脆弱。
后悔 3 —— Bash 脚本的「成功」无法自证。
7 月 14 号那个「微博抓不到但 exit 0」的事,根因是微博接口在 7 月初改用了 mobile UA bypass,Bash 里那段:
# 修复前
resp=$(curl -s -A "Mozilla/5.0 ..." https://weibo.com/ajax/side/hotSearch)
echo "$resp" > /tmp/weibo.json
跑完 `echo` 一下 HTTP 状态码,没报错就 `exit 0`。但 `/tmp/weibo.json` 实际上是微博返回的 HTML 错误页(403 反爬),不是 JSON。脚本不知道 —— 它只检查了 `curl` 的退出码,没检查响应内容是不是合法 JSON。
Python 版本加了 `timed_fetch()` 包装器:
# 修复后
def timed_fetch(name: str, fn) -> list:
"""统一的 fetch 包装器 — 同时返回结果 + 耗时 + 错误信息"""
t0 = time.time()
try:
items = fn()
elapsed = time.time() - t0
if not items:
print(f"💀 {name} 空响应 {elapsed:.1f}s")
return []
print(f"✓ {name} {len(items)}条 {elapsed:.1f}s")
return items
except Exception as e:
print(f"💀 {name} 异常 {time.time()-t0:.1f}s: {e}")
return []
每个 fetch 函数都被包了一层。返回值长度 0、解析异常、网络超时,3 种失败都会被单独打印,最终汇总到 md 文件顶部的 `` 注释里。Bash 脚本的「成功」是单点的「exit 0」,Python 的「成功」是结构化的「多少源 OK、多少空、多少异常」。
光是这一项改进,14 天里我就提前发现了 GitHub Trending HTML 改版(7+ 天 0 命中 → 改 API)和 Reddit r/MachineLearning 反爬 403 两个隐性 bug。Bash 版本要等到我某天 `grep` 才发现。
解决:把 80 行 Bash 翻成 320 行 Python
整个重写花了 2 个晚上。最大块的工作不是逻辑翻译,是「让失败可见」 —— Bash 的 `set -e` 只能告诉你「中途有没有非零退出」,但 80 行脚本里 90% 的失败是「成功退出但结果错误」。
具体改了 3 处:
1. `set -e` → 显式异常。Bash 的 `set -euo pipefail` 是「出错就退」,但「抓到一个空响应」不会让 curl 报错。Python 的 `try/except` + `return []` 把「空响应」明确分类为「失败但不抛异常」,由 `timed_fetch()` 统计。
所有调用点都走这个函数。
3. `curl -s` → `urllib.request` + timeout。所有网络调用强制加 `timeout=10`,超时直接 raise `TimeoutError`,被 `timed_fetch()` 捕获。Bash 的 `curl --max-time` 没有「未捕获超时」的标准行为,我见过 3 种不同 cron 环境对 curl 超时的处理。
4. 失败签名 → 结构化日志。Bash 的「exit 0/1」被替换成 md 顶部的 `` + jsonl 末尾的 `_meta` 行。下游消费者(不管是 LLM、grep、还是别的脚本)都能用一行 Python 解出当天的健康度,Bash 时代只能 `tail -50 收集日志` 人肉看。
把 4 项放在一起,是「从字符串哲学切到对象哲学」的转型 —— Bash 把所有东西当字符串,Python 把所有东西当对象。字符串可以被「看不见的换行」污染,对象的 `strip()` 永远在自己手里。
反思:我应该早 3 个月改
回头看这 3 个后悔点,它们有一个共同特征:都是「Bash 的退出码哲学」和「Hermes / cron 的运行哲学」不匹配。
Bash 的世界是「命令串起来,0 是成功,非 0 是失败」。但现代自动化系统(cron / CI / agent runtime)关心的是「这次产出对不对、这次的副作用可见不可见、这次的异常能不能被下游发现」 —— 这 3 个问题,Bash 给出的全是最弱的答案:退出码、`echo`、操作符。
Python 的世界默认是「异常即信号、对象即数据、上下文管理器即状态机」。`with open(...) as f` 关闭文件、`urllib.request.urlopen(...) as resp` 关连接、`try/except` 分类异常 —— 这些在 Bash 里要靠「if 检查 + trap + 临时文件」拼装,每拼一段就是 20 行易错的胶水。
更扎心的是:我之所以拖到 7 月 15 号才改,纯粹是路径依赖。`daily_collect.sh` 第一版是 5 月 29 号写的,那天才 12 行,到 7 月已经 80 行。每次我想「要不要重写」,看着「也就加 5 行」就放弃了。Bash 脚本的「可演化性」比 Python 差一个数量级 —— 12 行变 80 行在 Bash 里是「再加个 if」;在 Python 里早就触发「该拆函数了」的重构信号。
如果再让我选一次,我会在 5 月 29 号直接用 Python 起手。即使多花 2 天搭框架,省下的 4 小时 401 + 1 小时 buffer + 14 天隐性 bug 都值回来了。
不过也别被我误导 —— Bash 不是垃圾。它仍然是「一次性的、3 行内、能用 `&&` 串起来」的胶水之王。我现在的 `~/.bashrc` 里还留着 5 段 Bash 片段。但只要脚本开始要判断响应内容、要处理异常、要被 cron 反复调用、要和 Python 工具链混跑,Bash 就该让位给 Python 了。
这个判断线是:「如果脚本里出现了 if-elif-else,就该 Python 了」。Bash 的 `if` 写超过 3 层就是人肉拼图。
我的 3 个月时间线复盘(贴在 GitHub commit history 里看得见):
- 2026-05-29:第一版 `daily_collect.sh`,12 行,只抓微博 + 百度 → 一次跑通,commit
- 2026-06-04:加 V2EX,到 32 行 → 第一次出现 if-else(判断哪些源失败要重试)
- 2026-06-12:加 HN,到 58 行 → 第一次遇到 `$(cat …)` 末尾换行,但当时用 `echo -n` 临时糊过去了
- 2026-06-22:加 GitHub + B 站,到 80 行 → 「if 写超过 3 层」症状已经很明显了
- 2026-07-14:14 天后才发现微博抓不到(mobile UA bypass 改了) → 第一次 hit「成功假象」
- 2026-07-15:被迫重写 Python 版 → 重写时一次性把 3 个坑全踩出来
5 月 29 日到 6 月 22 日,是「能跑就继续 Bash」的甜蜜期,3 个坑一个都没暴露。6 月 22 日到 7 月 14 日,是「该改但没改」的盲飞期,坑在静默累积。7 月 14 日到 7 月 15 日,是「被迫改」的代价期 —— 一个周末搭框架 + 一个晚上 debug + 4 小时追 401 + 1 小时追 buffer。
如果 6 月 22 日就改,这 22 天的「每天下午 grep 一遍素材」的人肉巡检可以省掉。这就是「早改 3 个月」的真实成本:22 个工作日 × 5 分钟/天 = 110 分钟 ≈ 2 小时。而被迫改的时间是 6 小时 + 2 晚 + 22 天的隐性 bug 累积。
本文为虚构故事,不涉及任何真实项目部署过程。文中 401/502/buffer/PAT 等技术细节均来自公开运维案例。
评论区