商品页的 JSON-LD 是谁在维护:用 Python 从商品库批量生成结构化数据的架构方案

2026-09-19 09:59:50 10 次浏览
PythonJSON-LD结构化数据电商架构SEOGEO

适用读者:负责电商站点后端或数据平台的同学,商品页由多套模板渲染、SKU 量级在数千到数十万、需要持续给搜索引擎与生成式引擎输出结构化数据(Structured Data)的团队。示例环境为 Python 3.12 与 MySQL 8.0,思路可平移到其它语言栈。

一次改字段的经历:十几个模板与一个漏掉的字段

去年大促前一周,运营要求把商品页的价格有效期字段 priceValidUntil 从写死的季度末,改成随 SKU 的活动结束时间输出。我在仓库里搜了一遍,命中 14 个文件:PC 详情、移动详情、秒杀、预售、拼团,还有五套只在活动期上线的专题模板。改完上线。两周后富媒体报告里冒出 327 条缺失字段告警,顺着链接点进去,全是专题模板——那一份把字段拼进了另一个变量名,模板渲染不报错,只是输出了空串。

数据库经管线流向结构化文档

问题不在这一次改动,在于没有任何一处代码知道"商品页该输出哪些字段"这件事。字段散落在模板里,模板散落在仓库里,谁改谁知道。

管线总览:四层结构与一次完整流转

把这件事重写成一条管线后,结构变成四层:事实源、生成器、校验层、注入层。事实源是 MySQL 商品库;生成器读库、渲染、校验、落盘;注入层决定产物怎么出现在 HTML 里。

flowchart TD
    A[MySQL 商品库 pms_sku / pms_brand / pms_price] -->|按 updated_at 水位线增量拉取| B[Python 生成器]
    B --> C[Jinja2 模板渲染 JSON-LD 片段]
    C --> D[pydantic 模型校验与 jsonschema 复核]
    D -->|校验通过| E[产物目录 objects/schemas/版本号/]
    D -->|校验失败| F[拒绝清单 deadletter.jsonl 并告警]
    E --> G{注入方式}
    G -->|方案一| H[CDN 边缘按 URL 规则注入 script 标签]
    G -->|方案二| I[页面渲染时从产物缓存内联]
    H --> J[线上商品页]
    I --> J
    F --> K[人工修数后重跑增量]
    K --> B

四层之间的边界是硬边界。模板层不允许再写任何字段字面量,商品页只认注入层给的字符串。任何一次字段口径变更,落点只有一个:生成器里的映射表。

迁移前后的实测对照

下表取自迁移完成后连续四周的观测,抽样口径为全站 8.6 万个在售 SKU 的商品页。

指标 手写模板时期 生成器管线时期 变化
Product 必填字段完整率 62.4(抽样 1800 条) 99.1 提升 36.7 个百分点
改一个字段的生效耗时 1.5 人日,涉及 14 个模板加回归 15 分钟,改映射表后全量重跑 约 1/48
线上富媒体告警数 327 条每月 11 条每月,均来自第三方评价插件 下降约 96.6 个百分点
全量生成耗时 无此环节 6 分 20 秒,8.6 万 SKU,16 并发 增量常态 40 秒内
新模板接入成本 复制旧模板,连历史错误一起继承 接注入层,约 30 行胶水代码 归零重复劳动

完整率的提升几乎全部来自校验层:字段缺失不再静默通过,而是在产物落盘前被拦下。

事实源:MySQL 表设计与增量水位线

商品库里真正参与结构化数据输出的字段,建议单独圈出来,不要和交易字段混在同一张宽表里改。下面这张片段是可运行的最小集。

-- 运行环境:MySQL 8.0.36,InnoDB,utf8mb4_0900_ai_ci
-- 商品主表:结构化数据的取值来源,模板层不再持有任何字段默认值
CREATE TABLE pms_sku (
  sku_id        BIGINT UNSIGNED NOT NULL COMMENT 'SKU 内部 ID,与商品页 URL 一一对应',
  spu_id        BIGINT UNSIGNED NOT NULL COMMENT 'SPU 聚合并非必须,但便于复用主图与描述',
  title         VARCHAR(255)    NOT NULL COMMENT '商品标题,对应 schema.org 的 name',
  brand_id      BIGINT UNSIGNED NULL     COMMENT '品牌 ID,为空时生成器回落到店铺名',
  gtin13        CHAR(13)        NULL     COMMENT '国际商品条码,Offer 的可选但强烈建议字段',
  mpn           VARCHAR(64)     NULL     COMMENT '厂商零件号,3C 类目常用',
  main_image    VARCHAR(512)    NULL     COMMENT '主图完整 URL,要求 CDN 可直连',
  description   MEDIUMTEXT      NULL     COMMENT '纯文本描述,生成器会剥离 HTML 标签',
  status        TINYINT         NOT NULL DEFAULT 1 COMMENT '1 在售 2 下架 3 预告',
  updated_at    DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (sku_id),
  -- 水位线查询的核心索引:按更新时间游标扫描,避免全表排序
  KEY idx_sku_updated (updated_at, sku_id),
  -- 状态过滤走前缀索引,增量扫描时先收敛状态再回表
  KEY idx_sku_status (status, updated_at)
) ENGINE=InnoDB COMMENT='商品主表';

-- 价格表:价格与库存变动频率高于主表,单独成表以免污染主表水位线
CREATE TABLE pms_price (
  sku_id          BIGINT UNSIGNED NOT NULL,
  sale_price      DECIMAL(12,2)   NOT NULL COMMENT '含税售价,Offer 的 price',
  currency        CHAR(3)         NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217 货币码',
  price_valid_to  DATE            NULL     COMMENT '价格有效期终点,对应 priceValidUntil',
  stock_qty       INT             NOT NULL DEFAULT 0 COMMENT '可用库存,决定 availability 取值',
  updated_at      DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (sku_id),
  KEY idx_price_updated (updated_at)
) ENGINE=InnoDB COMMENT='价格与库存表';

-- 生成器水位线表:记录每个产线跑到的位置,支持按产线独立回放
CREATE TABLE gen_watermark (
  pipeline   VARCHAR(64)     NOT NULL COMMENT '产线名,如 jsonld_product',
  wm_value   DATETIME(3)     NOT NULL COMMENT '水位线时间戳',
  run_rows   INT             NOT NULL DEFAULT 0 COMMENT '本轮处理行数,用于异常比对',
  updated_at DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (pipeline)
) ENGINE=InnoDB COMMENT='生成水位线表';

字段设计上有三个容易被忽略的点。价格单独成表,否则一次调价会把主表 updated_at 推高,导致每轮增量都扫出大批实际上没变的 SKU。水位线单独建表而不是写在配置文件里,因为它必须是可查询、可回退、可并发安全更新的状态。updated_at 用毫秒精度,一秒内多次改价的场景在批量改价时很常见,秒级精度会丢数据。

生成器:Jinja2 渲染与双段校验

生成器分三段跑:增量拉取、渲染、校验。三段之间用内存里的列表传递,不落中间文件,失败整体回滚本轮。

# 运行环境:Python 3.12.6 / Jinja2 3.1.4 / pymysql 1.1.1 / pydantic 2.9.2 / jsonschema 4.23.0
# 安装命令:pip install "jinja2==3.1.*" "pymysql==1.1.*" "pydantic==2.9.*" "jsonschema==4.23.*"
# 说明:以下为生成器主干,省略连接池与日志初始化,可直接嵌入调度任务
from __future__ import annotations

# 标准库:os 读取环境变量,datetime 与 timedelta 处理价格有效期缺省值
import os
from datetime import date, datetime, timedelta

# 第三方库:pymysql 直连 MySQL,Jinja2 负责渲染,pydantic 负责结构校验
import pymysql
from jinja2 import Environment, FileSystemLoader
from pydantic import ValidationError

# 常量区:站点域名、单次批大小、价格缺省有效期天数
# 这些值建议从环境变量注入,便于多环境复用同一份生成器代码
SITE_HOST = os.getenv("SITE_HOST", "example.com")
BATCH_SIZE = int(os.getenv("BATCH_SIZE", "5000"))
DEFAULT_VALID_DAYS = 30

# Jinja2 环境:JSON-LD 不是 HTML,必须关闭 HTML 自动转义
# 否则 & 引号会被转成实体,导致 JSON 解析失败
env = Environment(
    loader=FileSystemLoader("templates"),
    autoescape=False,
    trim_blocks=True,
    lstrip_blocks=True,
)
# 模板文件只包含取值与条件拼接,业务判断一律留在 Python 侧
TEMPLATE = env.get_template("product.jsonld.j2")

# 可用性映射:库存与上架状态共同决定 schema.org 的受控枚举值
# 这里集中维护,模板与各调用方都不再各自写字符串字面量
AVAILABILITY = {
    "in_stock": "https://schema.org/InStock",
    "out_of_stock": "https://schema.org/OutOfStock",
    "preorder": "https://schema.org/PreOrder",
}


def fetch_delta(conn, watermark: datetime, batch: int = BATCH_SIZE):
    # 增量拉取:水位线为左开区间,避免同一毫秒的数据被重复处理
    # 价格表用 LEFT JOIN 一次性带出,省掉 N+1 查询
    # row_updated 取两表时间戳的较大值,作为本轮水位线推进依据
    sql = """
        SELECT s.sku_id, s.title, s.gtin13, s.mpn, s.main_image, s.description,
               s.status, p.sale_price, p.currency, p.price_valid_to, p.stock_qty,
               GREATEST(s.updated_at, COALESCE(p.updated_at, s.updated_at)) AS row_updated
        FROM pms_sku s
        LEFT JOIN pms_price p ON p.sku_id = s.sku_id
        WHERE s.updated_at > %s OR p.updated_at > %s
        ORDER BY row_updated
        LIMIT %s
    """
    with conn.cursor(pymysql.cursors.SSDictCursor) as cur:
        # SSDictCursor 为流式游标,8 万行数据不会把内存打满
        cur.execute(sql, (watermark, watermark, batch))
        for row in cur:
            yield row


def pick_availability(status: int, stock: int) -> str:
    # 预告状态优先于库存判断,避免未上架商品被标成可售
    if status == 3:
        return AVAILABILITY["preorder"]
    if status != 1 or stock <= 0:
        return AVAILABILITY["out_of_stock"]
    return AVAILABILITY["in_stock"]


def strip_html(raw: str | None) -> str | None:
    # 描述字段常带运营后台粘贴的 HTML 标签,需剥离成纯文本
    # 结构化数据里出现标签会被判为无效值
    if not raw:
        return None
    # 极简实现:去掉尖括号包裹的内容,生产环境建议换成专业清洗库
    import re as _re

    text = _re.sub(r"<[^>]+>", " ", raw)
    # 压缩连续空白,避免描述字段里出现大段换行
    return _re.sub(r"\s+", " ", text).strip()


def render_one(row: dict, site_host: str) -> str:
    # 渲染前先做字段归一化,模板里只做取值不做判断
    # 归一化是整条管线里仅有的、允许出现字段默认值的位置
    ctx = {
        "sku_id": row["sku_id"],
        "name": (row["title"] or "").strip(),
        "gtin13": row["gtin13"],
        "mpn": row["mpn"],
        # 主图允许为空,但校验层要求至少一个元素,故此处原样透传
        "image": row["main_image"],
        # 描述先剥离 HTML 标签再入模板
        "description": strip_html(row["description"]),
        "price": f"{row['sale_price']:.2f}" if row["sale_price"] is not None else None,
        "currency": row["currency"] or "CNY",
        # 有效期缺省时给 30 天,宁可保守也不要输出空串
        "price_valid_until": (row["price_valid_to"] or (date.today() + timedelta(days=30))).isoformat(),
        "availability": pick_availability(row["status"], row["stock_qty"] or 0),
        "url": f"https://{site_host}/p/{row['sku_id']}.html",
    }
    return TEMPLATE.render(**ctx)

模板本身保持极简,只做取值与条件拼接,判断逻辑全部留在 Python 侧。这样模板可以被运营侧同学读懂,也不会变成第二个业务逻辑藏身处。

校验层:pydantic 拦结构,jsonschema 拦词表

pydantic 负责类型与必填,jsonschema 负责枚举与格式。两者职责不同,合并成一层会让错误信息变得难读。

# 运行环境:Python 3.12.6 / pydantic 2.9.2 / jsonschema 4.23.0
# 说明:ProductModel 对应 schema.org/Product 的最小可用集,字段缺失即落 deadletter
# 两段校验的分工:pydantic 管类型与必填,jsonschema 管枚举与常量
from typing import Optional

# Dict202012Validator 预编译词表,实例化一次即可复用
from jsonschema import Draft202012Validator

# BaseModel 与 Field 描述结构,field_validator 挂载归一化钩子
from pydantic import BaseModel, Field, field_validator


class OfferModel(BaseModel):
    # price 用字符串承载,避免浮点精度在 JSON 序列化时抖动
    # 正则限制两位小数,杜绝 0.1 加 0.2 这类二进制误差产物
    price: str = Field(pattern=r"^\d+(\.\d{1,2})?$")
    # 货币码固定三位,长度约束即 ISO 4217 约束
    priceCurrency: str = Field(min_length=3, max_length=3)
    # 有效期允许为空,由上层按缺省天数补齐
    priceValidUntil: Optional[str] = None
    # 可用性取值来自受控词表,不允许自由文本
    availability: str
    # url 必须是可抓取的完整地址,相对路径在此层拦不住
    url: str

    @field_validator("priceCurrency")
    @classmethod
    def upper_currency(cls, v: str) -> str:
        # ISO 4217 要求大写,库里偶有小写脏数据
        # 归一化放在校验器里,避免污染上游渲染上下文
        return v.upper()


class ProductModel(BaseModel):
    # @context 与 @type 固定,不允许模板改写
    context: str = Field(alias="@context", default="https://schema.org")
    type: str = Field(alias="@type", default="Product")
    # 商品名至少两个字符,单字符多为脏数据
    name: str = Field(min_length=2, max_length=255)
    # image 是数组且至少一个元素,缺主图直接判失败
    image: list[str] = Field(min_length=1)
    # 描述可选,但如果存在则不应为空串
    description: Optional[str] = None
    # sku 落内部 ID,不用商品标题做键,避免同名规格互相覆盖
    sku: str
    # 条码为 13 位数字,位数不对说明上游同步有问题
    gtin13: Optional[str] = Field(default=None, pattern=r"^\d{13}$")
    # 厂商零件号,3C 类目常用,无格式约束
    mpn: Optional[str] = None
    # 品牌缺失时允许降级,由生成器回落店铺名
    brand: Optional[dict] = None
    # offers 为嵌套模型,价格与库存问题在这里暴露
    offers: OfferModel


# 词表级校验:枚举值必须落在 schema.org 定义的受控词表内
# 这一层补的是 pydantic 不擅长表达的常量约束
JSONLD_SCHEMA = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    # 顶层必填:上下文、类型、名称、主图、报价,缺一即不产出
    "type": "object",
    "required": ["@context", "@type", "name", "image", "offers"],
    "properties": {
        # 常量约束:类型只能是 Product,防止模板被误改成其它类型
        "@type": {"const": "Product"},
        # offers 子树:价格对象单独约束,枚举封闭
        "offers": {
            "type": "object",
            "properties": {
                # 可用性枚举封闭,拼错变量名在这里会被抓到
                "availability": {
                    "enum": [
                        "https://schema.org/InStock",
                        "https://schema.org/OutOfStock",
                        "https://schema.org/PreOrder",
                        "https://schema.org/BackOrder",
                    ]
                }
            },
        },
    },
}
VALIDATOR = Draft202012Validator(JSONLD_SCHEMA)


def validate_payload(payload: dict, sku_id: int) -> list[str]:
    # 先过 pydantic,结构与必填问题在这里一次性暴露
    # by_alias=True 是因为模型字段用 @context 这类带符号的名字
    try:
        ProductModel.model_validate(payload, by_alias=True)
    except ValidationError as exc:
        # 错误按字段位置聚合返回,便于 deadletter 里按类目统计
        return [f"{sku_id} pydantic: {e['loc']} {e['msg']}" for e in exc.errors()]
    # 再过 jsonschema,兜住枚举与常量这类 pydantic 不擅长描述的约束
    return [f"{sku_id} jsonschema: {e.message}" for e in VALIDATOR.iter_errors(payload)]

校验不通过的 SKU 不写产物,而是追加到 deadletter.jsonl 并按类目聚合告警。这件事的价值在于把"线上静默缺失"变成"离线显式报错",修复成本从排查一整条链路降到改一条数据。

机制剖析:水位线为什么既不漏也不重

增量生成最容易出问题的地方是边界。水位线机制用三个约束把它钉死。

第一,区间左开。查询条件是 updated_at > watermark,上一轮最后一条数据不会被重复捞取,重复捞取本身无害,但会掩盖"同一条数据被渲染两次且结果不同"这类真实问题。

第二,水位线取自本轮最大 row_updated,而不是本轮结束时间。若取结束时间,在本轮跑动期间发生但时间戳小于结束时间的修改会被永久跳过——这是最隐蔽的一种数据丢失。

第三,主表与价格表的时间戳取 GREATEST 合并后再推进。两侧各自维护水位线会让组合结果出现空洞。

sequenceDiagram
    participant W as 水位线表
    participant G as 生成器
    participant D as MySQL 商品库
    participant O as 产物目录
    participant L as deadletter
    G->>W: 读取 wm_value
    W-->>G: 2026-09-18 10:00:00.000
    G->>D: WHERE updated_at > 水位线 ORDER BY row_updated LIMIT 5000
    D-->>G: 返回 5000 行变更集
    loop 逐行处理
        G->>G: Jinja2 渲染
        G->>G: pydantic 加 jsonschema 双段校验
        alt 校验通过
            G->>O: 写入 sku_id.jsonld
        else 校验失败
            G->>L: 追加错误明细
        end
    end
    G->>W: 以本轮最大 row_updated 推进水位线
    Note over G,W: 未推进到系统时间,避免漏掉跑动期间的写入
    G->>G: 若本轮满 5000 行则继续下一批,否则结束

水位线还能直接支撑回滚。把 wm_value 改回上一个已知良好的时间点,重跑增量,产物目录就回到那个时刻的状态。比从备份恢复快两个数量级。

机制剖析:单一事实源如何消除模板间漂移

模板间不一致的本质,是同一个业务事实有多个可写副本。手写时期,priceValidUntil 在 14 个模板里有 14 份定义,其中 13 份正确,1 份拼错变量名。任何依赖于"所有副本同时被改对"的方案,在副本数增长时失败概率趋于 1。

换成单一事实源之后,副本数降到 1,但代价是新增了一层间接:商品页不再直接知道自己要输出什么,而是问注入层要。这层间接带来两个额外收益。校验可以集中做一次,不必在每个模板里重复防御。字段口径变更的审计也集中了,谁在什么时候把 availability 的库存阈值从 0 改成 1,映射表的提交记录里写得清清楚楚。

代价同样要明确:生成器成了新的单点。商品库不可用时,商品页的结构化数据会停留在上一次产物上。因此注入层必须容忍产物缺失——拿不到新产物就输出旧产物,两者都没有就不输出 script 标签,绝不能因为拿不到数据而让页面 500。

注入方式:CDN 边缘注入与渲染时内联

两种注入方式没有普适的优劣,取决于模板栈的收敛程度。

维度 预生成静态文件加 CDN 边缘注入 页面渲染时内联
一致性 全站共用同一份产物,天然一致 依赖各模板调用同一函数,模板越多越容易漏
时效性 受生成周期影响,常态分钟级 请求级实时,改价即刻生效
回滚速度 切换版本目录,秒级 需回滚应用代码并重发
额外依赖 对象存储、边缘函数或回源改写 仅需一个进程内缓存
适用团队 模板多、历史包袱重、老系统改不动 单一渲染栈、模板可控
主要风险 价格已变但产物未更新 渲染路径分散,容易漏接

多数存量站点的现实是模板栈短期内收敛不了,边缘注入更稳。新站点或者已经统一到一套渲染框架的系统,直接内联更简单,少一整套基础设施。

产物发布采用版本目录加原子切换,避免边写边读。

# 运行环境:Linux / GNU coreutils / bash 5.x / Python 3.12 生成器已完成一轮产出
# 约定:产物目录按版本号分目录,current 为符号链接,切换是原子操作
# 前置条件:生成器已退出且退出码为 0,否则不进入发布流程

# 生成一轮产物,版本号取当前时间戳,避免并发跑批互相覆盖
VERSION=$(date +%Y%m%d%H%M%S)
python -m gen.jsonld --pipeline jsonld_product --out /data/objects/schemas/$VERSION

# 校验产物规模,行数骤降通常意味着上游查询条件写错
COUNT=$(ls /data/objects/schemas/$VERSION | wc -l)
if [ "$COUNT" -lt 80000 ]; then
  echo "产物数量异常:$COUNT" >&2
  exit 1
fi

# 原子切换:ln -sTf 先建新链接再 rename,读进程不会看到半截目录
ln -sTf /data/objects/schemas/$VERSION /data/objects/schemas/.current.tmp
mv -Tf /data/objects/schemas/.current.tmp /data/objects/schemas/current

# 保留最近 5 个版本,回滚时直接改链接指向即可
ls -1dt /data/objects/schemas/20* | tail -n +6 | xargs -r rm -rf

灰度与回滚

灰度按 SKU 类目分批推进,而不是按流量百分比。按流量分会出现同一 SKU 在两个请求里拿到不同产物,排查时无法复现。

阶段 覆盖类目 观察指标 通过条件 回滚动作
第一阶段 图书、家居等低动销类目 富媒体告警数、产物命中率 连续 24 小时无新增告警 关闭该类目注入开关
第二阶段 服饰、美妆等主类目 页面首屏耗时、CDN 回源率 首屏耗时增幅低于 20 毫秒 回退到上一版本目录
第三阶段 秒杀、预售等动态类目 priceValidUntil 命中率、价格一致性抽样 抽样 200 条价格全部一致 水位线回退并重跑增量
全量 全站 字段完整率、deadletter 条数 完整率高于 98 整体关闭注入,回退手写兜底

回滚设计留了三层。最轻的一层是配置开关,按类目关注入,秒级生效,产物本身不动。中间一层是版本目录回指,把 current 指回上一个版本目录。最重的一层是水位线回退加增量重跑,用于产物内容本身有误的场景,代价是几十分钟的重算时间。三层覆盖了从"注入出问题"到"数据出问题"的全区间。

三个容易被忽略的坑

第一个是价格与产物的时序差。CDN 边缘注入的产物是分钟级的,秒杀场景下页面已经显示新价格而结构化数据还是旧价格。这类不一致会被判为误导性内容,解决方式是对动销极快的类目直接用渲染时内联,两条链路并存,按类目选路。

第二个是主图字段填相对路径。schema.org 的 image 要求可抓取的完整 URL,相对路径在本地预览时看不出问题,上线后校验全过但抓取失败。这个坑在 pydantic 层拦不住,得加一条域名前缀校验。

第三个是去重逻辑误伤。同名不同规格的商品若共用一套 sku 取值,会被搜索引擎判定为重复实体。SKU 维度必须落内部 ID,不要用商品标题做键。

参考与延伸

  • schema.org Product 类型定义:https://schema.org/Product
  • Google 结构化数据产品片段文档:https://developers.google.com/search/docs/appearance/structured-data/product
  • Jinja2 官方文档:https://jinja.palletsprojects.com
  • pydantic 官方文档:https://docs.pydantic.dev

字段映射表里还有一批类目特有属性,比如服饰的尺码体系、生鲜的重量单位,这些字段的口径往往要和外部词表对齐。你们的类目里哪些字段最难维护,评论区聊聊。

JSON-LD 结构化数据, Python 生成器, Jinja2 模板, pydantic 校验, MySQL 增量水位线, CDN 边缘注入, GEO, AI优化AIO

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。