Hugo 实现专业术语自动链接百度百科与外链新标签页打开

Reading time: 17 minutes.

开篇:我想给博客加两个小功能

我的 Hugo 静态博客写了不少技术文章,里面经常出现一些专业术语——密度泛函理论、机器学习、QSARDFT 之类的。读者如果不熟悉这些概念,得自己另开浏览器去搜。我就想:能不能让文章里的术语自动变成百度百科的链接?

另外还有个体验问题:文章里的外部链接(维基百科、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 的核心逻辑

算法分四步:

  1. 保护特殊标签:把 <pre><a><img><h1>-<h6><code><script><style> 用占位符替换,避免术语替换破坏已有结构
  2. 遍历术语列表(按长度降序),把首次出现的术语替换为占位符
  3. 还原所有占位符为原始 HTML 或生成的链接
  4. 输出 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)。requestscurl 的 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) -}}

修完后每个术语只链接一次,验证通过。

需求变更:大小写不敏感匹配

我的术语表里有 dftgohugoqsar 这些英文缩写,但文章里写的是 DFTGoHugoQSAR。我需要大小写不敏感匹配。

实现思路

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

大小写匹配跑通后,我发现 Hugo 那篇文章里出现了诡异的链接:Hu + 链接的 go + 模板渲染

原因:术语 hugo 只链接了第一次出现的 Hugo(首次匹配原则),第二次出现的 Hugo 还是纯文本。接着处理术语 go 时,在第二个 Hugo 里找到了子串 go,就链接了。

同理,go 还可能匹配进 Googlegoplsgolang 等词。

解决:词边界检查

含拉丁字母的术语,匹配位置的前后字符如果是字母或数字,就跳过该匹配,继续找下一个出现位置:

{{- $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))) -}}
  • 术语 goHugo 中:前一个字符是 u → 拒绝
  • 术语 goGo 语言 中:前一个字符是空格 → 通过
  • 中文术语不做边界检查(机器学习 匹配 机器学习方法 是合理的)

占位符的连锁问题

加了边界检查后又发现一个新问题:保护阶段的占位符 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"

全部通过:无重复链接、无残留占位符、内容无截断、代码块完整、标题未被链接。

小结与避坑清单

学到的关键点

  1. Hugo slice 函数:构造新切片,不是 Python 式切片。要取子切片得用 seq + index 循环
  2. Hugo substr 语义:第一参数是 rune 下标,第二参数是长度(不是结束位置),且是 rune 级别不是字节级别
  3. Hugo len vs strings.RuneCountlen 返回字节数,中文字符占 3 字节;做字符定位必须用 RuneCount
  4. 百度反爬:TLS 指纹检测,requests/curl 直接 403;需要 curl_cffiimpersonate="chrome" 模拟浏览器指纹
  5. 词边界:短英文术语(如 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)。