背景
cam-filter CI 流水线里有一个通知脚本 ntfy_notify.py,用 Python urllib.request 把处理结果推到 ntfy.sh。标题里带 emoji(⏳✅❌🎥)和中文字段名,比如 🎥 cam-filter 开始处理。

跑了几个月一直正常,直到某天改了 emoji 映射——加了一个 📷(camera)到标题里。流水线直接崩了:
File ".../urllib/request.py", line ... in putheader
UnicodeEncodeError: 'latin-1' codec can't encode character '\U0001f4f7' in position ...
根因
Python 的 http.client.HTTPSConnection.putheader() 硬编码用 ISO-8859-1(latin-1) 编码 HTTP header value。latin-1 只能编码 0-255 的字符,emoji(U+1F000 以上)和中文字符(U+4E00 以上)全部超出范围,直接抛异常。
这个问题不限于 urllib。requests 库底层也走 http.client,早期版本同样踩过这个坑(GitHub issues #1926、#2838)。
第一轮修复:errors=”replace”(失败)
第一反应是加一个兜底编码函数:
def _safe_header(value: str) -> str:
return value.encode("latin-1", errors="replace").decode("latin-1")
errors="replace" 把不能编码的字符全部替换成 ?。结果:
- 中文”开始处理” → ????
- 不在 shortcode 映射里的 📷 → ?
- 通知标题显示 📷🎥 cam-filter ????

用户反馈:“标题还有问号”。
教训:编码方案不能丢信息。如果目标编码不支持这个字符,说明方法不对——不是”替换”能解决的。
第二轮修复:UTF-8 Smuggling(正确姿势)
正解是用 UTF-8 smuggling——和 requests 库用的同一手法:
def _safe_header(value: str) -> str:
try:
value.encode("latin-1")
return value # already safe
except UnicodeEncodeError:
# UTF-8 bytes → pretend they're latin-1 code points
return value.encode("utf-8").decode("latin-1")
转义映射表,把 emoji 转成 ntfy 官方 shortcode 追加到 Tags header,让通知栏显示 emoji 图标而不是原始字符:
_EMOJI_SHORTCODES = {
"\u23f3": "hourglass_not_done", # ⏳
"\u2705": "white_check_mark", # ✅
"\u274c": "cross_mark", # ❌
"\u26a0": "warning", # ⚠️
"\u26a0\ufe0f": "warning", # ⚠️ (with VS16)
"\U0001f3a5": "movie_camera", # 🎥
"\U0001f4f7": "camera", # 📷
"\U0001f534": "red_circle", # 🔴
}
提取逻辑内嵌在 send() 里,边扫描标题边生 clean_title 和 shortcode 标签:
原理
value.encode("utf-8")→ 把 Unicode string 编码为 UTF-8 字节序列(每字节 0-255,完全在 latin-1 范围内).decode("latin-1")→ 把这些字节”伪装”成 latin-1 码点- Wire 上发送的是完整的 UTF-8 字节序列
- 服务端(ntfy.sh 用 Go 写)收到后按 UTF-8 解析,正确还原 emoji 和中文

最小演示:
header = "🎥 cam-filter 开始处理"
# ❌ errors="replace" 丢信息
safe_bad = header.encode("latin-1", errors="replace").decode("latin-1")
# → "??? cam-filter ????"
# ✅ UTF-8 smuggling 保信息
safe_good = header.encode("utf-8").decode("latin-1")
# wire bytes: \xf0\x9f\x8e\xa5 cam-filter \xe5\xbc\x80...
# 服务端收到后:🎥 cam-filter 开始处理
最终 send() 函数把 emoji 提取、UTF-8 smuggling、HTTP POST 串在一起:
def send(topic, title, message, tags=None):
msg_bytes = message.encode("utf-8")
# 从标题提取 emoji → ntfy shortcode,追加到 Tags
title_tags = []
clean_title = title
for emoji_char, shortcode in _EMOJI_SHORTCODES.items():
if emoji_char in clean_title:
clean_title = clean_title.replace(emoji_char, "").strip()
title_tags.append(shortcode)
all_tags = []
if tags:
all_tags.append(tags)
all_tags.extend(title_tags)
tags_header = ",".join(all_tags) if all_tags else None
headers = {"Title": _safe_header(clean_title)}
if tags_header:
headers["Tags"] = _safe_header(tags_header)
req = urllib.request.Request(
f"https://ntfy.sh/{topic}",
data=msg_bytes,
headers=headers,
method="POST",
)
urllib.request.urlopen(req, timeout=10)
踩坑总结
| 坑 | 症状 | 根因 | 教训 |
|---|---|---|---|
| latin-1 限制 | UnicodeEncodeError | putheader() 硬编码 latin-1 |
底层库有编码假设 |
| errors=”replace” | 问号占位 | 丢信息式编码 | 编码不能丢信息 |
| shortcode 不完整 | 漏映射的 emoji 触发报错 | 字典维护遗漏 | 映射表需要持续维护 |
| Git rebase 竞态 | CI 持续 push 打乱 rebase | 多人/流水线并行 | 操作前先看 CI 状态 |
附录
ntfy 通知最佳实践:
- emoji 用 Tags header 传官方 shortcode,不要放 Title 里
- Title 头的非 ASCII 字符用 UTF-8 smuggling
- 正文正文用纯文本
社区讨论:
- CPython Issue #26045 — http.client 的 latin-1 限制
- CPython Issue #105981
- requests/issues/1926
RFC 背景: HTTP/1.1 规定 header value 用 ISO-8859-1(RFC 7230 §3.2.6)。但实践中服务端通常接受 UTF-8 bytes,真正卡住的是客户端库的编码检查。