09. 程序化 SEO 与规模化页面供给 · 资料陈述 · 教学步骤 3
把页面拆成稳定内容模型
稳定结构让内容能够批量生成、局部更新、审核和重跑。
原知识点:Woy的CMS把内容拆成Page、Section、Content与Tag,分别承载公共页面、章节、结构化内容和分类关系。
执行步骤
- 先画实体关系:页面解决什么任务,Section承担哪些答案,Content保存哪些事实,Tag只用于哪些分类。
- 为每个字段规定类型、是否必填、允许空值、唯一键和校验规则。
- 为事实字段增加来源、获取时间、许可、有效期和审核状态。
- 写出一条完整示例记录并渲染成页面,检查缺值、冲突和转义。
- 给Schema标版本;字段变更先迁移预览数据再进入生产。
可复制工作表
# JSON Schema(复制后把__替换为实际值)
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "__",
"title": "RoutePage",
"type": "object",
"additionalProperties": false,
"required": ["id", "slug", "originId", "destinationId", "facts", "status", "schemaVersion"],
"properties": {
"id": {"type": "string", "minLength": 1},
"slug": {"type": "string", "pattern": "^[a-z0-9-]+{{INITIAL_CONTENT}}quot;},
"originId": {"type": "string", "minLength": 1},
"destinationId": {"type": "string", "minLength": 1},
"facts": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["field", "value", "unit", "sourceUrl", "checkedAt", "licenseStatus"],
"properties": {
"field": {"enum": ["distanceKm", "durationMinutes", "fare"]},
"value": {"type": "number", "minimum": 0},
"unit": {"enum": ["km", "minute", "CNY"]},
"sourceUrl": {"type": "string", "format": "uri", "pattern": "^https://"},
"checkedAt": {"type": "string", "format": "date-time"},
"licenseStatus": {"enum": ["approved", "restricted", "unknown"]}
},
"allOf": [
{"if": {"properties": {"field": {"const": "distanceKm"}}, "required": ["field"]}, "then": {"properties": {"unit": {"const": "km"}}}},
{"if": {"properties": {"field": {"const": "durationMinutes"}}, "required": ["field"]}, "then": {"properties": {"unit": {"const": "minute"}}}},
{"if": {"properties": {"field": {"const": "fare"}}, "required": ["field"]}, "then": {"properties": {"unit": {"const": "CNY"}}}}
]
}
},
"tags": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"status": {"enum": ["draft", "review", "published", "withdrawn"]},
"schemaVersion": {"const": "__"}
}
}
```
# 数据库Schema
```sql
CREATE TABLE route_page (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
origin_id TEXT NOT NULL,
destination_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('draft','review','published','withdrawn')),
schema_version TEXT NOT NULL,
UNIQUE(origin_id, destination_id)
);
CREATE TABLE page_fact (
page_id TEXT NOT NULL REFERENCES route_page(id),
field TEXT NOT NULL CHECK (field IN ('distanceKm','durationMinutes','fare')),
value_json TEXT NOT NULL CHECK (json_valid(value_json)) CHECK (json_type(value_json) IN ('integer','real')) CHECK (CAST(value_json AS REAL) >= 0),
unit TEXT NOT NULL CHECK (
(field='distanceKm' AND unit='km') OR
(field='durationMinutes' AND unit='minute') OR
(field='fare' AND unit='CNY')
),
source_url TEXT NOT NULL CHECK (source_url LIKE 'https://%'),
checked_at TEXT NOT NULL CHECK (
checked_at GLOB '????-??-??T??:??:??Z' AND
datetime(checked_at) IS NOT NULL AND
strftime('%Y-%m-%dT%H:%M:%SZ', checked_at)=checked_at
),
license_status TEXT NOT NULL CHECK (license_status IN ('approved','restricted','unknown')),
PRIMARY KEY(page_id, field, source_url)
);
```
# 示例记录
```json
{"id":"__","slug":"__","originId":"__","destinationId":"__","facts":[{"field":"distanceKm","value":0,"unit":"km","sourceUrl":"https://__","checkedAt":"__","licenseStatus":"approved"}],"tags":["__"],"status":"draft","schemaVersion":"__"}
```
校验命令/工具:__
预期通过:__
预期失败记录及错误:__
迁移前版本/记录:__
迁移步骤:__
迁移后版本/记录:__
回滚命令/条件:__
完整示例
示例(ID、命令与数字仅演示可复现填法):
Schema采用模板中的RoutePage,$id=https://schemas.example.test/route-page-2.0.0.json,schemaVersion=2.0.0;数据库迁移为db/migrations/021_route_page_v2.sql。JSON Schema把distanceKm、durationMinutes、fare都限定为非负数字,并用条件规则绑定km、minute、CNY;SQLite同时校验合法JSON、非负JSON数值、字段—单位、HTTPS来源和UTC真实日历日期;日期解析后必须格式化回原字符串,不能让SQLite把2月30日静默归一成3月2日。
完整通过记录:
{"id":"r-sh-hz","slug":"shanghai-to-hangzhou","originId":"sh","destinationId":"hz","facts":[{"field":"distanceKm","value":176,"unit":"km","sourceUrl":"https://data.example.gov/route/sh-hz","checkedAt":"2026-07-31T08:00:00Z","licenseStatus":"approved"}],"tags":["rail"],"status":"draft","schemaVersion":"2.0.0"}
校验命令与结果:
- npx ajv-cli validate -s schemas/route-page-2.0.0.json -d fixtures/route-valid.json --spec=draft2020 → valid。
- sqlite3 qa/route-v2.db < db/migrations/021_route_page_v2.sql,再导入route-valid.json → 1条写入且唯一、JSON类型、单位、来源和日期约束全部通过。
- npm test -- route-page-render.test.mjs route-page-constraints.test.mjs → 有效记录与9类负例共10/10通过。
列较多时可左右滚动;首列会固定,便于逐行比较。
| 失败fixture/测试 | 注入错误 | 预期错误 | 实际结果 | 发布门禁 |
|---|---|---|---|---|
| route-missing-source.json | 删除facts[0].sourceUrl | missing required property sourceUrl | AJV在facts[0]报同一错误 | 保持draft |
| route-wrong-value-type.json | 把distanceKm的value改成字符串“176” | value must be number | AJV在facts[0].value报同一错误 | 拒绝导入 |
| route-wrong-unit.json | distanceKm使用unit=minute | unit must be equal to constant km | 条件Schema在facts[0].unit报同一错误 | 拒绝导入 |
| route-invalid-json.sql | 直接写入value_json='not-json' | CHECK json_valid(value_json) | SQLite CHECK constraint failed: json_valid | 拒绝写入 |
| route-invalid-date.sql | checked_at写2026-02-30T08:00:00Z | strftime往返结果必须仍等于原UTC字符串 | SQLite把它解析为03-02,往返相等CHECK失败 | 拒绝写入 |
| route-negative-value.sql | value_json写-1 | 数值必须>=0 | SQLite CHECK constraint failed: CAST(value_json AS REAL)>=0 | 拒绝写入 |
| route-duplicate-pair.json | 用不同slug重复sh→hz | UNIQUE(origin_id,destination_id) | SQLite UNIQUE constraint failed | 拒绝导入 |
| route-conflict.json | 两个approved来源给distanceKm 176与181 | FACT_CONFLICT,需人工选择 | 渲染器返回409并列出两个sourceUrl | 不生成生产HTML |
| route-escape.json | tags[0]含<script>alert(1)</script> | 文本转义且脚本不执行 | 快照为<script>,DOM中script节点0 | 允许预览 |
迁移前快照route-v1-20260731.jsonl有120条,旧字段distance为“176 km”。执行node scripts/migrate-route-v1-to-v2.mjs --input route-v1-20260731.jsonl --output route-v2.jsonl --report migration-v2.csv:116条拆成数字value与匹配unit并通过,4条不可解析进入review,0条静默丢弃。随后运行node scripts/validate-route-v2.mjs route-v2.jsonl与上述数据库/渲染测试,116/116通过;只有签字后的记录才从draft转published。回滚制品为快照route-v1-20260731.jsonl和reader-v1镜像;任一丢记录、负数/真实日历日期约束失效、冲突静默覆盖或渲染测试失败即执行deploy rollback reader-v1并恢复只读快照。负责人为数据工程师吴,复查日2026-08-02。
完成清单
- JSON Schema和数据库都约束非负事实值、字段—单位、必填、唯一、枚举、来源与真实日历日期。
- 包含完整通过记录,以及错误类型、负数、非法JSON和无效日历日期等具有预期错误的失败记录。
- 迁移前后结构、数据转换、验证命令和回滚条件齐全。
- 约束与渲染测试能证明缺值、类型、数值范围、日期、冲突和转义行为符合发布门禁。
适用条件
页面类型和章节类型需有稳定语义,不能退化成无约束富文本。
来源:SEO友好的AI原生 CMS 的思考和实践