前言

之前写过三篇 Hexo SEO 系列(搜索引擎原理、站内优化、提升排名), 讲的多是 meta 标签、关键词、内外链这些。

但有一个东西漏了 —— 结构化数据。

它的作用是把”这是一篇文章, 作者是谁, 什么时候发的, 封面图是哪张”这类信息, 用机器能读的格式告诉搜索引擎。好处很直接: 搜索结果里可能直接显示出封面图、作者名、发布时间, 点击率比纯文字结果高。

这篇记录怎么接。

原理: 什么是 JSON-LD

三种格式, 选 JSON-LD

结构化数据有三种写法:

格式 写法 推荐度
Microdata 写在 HTML 属性里 一般, 侵入性强
RDFa 写在 HTML 属性里 一般
JSON-LD 单独一段 <script> 推荐

选 JSON-LD 的理由: 与 HTML 完全分离。 写在 HTML 属性里的方案, 改模板容易把结构化数据改坏; JSON-LD 是一段独立的代码, 不动页面结构, 维护成本最低。

长什么样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "文章标题",
"image": "https://example.com/cover.png",
"datePublished": "2026-10-21T11:05:42+08:00",
"dateModified": "2026-10-21T11:05:42+08:00",
"author": {
"@type": "Person",
"name": "竹山一叶"
},
"publisher": {
"@type": "Organization",
"name": "竹山一叶的小家"
}
}
</script>

搜索引擎会解析这段 JSON, 提取成知识图谱里的实体。

第一步: 加到 Butterfly 主题里

Butterfly 提供了 inject 机制, 可以往页面里塞代码。

先说清楚这个版本的 inject 支持哪些位置。 我查了本地主题源码, 实际只有两个注入点:

位置 作用范围 对应模板
head 所有页面的 <head> 里 layout/includes/head.pug
bottom 所有页面底部(</body> 前) layout/includes/additional-js.pug

源码里就这两行:

1
2
3
4
5
// layout/includes/head.pug
!=fragment_cache('injectHead', function(){return injectHtml(theme.inject.head)})

// layout/includes/additional-js.pug
!=fragment_cache('injectBottom', function(){return injectHtml(theme.inject.bottom)})

这意味着没有”仅文章页”的注入点。 网上很多教程写的 inject.post / inject.post-bottom 在这个版本里不生效 —— 配了也不会被渲染。

那文章级的 JSON-LD 怎么加?

既然没有文章级注入点, 就用 head, 但在代码里自己判断当前是不是文章页。

1
2
3
4
5
6
7
8
9
10
11
12
inject:
head:
- |
<% if (page.post) { %>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
...
}
</script>
<% } %>

page.post 在文章页上存在, 在独立页面、分类页、标签页上都是 undefined, 用它判断最直接。

注意 inject 的格式: 源码里是 data.join(''), 所以每一项必须是一个字符串, 不能写成 { code: 'xxx' } 这种对象结构。写成对象会渲染成 [object Object]。

顺带说明: Pug 模板里的条件语法

Butterfly 用 Pug, 但 inject 的内容是字符串, 会被当成普通文本输出, 不会被 Pug 编译。

所以上面的 <% if (page.post) { %> 这种 EJS 风格语法, 在 inject 里同样不会被解析 —— 它会原样输出到页面上。

正确做法是改用主题的标签系统, 或者接受所有页面都注入。

考虑到这一点, 更实用的方案是: 给所有页面都注入 JSON-LD, 但用不同的 @type 区分。搜索引擎能接受一个页面里有多个 JSON-LD 块, 它会按类型分别理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inject:
head:
- |
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "...",
"url": "..."
}
</script>
- |
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
...
}
</script>

如果你的站点没有独立页面(关于页等), 或者独立页面很少, 直接全站注入 BlogPosting 也没有大碍。 独立页面通常就那么几个, 搜索引擎不会因为一两个页面的类型标注不精确就惩罚整个站点。

另一个更稳的方案: 直接改主题模板。 主题的 layout/post.pug 是 Pug 文件, 里面可以用真正的 Pug/JS 语法判断页面类型。代价是主题升级时可能要重新改(所以要先改 themes/butterfly 还是用 extend/inject 覆盖, 取决于你愿不愿意接受维护成本)。

第二步: 动态取值

上面那段 JSON 里的字段不能写死 —— 每篇文章的标题、时间都不一样。得用模板变量动态填充。

先说个坑: inject 里不能用模板变量

这是最容易踩错的地方。 很多人以为 inject 里能写 <%- title %>, 实际上不行。

看主题源码里 inject 的实现:

1
2
3
4
// scripts/helpers/page.js
hexo.extend.helper.register('injectHtml', data => {
return data ? data.join('') : ''
})

只是 join(''), 没有任何模板编译。 意味着你在 inject 里写的 <%- title %> 会原封不动地作为文本输出到 HTML 里, 页面上会出现字面量 <%- title %>。

结论: 需要动态取值的结构化数据, 不能靠 inject, 必须改主题模板。

正确做法: 在 post.pug 里加

Butterfly 的 layout/post.pug 是真正的 Pug 模板, 里面可以用完整语法。

在 layout/post.pug 的 block content 下面找合适位置(建议放在 article#article-container 之后), 加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
if page.title
script(type="application/ld+json").
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "#{config.url}/#{page.path}"
},
"headline": "#{page.title}",
"description": "#{page.description || page.excerpt || ''}",
"image": "#{page.cover || config.site.img || config.site.logo}",
"datePublished": "#{page.date.toISOString()}",
"dateModified": "#{page.updated ? page.updated.toISOString() : page.date.toISOString()}",
"author": {
"@type": "Person",
"name": "#{config.author}"
},
"publisher": {
"@type": "Organization",
"name": "#{config.title}",
"logo": {
"@type": "ImageObject",
"url": "#{config.site.logo}"
}
},
"keywords": "#{page.tags && page.tags.length ? page.tags.map(t => t.name).join(', ') : ''}"
}

注意 Pug 里输出 JSON 用了 script(type="application/ld+json"). 这个写法 —— 末尾的点是 Pug 的”纯文本块”语法, 后面跟着的内容原样输出, 不做 HTML 转义。

这一点很关键: 如果用 script(type="application/ld+json")= #{json}, Pug 会把引号转义成 &quot;, JSON 就废了。

几个关键点

标题字段用 headline 不是 name

文章类型用 headline。用 name 会被 Google 判为”不匹配预期类型”。

时间要用 ISO 8601 格式

page.date.toISOString() 输出的是 2026-10-21T03:05:42.000Z, 这是标准格式。

不要直接输出 page.date 本身, 默认的 2026-10-21 11:05:42 这种格式搜索引擎可能解析失败。

URL 要拼成绝对路径

#{config.url}/#{page.path} 拼出的是完整 URL, 搜索引擎最喜欢这种。

如果用 url_for(page.path), 得到的是带 config.root 的站内路径, 部署在根目录时也能用, 但部署在子目录时可能拼不完整。所以这里直接用 config.url 更稳。

封面图要有兜底

不是每篇文章都设了封面。所以要准备 fallback:

1
"image": "#{page.cover || config.site.img || config.site.logo}"

注意 page.cover 可能是相对路径(比如 /images/xxx.png)。搜索引擎对相对路径的 image 支持不稳定, 稳妥起见拼成绝对 URL:

1
"image": "#{page.cover && !page.cover.startsWith('http') ? config.url + page.cover : (page.cover || config.site.img || config.site.logo)}"

注意 cover 可能是相对路径, 而结构化数据的 image 最好是绝对 URL。如果搜索引擎抓不到完整 URL, 富媒体展示就不会生效。稳妥起见可以这样写:

1
"image": "<%- (cover && cover.startsWith('http')) ? cover : (config.root + (cover || config.site.img || config.site.logo)) %>"

第三步: 验证

写完不能靠猜, 必须用官方工具验。

Google 富媒体测试工具

地址: https://search.google.com/test/rich-results

输入你的文章 URL, 点测试。它会告诉你:

  • 结构化数据是否被识别
  • 哪些字段有警告或错误
  • 预览效果长什么样

Schema Markup Validator

地址: https://validator.schema.org/

比 Google 那个更宽松, 主要用来检查 JSON 语法和 schema 定义是否符合规范。两个都跑一遍, 前者看实际效果, 后者查语法细节。

本地怎么验

本地还没部署的话, 可以直接看渲染后的 HTML:

1
hexo clean && hexo g

然后在 public/ 里找文章页面:

1
2
# 找到刚生成的文章
grep -l "application/ld+json" public/*.html | head -3

确认 JSON 语法没错:

1
2
# 提取 JSON 部分并校验格式
grep -A20 "application/ld+json" public/xxxxx.html | head -25

常见陷阱

陷阱一: JSON 里的引号没转义

这是最常见的错误。如果你的标题里带了英文双引号, JSON 就会解析失败, 整段结构化数据直接作废。

比如标题是 Hexo 配置的 "url" 该怎么填, 直接塞进 JSON 就崩了。

解决办法: 在模板里做 JSON 转义

Pug 的纯文本块(script(...).)不做 HTML 转义, 也就不会帮你转义 JSON 里的双引号。所以要在模板里手动处理。

在 post.pug 里定义一个变量先转义:

1
2
3
4
5
6
7
8
- var ldTitle = (page.title || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
if page.title
script(type="application/ld+json").
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "#{ldTitle}"
}

两个转义都要做: 先转义反斜杠 \, 再转义双引号 "。顺序反了会出问题 —— 先转义双引号的话, 新产生的 \ 会被二次处理。

description 和 keywords 同理, 有引号的话也要处理。

更省事的办法: front-matter 里的标题和描述尽量用中文标点(「」『』””), 避开英文双引号。但这只能规避, 不是根治。

陷阱二: 和主题自带的重复

Butterfly 5.x 本身没有内置 JSON-LD(我查过 layout/ 和 scripts/, 没有 ld+json 相关代码), 所以这块不会撞车。

但 canonical 和 og:url 这类标签, 主题和 SEO 插件只能有一个来源。

检查方法: 看渲染后的 HTML 里有没有多个同类标签。

1
2
grep -c "application/ld+json" public/xxxxx.html
grep -c "rel=\"canonical\"" public/xxxxx.html

大于 1 就要去掉多余的。

陷阱三: 忘了写 dateModified

datePublished 和 dateModified 都应该填。

  • datePublished —— 文章首次发布时间
  • dateModified —— 最后修改时间

老文章更新后, dateModified 是重要的信号。 只写 datePublished 会让搜索引擎以为这篇内容一直没变过, 不给重新抓取的权重。

你可以在 front-matter 里加 updated 字段:

1
2
3
4
5
---
title: 'xxx'
date: 2026-10-21 11:05:42
updated: 2026-10-25 15:30:00
---

陷阱四: 忘记改 updated 字段

上面那个 updated 不会自动更新, 得手动维护。

Hexo 有个插件可以自动根据 Git 提交时间生成, 但更简单的方式是每次改文章时顺手更新 updated 字段。

陷阱五: 结构化数据被当成作弊

不要在结构化数据里塞关键词。 比如把 keywords 写成 "Hexo,Hexo教程,Hexo博客,Hexo搭建,hexo主题", 这种堆砌反而会被判为垃圾信号。

keywords 字段就填真实的标签, 有几个填几个, 没有就干脆不填。

可以加的其他类型

BlogPosting 之外, 常见的还有:

类型 用途
WebSite 站点级信息, 全站通用
Person 作者信息
BreadcrumbList 面包屑导航
FAQPage 问答页面(注意: Google 已限制该类型的富媒体展示)
HowTo 步骤教程类文章

加站点级 WebSite

WebSite 的字段所有页面都一样(站点名、站点 URL), 所以这条可以放心用 inject 注入 —— 不需要动态取值, 也没有引号转义问题。

1
2
3
4
5
6
7
8
9
10
11
inject:
head:
- |
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "竹山一叶的小家",
"url": "https://zsyyblog.com"
}
</script>

站点名和 URL 写死就行 —— 站点信息本来就不会每篇文章变。

不过要注意: 只有在页面真的有搜索功能时, 声明 SearchAction 才有意义, 否则 Google 会提示这个动作无效。

你如果开了 Butterfly 的搜索功能(search.enable: true), 可以加上:

1
2
3
4
5
"potentialAction": {
"@type": "SearchAction",
"target": "https://zsyyblog.com/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}

关于改主题模板的维护成本

前面说了要改 themes/butterfly/layout/post.pug。这会带来一个问题: 主题升级时这个改动会丢失。

两个应对办法:

办法一: 改之前留个备份

1
2
cd themes/butterfly/layout
cp post.pug post.pug.bak

升级主题后对比 diff, 把改动重新补回去。

办法二: 用 Hexo 的主题覆盖机制

Hexo 支持通过 _config.yml 里的 theme 字段配合 extend 生成器做覆盖, 但 Butterfly 官方文档对此支持有限。

更实用的第三种: 把 JSON-LD 的生成逻辑写成一个自定义 Hexo 过滤器或脚本, 输出到 source/ 下, 再用 inject 引入。这样升级主题不受影响。

不过说实话, 对大多数个人博客来说, 办法一就够用了 —— 一年升级主题一两次, 每次补一下也花不了几分钟。改之前记一下改了什么就行。

小结

接入结构化数据, 核心就三步:

  1. 加代码 —— 文章级改 themes/butterfly/layout/post.pug, 站点级用 inject.head
  2. 动态取值 —— Pug 里用 page.xxx, 时间转 ISO 8601, URL 拼绝对路径, 引号手动转义
  3. 用官方工具验证 —— Google 富媒体测试 + Schema Markup Validator

最需要小心的三个点:

  • inject 里不能用模板变量 —— 它只做 join(''), <%- title %> 会原样输出
  • JSON 里的双引号要转义 —— 先转 \, 再转 ", 顺序不能反
  • 别和主题自带的标签重复 —— canonical、og:url 这类一个来源就够了

结构化数据是纯增量的 —— 加错了顶多不起作用, 不会让搜索排名变差。所以值得花半小时配好它, 换来的是搜索结果里更丰富的展示。

参考链接: