开篇:我想给博客加两个小功能
我的 Hugo 静态博客写了不少技术文章,里面经常出现一些专业术语——密度泛函理论、机器学习、QSAR、DFT 之类的。读者如果不熟悉这些概念,得自己另开浏览器去搜。我就想:能不能让文章里的术语自动变成百度百科的链接?
另外还有个体验问题:文章里的外部链接(维基百科、GitHub 等)点击后直接在当前页跳走了,读完外链回来得重新找阅读位置。我希望外部链接在新标签页打开,站内链接保持当前页跳转。
这两个需求看起来不复杂,但真正动手实现时,我踩了一连串 Hugo 模板函数的坑,也第一次跟百度的反爬机制正面交锋。这篇文章完整记录我的探索过程。
第一个功能:外部链接新标签页打开
思路选择
Hugo 支持 Markdown 渲染钩子(render hooks),可以自定义链接的 HTML 输出。我选择方案 B:仅外部链接加 target="_blank",内部链接(相对路径、锚点、脚注引用)保持当前页跳转。
实现
创建 layouts/_default/_markup/render-link.html:
{{- $u := urls.Parse .Destination -}}
{{- $external := and $u.Host (ne $u.Host (urls.Parse site.BaseURL).Host) -}}
<a href="{{ .Destination | safeURL }}"{{ with .Title }} title="{{ . }}"{{ end }}{{ if $external }} target="_blank" rel="noopener noreferrer"{{ end }}>{{ .Text | safeHTML }}</a>
逻辑很简单:解析链接的 Host,如果非空且与站点 Host 不同,就判定为外部链接。这样:
[文章](/post/foo/)→ 相对路径,Host 为空 → 当前页跳转[^1]脚注引用 →#fn:1,Host 为空 → 当前页跳转[维基百科](https://zh.wikipedia.org/...)→ Host 不同 → 新标签页
构建验证后一切正常,脚注锚点没有被误加 target="_blank",外部 Wikipedia 链接正确新窗口打开。
第二个功能:术语自动链接百度百科
整体方案设计
我选择了服务端模板预处理方案:在 Hugo 构建时,通过模板 partial 对 .Content 做字符串替换,把术语替换成带百度百科链接的 <a> 标签。
核心文件结构:
data/baike.yaml— 术语列表layouts/partials/baike-links.html— 预处理逻辑layouts/_default/single.html— 覆盖主题模板,调用 partial
术语数据文件
data/baike.yaml 格式:
terms:
- name: "密度泛函理论"
- name: "机器学习"
- name: "过氧单硫酸盐"
url: "https://baike.baidu.com/item/单过硫酸氢钾"
- name: "酷热指数"
url: "https://baike.baidu.com/item/热指数"
默认 URL 是 https://baike.baidu.com/item/{术语名},但有些术语在百科里的词条名跟我的叫法不同,所以支持 url 字段覆盖。
预处理 Partial 的核心逻辑
算法分四步:
- 保护特殊标签:把
<pre>、<a>、<img>、<h1>-<h6>、<code>、<script>、<style>用占位符替换,避免术语替换破坏已有结构 - 遍历术语列表(按长度降序),把首次出现的术语替换为占位符
- 还原所有占位符为原始 HTML 或生成的链接
- 输出
safeHTML
为什么要保护?因为 .Content 是已渲染的 HTML,里面可能有代码块包含术语文字、已有超链接的锚文本包含术语、标题里有术语——这些都不该被再次链接。
覆盖 single.html
把主题的 single.html 复制到项目 layouts/_default/single.html,只改一行:
<!-- 原来是 {{ .Content }} -->
{{ partial "baike-links.html" . }}
Python 检查脚本:第一次撞上百度反爬
术语链接生成后,我得验证这些百科 URL 是不是真的能访问。于是写了个 Python 脚本 check_baike_links.py,逐个请求术语 URL,检查返回状态和页面内容。
第一版:全部 403
import requests
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ...",
}
resp = requests.get(url, headers=HEADERS, timeout=15)
跑起来一看——18 个术语全部返回 HTTP 403。
我以为是 User-Agent 的问题,换了 Chrome、Safari、iPhone 各种 UA,甚至用 curl 命令行直接请求——统统 403。百度百科首页 https://baike.baidu.com/ 返回 200,但 /item/xxx 路径一律 403。
这明显不是 UA 层面的拦截,而是 TLS 指纹检测(JA3)。requests 和 curl 的 TLS 握手特征跟真实浏览器不同,百度 WAF 直接拒绝了。
解决:curl_cffi 模拟浏览器指纹
from curl_cffi import requests as http
resp = http.get(url, headers=HEADERS, impersonate="chrome", timeout=timeout)
curl_cffi 能模拟 Chrome 的完整 TLS 指纹,装上之后立刻有 4 个术语通过了。
反爬验证码的概率性拦截
即便用了 curl_cffi,百度还是有概率弹验证码(重定向到 /anticrawl/captchaview)。这是基于请求频率和 IP 信誉的风控,不是术语本身的问题。我在脚本里加了:
- 重试机制(默认 2 次,指数退避)
- 区分状态:
ok(词条存在)、blocked(被反爬拦截,非术语问题)、fail(词条真不存在,404) --strict选项:把 blocked 也视为失败
404 词条的修正
跑通后发现了 4 个真 404:
| 我的术语 | 百科实际词条 |
|---|---|
| 过氧单硫酸盐 | 单过硫酸氢钾 |
| 硫酸自由基 | 硫酸根自由基 |
| 磺胺类抗生素 | 磺胺类药物 |
| 酷热指数 | 热指数 |
在 data/baike.yaml 里给这些术语配了 url 字段指向正确词条。
脚本对 custom URL 的验证逻辑也做了调整:不能要求页面标题包含我的术语名(因为词条名不同),改为检查标题是否含 _百度百科(有效词条页的标志)。
需求变更:只链接首次出现
最初版本用 replace 替换术语的所有出现位置。但一篇文章里"密度泛函理论"可能出现十几次,全加链接太密了。我改成只链接第一次出现。
踩坑:Hugo 的 slice 不是切片操作
我的第一反应是用 split 拆分后取 parts[1:]:
{{- $rest := delimit (slice $parts 1) .name -}}
构建后文章输出被截断了——只剩第一个链接和一个孤零零的 1。
我写了个最小测试:
delimit: {{ delimit (slice (split "X,Y,Z" ",") 1) "," }}
输出:delimit: 1
真相:Hugo 的 slice 函数不是 Python 的 list[1:] 切片!它是从给定项构造新切片。slice $parts 1 创建的是 [$parts, 1]——一个包含原切片和整数 1 的两元素切片。
修复:用 seq 循环手动构建尾部切片:
{{- $tail := slice -}}
{{- range $j := seq 1 (sub (len $parts) 1) -}}
{{- $tail = $tail | append (index $parts $j) -}}
{{- end -}}
{{- $content = printf "%s%s%s" (index $parts 0) $token (delimit $tail .name) -}}
修完后每个术语只链接一次,验证通过。
需求变更:大小写不敏感匹配
我的术语表里有 dft、go、hugo、qsar 这些英文缩写,但文章里写的是 DFT、Go、Hugo、QSAR。我需要大小写不敏感匹配。
实现思路
用 lower 归一化后定位位置,再从原文取回匹配文本(保留原始大小写作为链接文字):
{{- $parts := split (lower $content) (lower .name) -}}
{{- if gt (len $parts) 1 -}}
{{- $pos := strings.RuneCount (index $parts 0) -}}
{{- $rlen := strings.RuneCount .name -}}
{{- $matched := substr $content $pos $rlen -}}
...
{{- end -}}
踩坑:substr 的参数语义
第一版我写了 substr $content $pos (len .name),结果输出完全错乱。
测试发现 Hugo 的 substr 语义是:
- START:rune 下标(不是字节)
- 第二参数:长度(不是结束下标)
而且 len 返回的是字节数,中文一个字符占 3 字节。用字节数当 rune 下标,位置全偏了。
修复:统一用 strings.RuneCount 计算字符数:
{{- $pos := strings.RuneCount (index $parts 0) -}}
{{- $rlen := strings.RuneCount .name -}}
{{- $matched := substr $content $pos $rlen -}}
{{- $content = printf "%s%s%s" (substr $content 0 $pos) $token (substr $content (add $pos $rlen)) -}}
修完后 dft 正确匹配文中的 DFT,链接文字显示原文的 DFT。
go-匹配进了-hugo">踩坑:go 匹配进了 Hugo
大小写匹配跑通后,我发现 Hugo 那篇文章里出现了诡异的链接:Hu + 链接的 go + 模板渲染。
原因:术语 hugo 只链接了第一次出现的 Hugo(首次匹配原则),第二次出现的 Hugo 还是纯文本。接着处理术语 go 时,在第二个 Hugo 里找到了子串 go,就链接了。
同理,go 还可能匹配进 Google、gopls、golang 等词。
解决:词边界检查
对含拉丁字母的术语,匹配位置的前后字符如果是字母或数字,就跳过该匹配,继续找下一个出现位置:
{{- $isAscii := findRE "[A-Za-z0-9]" .name -}}
...
{{- $prev := "" -}}
{{- if gt $cp 0 -}}{{- $prev = substr $content (sub $cp 1) 1 -}}{{- end -}}
{{- $next := "" -}}
{{- if lt $np $totalRunes -}}{{- $next = substr $content $np 1 -}}{{- end -}}
{{- $boundaryOk := or (not $isAscii) (and (not (findRE "[A-Za-z0-9]" $prev)) (not (findRE "[A-Za-z0-9]" $next))) -}}
- 术语
go在Hugo中:前一个字符是u→ 拒绝 - 术语
go在Go 语言中:前一个字符是空格 → 通过 - 中文术语不做边界检查(
机器学习匹配机器学习方法是合理的)
占位符的连锁问题
加了边界检查后又发现一个新问题:保护阶段的占位符 ZZBK0ZZ 全是字母。如果一个术语紧挨着被保护的代码块或脚注(如 机器学习[^1]),替换后变成 机器学习ZZBK0ZZ——边界检查看到下一个字符是 Z,判定为字母,拒绝匹配。
修复:把占位符改为 Unicode 私用区字符 \ue000 包裹:
{{- $token := printf "\ue000BK%d\ue000" $i -}}
\ue000 不是 ASCII 字母数字,不会触发边界拒绝。
最终验证
构建后逐项检查:
# 每篇文章的百科链接数
for f in public/post/*/index.html; do
n=$(grep -o 'class="baike-link"' "$f" | wc -l)
[ "$n" -gt 0 ] && echo "$n links: $f"
done
# 检查重复链接
for f in public/post/*/index.html; do
dups=$(grep -o 'class="baike-link">[^<]*' "$f" | sort | uniq -d)
[ -n "$dups" ] && echo "DUP: $f: $dups"
done
# 检查残留占位符
rg -l '\ue000' public/post/*/index.html || echo "no leftover tokens"
全部通过:无重复链接、无残留占位符、内容无截断、代码块完整、标题未被链接。
小结与避坑清单
学到的关键点
- Hugo
slice函数:构造新切片,不是 Python 式切片。要取子切片得用seq+index循环 - Hugo
substr语义:第一参数是 rune 下标,第二参数是长度(不是结束位置),且是 rune 级别不是字节级别 - Hugo
lenvsstrings.RuneCount:len返回字节数,中文字符占 3 字节;做字符定位必须用RuneCount - 百度反爬:TLS 指纹检测,
requests/curl直接 403;需要curl_cffi的impersonate="chrome"模拟浏览器指纹 - 词边界:短英文术语(如
go)做子串匹配时必须检查前后字符,否则会匹配进更长的单词
避坑清单
| 坑 | 表现 | 解法 |
|---|---|---|
slice $parts 1 当切片用 |
内容截断,输出只剩 1 |
用 seq 循环 + append 构建子切片 |
substr 第二参数当结束下标 |
取出内容过长/错位 | 第二参数是长度,用 RuneCount 计算 |
len 当字符数 |
中文定位偏移 | 用 strings.RuneCount |
| 占位符用纯字母 | 边界检查误判 | 用 \ue000 私用区字符包裹 |
| 术语不排序 | 短术语抢走长术语的匹配 | 按长度降序处理 |
用 requests 请求百科 |
全部 403 | 用 curl_cffi + impersonate="chrome" |
文件清单
layouts/_default/_markup/render-link.html # 外链新标签页
data/baike.yaml # 术语列表
layouts/partials/baike-links.html # 预处理核心逻辑
layouts/_default/single.html # 覆盖主题调用 partial
check_baike_links.py # 百科链接可用性检查脚本
运行检查:python3.14 check_baike_links.py --delay 2(需要 pyyaml + curl_cffi)。