事情是这样的。

10 月底,我给站上的 9 篇文章批量补 SEO,写了个 Python 脚本,一次生成 TDK、canonical 和 JSON-LD。

脚本跑完,页面上线,我自己点开看了两眼,标题在、描述在、canonical 在,觉得成了。

过了三天,我想验证一下结构化数据,跑了个解析:

import json, re

html = open('post/26-docker-family.html').read()
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
data = json.loads(m.group(1))

报错:

json.decoder.JSONDecodeError: Expecting ',' delimiter: line 4 column 68 (char 124)

我第一反应是脚本写错了。打开那篇文章的源码,找到那段 JSON-LD:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "Mac mini 当服务器第 2 弹:Docker 全家桶一次拉满",

看到这里我明白了。

标题里有中文冒号":"和中文引号"",我脚本里是这么拼的:

json_ld = f'''
{{
  "headline": "{title}",
  "description": "{desc}"
}}
'''

title 变量里带着中文引号和括号,直接塞进 f-string,生成出来的 JSON 就是坏的。

为什么中文引号会毁掉 JSON

JSON 规定字符串必须用英文双引号 " 包裹。中文引号 " " 在 JSON 里是普通字符,可以出现,但中文引号长得太像英文引号,很多解析器会混淆。

更麻烦的是另一个问题:我的标题里有英文引号的时候,直接拼接会提前闭合字符串。

比如标题是 Mac mini 当服务器第 2 弹:"全家桶"一次拉满,拼出来是:

"headline": "Mac mini 当服务器第 2 弹:"全家桶"一次拉满",

那个英文引号直接把 headline 的值截断了,后面的 全家桶 变成非法字符,JSON 报错。

我的情况是中文引号版的同一类问题——中文书名号、中文冒号、中文引号,全都可能让解析器出问题,尤其是当内容里同时出现英文符号的时候。

正确的写法

一句话:别拼字符串,用 json.dumps。

import json

obj = {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": title,
    "description": desc,
    "datePublished": published,
    "dateModified": modified,
    "author": {"@type": "Person", "name": "陌笑技术分享"},
    "mainEntityOfPage": {"@type": "WebPage", "@id": canonical_url},
}

json_ld = json.dumps(obj, ensure_ascii=False, indent=2)

三个参数的意义:

还有一个细节:如果内容里有 </script>,需要转义,否则会提前闭合脚本标签:

json_ld = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")

顺手发现的另外两个问题

排查 JSON-LD 的时候,我把整个 head 都看了一遍,又发现两个。

问题一:keywords 全是"分类名,站名"

<meta name="keywords" content="Docker,陌笑技术分享">

9 篇文章都是这个格式,等于没写。

修法:按每篇内容定制长尾词。我在脚本里加了个映射表:

kw_map = {
    "26-docker-family": "Docker全家桶,Mac mini服务器,Docker Compose,家庭服务器搭建",
    "27-docker-basics": "Docker入门,Docker教程,容器,docker compose",
    "28-alist-mount": "Alist教程,网盘聚合,百度网盘挂载,自建网盘",
    # ...
}

问题二:canonical 不带 www,推送带 www

<link rel="canonical" href="https://moxiaoa.com/post/26-docker-family.html">

但 sitemap 和主动推送里都是 https://www.moxiaoa.com/...。

搜索引擎看到两个地址指向同一内容,不知道该认哪个。

修法:全站统一带 www,包括服务器上做 301。

修复之后怎么验证

批量改 9 篇,写完不能只看一眼就算。我做了三层验证:

第一层:脚本解析

import json, re, glob

for f in glob.glob('post/*.html'):
    html = open(f).read()
    m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
    if not m:
        print(f, '缺少 JSON-LD')
        continue
    try:
        obj = json.loads(m.group(1))
        print(f, 'OK', obj['headline'][:20])
    except Exception as e:
        print(f, '解析失败', e)

9 篇全部 OK 才算过。

第二层:线上抓取

本地文件对了,不代表线上对。要真的请求一次:

curl -s https://www.moxiaoa.com/post/26-docker-family.html | \
  python3 -c "import sys,re,json; h=sys.stdin.read(); \
  m=re.search(r'application/ld+json\">(.*?)</script>', h, re.S); \
  print(json.loads(m.group(1))['headline'])"

第三层:看实际渲染

浏览器打开页面,按 F12,看 head 里这几行有没有出现,有没有被其他代码覆盖。

有些站会动态改 head,静态文件对了,运行时又被脚本改了,这种最难查。

教训

这三件事花了我一个晚上,但都是可以避免的:

1. 生成 JSON 永远用序列化函数

手拼字符串的坑,不止引号,还有换行、反斜杠、控制字符。用 json.dumps 一次解决。

2. 批量改完必须机器验证

9 篇文章,我肉眼看了两篇觉得没问题。实际上第一篇就是坏的。批量操作一定要写验证脚本,跑一遍才有底。

3. 一致性比完美更重要

canonical、sitemap、推送地址,只要有一个不一致,前面都白做。定一个规则(比如永远带 www),然后全站贯彻。

现在的状态

9 篇文章的 TDK、canonical、JSON-LD 全部重生成,验证通过,线上抓取确认无误。

索引量开始动了,从 1 涨到 7。

我这次算是把 SEO 的地基重新夯了一遍。剩下的就是等——等搜索引擎重新评估,等内容积累。

SEO 这件事,做对的事比做多的事重要。

有在改站点 SEO 的朋友,记得跑一遍 json.loads 验证,能省很多时间。

如果你也在折腾这套东西,碰到卡住的地方,可以加我微信 lige0552 聊聊。白天要上班,消息回得慢,看到都会回。