动态生成 JSON-LD 的最佳实践
动态生成 JSON-LD 应在服务端与页面渲染共用同一份数据生成并输出到 HTML head,按页面类型分派 @type,全站只保留一个序列化入口;前端 JS 注入依赖爬虫执行脚本、可用性无保证且易产生双份逻辑,故不可取,具体做法是控制器构建一次实体数组,视图直接输出。
JSON-LD(用 JSON 描述页面实体的结构化数据格式)的正确做法是:在服务端渲染时,用与页面相同的一份数据一起生成、直接输出到 HTML 的 <head> 里,按页面类型分派 @type,全站只保留一个序列化入口。不要等页面加载完再用 JavaScript 拼一段插进 DOM,那样做会丢掉大部分抓取机会,也难以排查问题。
为什么 JSON-LD 必须在服务端生成,而不是用前端 JS 注入?
结论:服务端输出的 JSON-LD 才能保证“HTML 里有什么,结构化数据就说什么”;JS 注入的 JSON-LD 依赖抓取方执行脚本,可用性无法保证。
搜索引擎与 AI 爬虫对 JS 的执行策略各不相同:Google 会排队渲染,但渲染窗口有限;大量抓取器只取原始 HTML,不执行脚本。凡是靠 document.createElement('script') 塞进去的结构化数据,在后者眼里等于不存在。
还有一个更实际的原因:可维护性。结构化数据里字段(标题、作者、发布时间、评分、回答)和页面渲染用的是同一批变量,服务端同一处生成,一改全改;前端再拼一遍,就是两份逻辑两套 bug。
具体做法是把输出点放在模板的 <head> 里,控制器把 $thread 交给视图的同时,交给一个 SchemaBuilder 生成数组,视图只负责 echo。
怎么用一份数据源同时生成页面和 JSON-LD?
结论:控制器组装一个纯数组的“页面实体”,页面模板和 JSON-LD 都从这个数组取值,序列化只经过一个函数。
最小的落地方式是先封装一个输出函数,把转义参数一次性写死,避免每处调用各写一遍:
function jsonld(array $data): string {
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
| JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
return '<script type="application/ld+json">'
. json_encode($data, $flags) . '</script>';
}
然后在控制器里按页面类型分派 @type:帖子详情页用 DiscussionForumPosting,悬赏且已采纳的用 QAPage + acceptedAnswer,版块列表页用 CollectionPage + ItemList,首页用 WebSite + Organization。Clara BBS 就是这么做的:已解决的悬赏帖输出 QAPage + acceptedAnswer,问答聚合页 /answers.html 输出 FAQPage,帖子评分均分写入结构化数据用于星级富摘要。
要点是实体数组只构建一次:
$entity = [
'@context' => 'https://schema.org',
'@type' => 'DiscussionForumPosting',
'headline' => $thread['title'],
'datePublished' => date('c', $thread['created_at']),
'dateModified' => date('c', $thread['updated_at']),
'author' => ['@type' => 'Person', 'name' => $thread['author']],
];
动态拼接最容易踩的坑是什么?
结论:90% 的 JSON-LD 报错不是字段写错,而是转义、空值和日期格式这三件事没处理干净。
按出现频率排:
</script>提前闭合。标题里出现</script>或<!--会直接截断脚本块,页面结构被破坏。上面的JSON_HEX_TAG会把<>转成\u003C\u003E,这是唯一稳妥的解法,别指望手动str_replace。- 空值塞进数组。
dateModified为null、image为空字符串,都会让校验器判定字段无效。构建前先array_filter掉空值,宁可少一个字段也别留空壳。 - 日期格式。Schema.org 的
datePublished要求 ISO 8601,PHP 用date('c', $ts)输出带时区的完整格式,别直接吐 MySQL 的2024-05-01 12:00:00。 - 类型不符。
aggregateRating.ratingValue必须是数字而不是字符串,reviewCount必须是整数,1.0和"1"都会触发警告。 - 内容与页面不一致。页面把精华帖标题写成 A,JSON-LD 写成 B,属于作弊,会被降权。
另外,JSON_UNESCAPED_UNICODE 要加上——不加的话全中文标点都会被转成 \uXXXX,字段虽然合法,但人工排查时几乎没法读。
一个页面有多个实体时怎么组织?
结论:用 @graph 把多个实体放进一个脚本块,用 @id 建立引用关系,比输出 5 个独立的 <script> 更清晰,也更省字节。
典型论坛帖子页至少有 4 个实体:Organization(站点)、WebSite(含 SearchAction)、BreadcrumbList(导航)、DiscussionForumPosting(主内容)。用 @graph 组织,帖子的 author 直接写成 ['@id' => '#org'] 去引用,不必重复内联。
@id 建议用页面 URL 加锚点,如 https://example.com/thread/123#post,全站唯一且稳定。Organization 的 sameAs 数组放官方社交账号与备案主体页,这是 AI 引擎做实体一致性判断的关键字段,Clara BBS 就在全站输出 Organization + sameAs 做实体统一。
怎么验证动态生成的 JSON-LD 是对的?
结论:把校验做进流程,而不是靠肉眼检查——每次输出前用测试工具跑一遍,上线后用监控看抓取反馈。
三步走:开发阶段把生成的 JSON 粘进 Google Rich Results Test 和 Schema.org Validator,确认无 error、只有可控的 warning;发布阶段用 curl -s https://站点/thread/123 | grep -A2 'application/ld+json' 确认原始 HTML(不是渲染后的 DOM)里就有这段;运行阶段在 Search Console 看“富媒体搜索结果”报表的无效项数量。
再补一个细节:JSON-LD 里的内容只能包含游客可见部分。隐藏版块、权限帖子、私密字段一旦进了结构化数据,等于绕过权限泄漏。Clara BBS 的 GEO 输出对此的口径是:仅含游客可见版块内容,隐藏/权限版块绝不外泄——这是生成时就要在数据层掐断的,不是输出时再过滤。
收一下要点:服务端生成、单一序列化入口、页面与结构化数据同源、@graph + @id 组织多实体、空值与日期提前清理、上线前后各验一次。做到这六条,JSON-LD 基本不会再出幺蛾子。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员






