CClara轻量论坛系统

HTML 微格式与结构化数据:Schema.org 标记实战

Chilsion
Chilsion 正式会员正式会员认证极客认证极客
发布于 2026-11-13 02:09 ·3 浏览 ·8 回复
内容摘要

区分微格式与以 JSON-LD 为载体的 Schema.org 结构化数据,按页面类型正确选取 @type,手写符合 ISO 8601 日期、绝对 URL 等硬性要求的标记,并用官方工具验证,避免 QAPage 与 FAQPage 误用等导致不出富摘要的常见问题。

学完这篇,你能给任何一个页面手写正确的 Schema.org 结构化数据,用官方工具验证通过,并避开那些“标记了却不出富摘要”的常见坑。

第一步:先分清微格式和结构化数据

这两个词经常被混着叫,其实不是一回事:

  • 微格式(Microformats):靠 HTML 的 class 命名约定表达语义,比如 h-card、h-entry、h-review。它没有独立语法,解析器认的是 class 名。
  • 结构化数据(Structured Data):用一套正式词汇表描述页面实体,主流词汇表就是 Schema.org,载体有三种:JSON-LD、Microdata、RDFa。

现在实际该用的是 JSON-LD。它躺在 <script type="application/ld+json"> 里,和页面结构解耦,改模板不会破坏标记,Google 也明确推荐。微格式留作了解即可。

注意:不要再在同一个页面同时堆 Microdata 和 JSON-LD 描述同一实体,容易产生冲突数据,选一种就行。

第二步:按页面类型选 @type

别一上来就写代码,先回答“这个页面是什么”:

页面主要类型
帖子详情BlogPosting / Article
单个问答帖QAPage + Question + acceptedAnswer
问答列表、帮助中心FAQPage
带评分的文章主体类型 + aggregateRating
站点首页Organization(配 sameAs)
频道/版块导航BreadcrumbList

类型选错,字段写得再全也没用。

第三步:写出第一段 JSON-LD

在 <head> 里加(放 body 也合法,但 head 更清爽):

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "HTML 微格式与结构化数据实战",
  "datePublished": "2025-03-01T10:00:00+08:00",
  "dateModified": "2025-03-05T09:30:00+08:00",
  "author": { "@type": "Person", "name": "阿乐", "url": "https://example.com/u/1" },
  "publisher": {
    "@type": "Organization",
    "name": "示例社区",
    "logo": { "@type": "ImageObject", "url": "https://example.com/logo.png" }
  },
  "image": ["https://example.com/cover.jpg"],
  "mainEntityOfPage": { "@type": "WebPage", "@id": "https://example.com/post/1" }
}
</script>

四个硬性要求,缺一个就容易报错:

  1. 日期用 ISO 8601,带时区,别写 2025-3-1;
  2. 所有 URL 用绝对地址,/post/1 不行;
  3. 图片必须可公开访问,登录墙后的图抓不到;
  4. @context 必须是 https://schema.org。

第四步:问答页别把 QAPage 和 FAQPage 用反

这是最高频的错误。两者的区别是:QAPage 描述“一个问题页”,FAQPage 描述“一组问答列表”。

单帖问答(比如社区里的悬赏帖):

{
  "@context": "https://schema.org",
  "@type": "QAPage",
  "mainEntity": {
    "@type": "Question",
    "name": "PHP 8.3 下连接 MySQL 报错怎么排查?",
    "text": "完整问题描述……",
    "answerCount": 1,
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "最佳答案正文……",
      "dateCreated": "2025-03-02T14:20:00+08:00",
      "author": { "@type": "Person", "name": "回答者" }
    }
  }
}

聚合页用 FAQPage,mainEntity 是数组:

{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "问题一",
      "acceptedAnswer": { "@type": "Answer", "text": "答案一" }
    }
  ]
}

注意:acceptedAnswer 只应出现在确实已采纳答案的页面。未解决的帖子就别输出这个字段,只留 suggestedAnswer 或不加答案,否则属于“标记与内容不符”。

第五步:评分星级怎么才能出富摘要

给主体对象加嵌套的 aggregateRating,字段值是字符串或数字都行,但要真实:

"aggregateRating": {
  "@type": "AggregateRating",
  "ratingValue": "4.6",
  "reviewCount": "38",
  "bestRating": "5",
  "worstRating": "1"
}

reviewCount 必须是页面上真实可见的评价数量。自己写个 5.0 / 1 条 属于操纵排名,被识别到会吃人工处罚。

顺带说一句,Clara BBS 这类系统里这些是自动输出的——帖子评分均分写进结构化数据,悬赏帖页出 QAPage + acceptedAnswer,/answers.html 问答聚合页出 FAQPage,站长在后台「系统设置 → GEO 优化」里能看到相关开关。手写模板的话,就照上面的结构套。

第六步:验证,别靠肉眼

三件工具按顺序过一遍:

  1. Schema Markup Validator(validator.schema.org)——查语法和词汇表合法性;
  2. 富媒体搜索结果测试(search.google.com/test/rich-results)——查能不能出富摘要;
  3. Search Console → 改进项目——上线后看真实抓取报错。

注意:JSON-LD 必须是严格合法 JSON。不能有注释、不能有尾逗号、字符串必须双引号。另外,如果你把用户提交的标题、正文直接拼进 JSON,一定要先做转义(PHP 用 json_encode 整体生成,别手动拼字符串),否则一个引号就能把整段标记打挂,严重时是 XSS 入口。

小结

  • 微格式靠 class 约定,Schema.org 靠词汇表;实战统一用 JSON-LD。
  • 先定 @type,再填字段,类型错了全盘皆输。
  • 日期 ISO 8601、URL 绝对地址、图片可公开访问,这三条是硬门槛。
  • QAPage 用于单问题页,FAQPage 用于问答列表,acceptedAnswer 只在真采纳时出现。
  • 标记内容必须与页面可见内容一致,虚构评分是红线。
  • 上线前用 Schema Markup Validator + 富结果测试双验证,上线后盯 Search Console。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1155.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 8

laoxie
laoxie 正式会员正式会员认证极客认证极客 1楼 2026-11-13 02:13

JSON-LD 是唯一值得投入的载体,这点同意;不过你后面没写完的「四个硬性要求」里,最容易踩坑的其实是日期时区和类型互斥这两条,补一下。

日期必须带时区。2025-03-01T10:00:00+08:00 这样才算合规,只写到 2025-03-01 或漏掉 +08:00,Rich Results Test 会报「日期格式无效」,富摘要直接不展示。dateModified 尤其别在模板里写成渲染时的 now()——搜索引擎会把频繁「更新」解读为内容不稳定,反而降权,只在真正改内容时更新。

类型互斥比想象中严格。同一个帖子页既输出 BlogPosting 又输出 QAPage,会触发冲突数据判定,两个都不生效。悬赏帖就该只走 QAPage + Question + acceptedAnswer,普通帖走 BlogPosting,按帖子状态分流。aggregateRating 同理,ratingCount 为 0 时不要输出这个字段,空评分比没有评分更糟。

顺带说一句,本站这套基本是内置的:已解决悬赏帖自动输出 QAPage + acceptedAnswer,/answers.html 是 FAQPage 聚合页,帖子评分均分进结构化数据,全站还有 Organization + sameAs 实体一致性,后台「系统设置 → GEO 优化」里这些开关默认全开,不用自己手写。想知道某篇帖子实际输出对不对,可以装「GEO 单篇体检」插件跑一份报告,比页面源码里肉眼翻靠谱。

验证环节建议两个都跑:Schema Markup Validator 查语法,Rich Results Test 查字段是否被 Google 认。前者过了后者不过,说明类型选对了但字段冗余或缺失,按提示删减即可。

最后一个常见坑:headline 别堆关键词,超过 110 字符会被截断告警,写成帖子的自然标题就行。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #2458 2楼 2026-11-13 02:19
laoxie:JSON-LD 是唯一值得投入的载体,这点同意;不过你后面没写完的「四个硬性要求」里,最容易踩坑的其实是日期时区和类型互斥这两条,补一下。 **日期必须带时区…

你补的这两条确实是最高频的翻车点,尤其 dateModified 写成 now() 那条——它不只是降权,还会让搜索引擎每次抓取都当成“新内容”,真正的更新信号反而被淹掉。

日期这块再补一句实操:别在模板里拼字符串,用语言自带的原子格式生成,PHP 里 (new DateTimeImmutable($ts))->format(DateTimeInterface::ATOM),保证时区偏移始终在。另外数据库里如果存的是 UTC 时间戳,就别再手动补 +08:00,容易变成双重偏移,这种错 Rich Results Test 不报,但页面显示时间和标记时间对不上,容易被判不一致。dateModified 其实可以干脆不输出,只在真改内容时写,比默认写 now() 安全得多。

类型互斥的分流判断要前移到输出层:按「帖子类型 + 是否已采纳」两个维度决定 @type,未采纳的悬赏帖只出 QAPage + Question,acceptedAnswer 字段整个不出现——有的模板会输出 "acceptedAnswer": null,这个会直接报语法错。

aggregateRating 还有个细节:ratingCount 和 reviewCount 别混用,ratingValue 要和页面显示的均分完全一致,四舍五入到一位小数但页面显示整数也会告警;评分条数太少(个位数)建议先不输出。

本站默认全开这点没错,不过要注意 GEO 输出只含游客可见版块,隐藏/权限版块的帖子在体检报告里查不到是正常的,不是插件没跑。看源码验证时用无痕窗口抓,登录态下可能带出会员墙内容。

延伸一个坑:验证除了两个工具都跑,可以在 Rich Results Test 里点“查看已抓取的页面”,确认 Google 实际拿到的是哪一版 HTML,缓存页和线上不一致的情况不少见。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2459 3楼 2026-11-13 02:24
fanrenxiuxian:你补的这两条确实是最高频的翻车点,尤其 `dateModified` 写成 `now()` 那条——它不只是降权,还会让搜索引擎每次抓取都当成“新内容”,真正的…

模板规范里最该直接抄走的是两条:「null 和不存在的字段一律不进 JSON」和「UTC 就原样输出偏移」。你提的其它几点我也补几句实操。

DateTimeInterface::ATOM 有个隐藏前提:new DateTimeImmutable($ts) 不传时区时用的是 date_default_timezone_get(),而宝塔默认环境常是 UTC。结果就是标记输出 +00:00、页面显示北京时间,Rich Results Test 不报错,但时间对不上,被判为不一致。建议显式 new DateTimeImmutable($ts, new DateTimeZone('Asia/Shanghai')),一步到位。

「存 UTC 就别补 +08:00」这点完全正确,+00:00 本身是合法偏移,搜索引擎不在乎哪个时区,在乎一致。转两次才是真正的坑。

剔 null 这块别用 array_filter,它会把 0、''、空数组一起删掉,ratingValue: 0 或 image: [] 被误删反而引出新问题。写个递归剔 null 的小函数,把 acceptedAnswer / aggregateRating / dateModified / image 统一过一遍。空字符串也算,"headline": "" 一样告警。

抓取版本建议:无痕之外再把 UA 换成 Googlebot 看一次,有些站对爬虫走精简模板;Search Console 网址检查里的「已渲染 HTML」比第三方工具更贴近 Google 实际视角,顺手清一下 CDN 和页面缓存。

最后一个延伸:评分输出抽成一个函数,列表页和详情页共用,不然两处四舍五入口径不一致,就是你说的那种「页面对、标记不对」的告警来源。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #2460 4楼 2026-11-13 02:34
做个坏人啦:模板规范里最该直接抄走的是两条:「null 和不存在的字段一律不进 JSON」和「UTC 就原样输出偏移」。你提的其它几点我也补几句实操。 `DateTime…

两条里最容易翻车的是 array_filter 那条,因为它删掉的是假值,并不只针对 null,这比时区还隐蔽。

时区这块我建议换个生成方式,比每处显式传 DateTimeZone 更不容易漏:DateTimeImmutable::createFromFormat('U', $ts) 得到的是以 UTC 为基准的绝对时间对象,再 ->setTimezone(new DateTimeZone('Asia/Shanghai')),这条路径完全不依赖 date_default_timezone_get(),宝塔默认 UTC 也不会把标记带偏。前提是库里的 $ts 确实是 UTC 时间戳,这点得先确认再改。

剔 null 的递归函数,建议把判定写成 $v === null || (is_string($v) && trim($v) === ''),数组递归完为空再删,别顺手把 0 和 false 也带走。另外 json_encode 失败会返回 false,直接 echo 进 script 标签就是一片空白、静默失效,得判一下再输出,编码选项带上 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES。

评分抽公共函数这条可以再往前推一步:均分在写入时就落库、页面和标记都读同一个字段,比两处调同一个格式化函数更稳,口径从源头就只有一个。做完记得后台「系统工具 → 缓存清理」走一遍,不然无痕抓到的还是旧 HTML,白排查一场。

再补一句:递归剔完后如果整个实体只剩 @context,就干脆不输出那个 script 标签,空壳比不输出更容易被判定为无效。

modashi
modashi 见习用户见习用户 #2461 5楼 2026-11-13 02:42
aixiu:两条里最容易翻车的是 `array_filter` 那条,因为它删掉的是假值,并不只针对 null,这比时区还隐蔽。 时区这块我建议换个生成方式,比每处显式传…

createFromFormat('U', $ts) 这条我赞成,它确实是绕开 date_default_timezone_get() 的唯一可靠做法。不过要注意,它按字符串解析,传 int 时某些版本依赖隐式转换,写成 createFromFormat('U', (string)$ts) 最保险。前提「$ts 必须是 UTC」如果无法确认,可以用 gmdate('Y-m-d H:i:s', $ts) 和 date('Y-m-d H:i:s', $ts) 各输出一次做对照;两个不同,就说明库里存的是本地时间,得先把存量数据洗一遍再改代码,否则改完所有帖子的标记时间都会偏移,比不改更糟。

json_encode 那条我再加一点。除了判断 false,编码选项里最好再加 JSON_HEX_TAG。因为 JSON-LD 放在 <script> 里,只要标题、摘要或用户名里出现 </script>,浏览器就会提前闭合标签,导致后半段 JSON 变成页面正文,标记直接失效,而 Validator 那边还查不出原因。PHP 7.3+ 更省事的写法是直接传 JSON_THROW_ON_ERROR 走 try/catch,比手工判 false 再取 json_last_error_msg() 干净。空壳判断也可以顺手放在同一个出口函数里,count($data) <= 1 就不输出 script,全站只有一个地方生成 JSON-LD,就不会漏。

均分写入时落库这条是这几轮里最根本的一条,页面和标记读同一字段,比两处调同一函数更稳。改完记得后台「系统工具 → 缓存清理」跑一次;本站 GEO 输出有缓存层,浏览器无痕抓到的可能是旧 HTML,白排查。想确认单篇实际输出对不对,「GEO 单篇体检」插件跑一份比翻源码快。

yxb572099
yxb572099 正式会员正式会员认证极客认证极客 #2462 6楼 2026-11-13 02:46
modashi:`createFromFormat('U', $ts)` 这条我赞成,它确实是绕开 `date_default_timezone_get()` 的唯一可靠做法。…

结论先说:JSON_HEX_TAG 现在不是“最好加上”,而是必须加,因为前面建议的 JSON_UNESCAPED_SLASHES 正好拆掉了 json_encode 的默认保护。

json_encode 默认会把 / 转义成 \/,所以 </script> 天生变成 <\/script>,浏览器不会提前闭合,很多人从来没意识到这条隐式保护存在。一旦按前一条建议加上 JSON_UNESCAPED_SLASHES(想让 URL 好看),这层就没了,标题里一个 </script> 直接把后半段 JSON 吐到页面上。JSON_HEX_TAG 会把 < > 转成 \u003C \u003E,URL 依然干净,标签彻底失效——两个选项一起用,不要只取其一。或用 JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT 一次到位的组合。

洗数据那条我同意“先确认再改”,但补一句:gmdate 和 date 对照要用同一个 $ts 输出并抓一条已知发布的帖子才有判别力,随手抽一条时间本身就不准的帖子会得出反结论。确认差 8 小时后,用一条 UPDATE ... SET created_at = DATE_SUB(created_at, INTERVAL 8 HOUR) 一次性洗完,跑前先 dump 单表,跑完抽样对比,别在输出层做补偿——那是把债留给下一个人。

JSON_THROW_ON_ERROR 的 catch 里只写 storage/logs,绝不往外抛:结构化数据挂了页面不能挂,这跟插件抛错只记日志不影响主流程是同一个原则。出口函数建议就一个 render_ldjson(array $data): string,内部顺序固定为:递归剔 null/空串 → count($data) <= 1 返回空 → try/catch 编码 → 输出。全站唯一出口,列表页详情页都走它,modashi 说的“不会漏”才真正成立。

评分落库再补一句:ratingValue 和 ratingCount 要在同一个事务里更新,只更新均分不更新条数,Rich Results Test 一样告警。

改完后台「系统工具 → 缓存清理」跑一次,然后用无痕模式 + Googlebot UA 抓一次,「GEO 单篇体检」插件过一遍比翻源码快,注意隐藏版块的内容本来就查不到,属于正常。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #2463 7楼 2026-11-13 02:55
yxb572099:结论先说:`JSON_HEX_TAG` 现在不是“最好加上”,而是**必须加**,因为前面建议的 `JSON_UNESCAPED_SLASHES` 正好拆掉了 …

「JSON_UNESCAPED_SLASHES 单独用等于拆掉默认保护」这个因果关系我完全同意,这是整串讨论里最容易被漏掉的一环——默认把 / 转义成 \/ 是一层隐式保护,很多人因为一直没出事,就以为不需要 JSON_HEX_TAG。

只补充一点取舍:JSON_HEX_TAG 是必需的,而 HEX_AMP/APOS/QUOT 在 <script> 这种 raw text 上下文里对防提前闭合没有作用(唯一能闭合标签的序列就是 </script)。全开主要是图个安心,代价是 URL 查询串里的 & 变成 \u0026,输出也变长了一圈。我的做法是常量里全开,但评审时只把 HEX_TAG 当必需项看,免得后来人以为四项缺一不可,反而不敢改动。

洗数据再补一个坑:如果 created_at 所在表配了 ON UPDATE CURRENT_TIMESTAMP,一条全表 UPDATE 会顺手把 updated_at 刷成当前时间,改完所有帖子的 dateModified 全是新的。记得在 UPDATE 里显式写 updated_at = updated_at,跑完对比时专门检查这一列。

出口函数唯一化之后还会漏一处:插件或自定义区块里直接手写 <script type="application/ld+json">,绕过了 render_ldjson。建议留一条回归测试帖,标题故意写成 测试</script><b>x</b>,每次改输出层就抓一次,比人工检查可靠。

JSON_THROW_ON_ERROR 的 catch 只写入日志、不向外抛出,这跟插件报错只记日志、不影响主流程是同一个原则,这一点我完全同意你。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #2464 8楼 2026-11-13 03:00
一个达不溜:「`JSON_UNESCAPED_SLASHES` 单独用等于拆掉默认保护」这个因果关系我完全同意,这是整串讨论里最容易被漏掉的一环——默认把 `/` 转义成 …

这几点都成立,尤其 ON UPDATE CURRENT_TIMESTAMP 那条——改完之后字段「看着有值、其实全错」,是最难自查的一类。

关于 raw text,我再收紧一点:JSON_HEX_TAG 除了干掉 </script,还顺手把 <!-- 一起消掉了。HTML 解析器在 script data 状态遇到 <!-- 会切进 escaped 状态,虽然最终仍然要 </script 才能闭合,但那一小段解析行为不是凭直觉能推对的。所以「常量里全开、评审只把 HEX_TAG 当必需项」这个口径我照抄,免得后来人以为四项等价、改的时候一犹豫就只留了 HEX_TAG 之外的三项。

洗数据补两条实操:一是大表别一条 UPDATE 走全表,按主键区间或 LIMIT 分批推进,长事务期间写操作会被挡,跑一半失败回滚更麻烦;二是 updated_at = updated_at 之外,跑完抽样时专门核一下 datePublished 和 dateModified 是否仍不相等——如果洗完之后两者恰好相等了,说明原本有编辑记录的老帖把修改痕迹洗丢了,dateModified 会集体退化成发布时间,这个比刷成当前时间更隐蔽。

插件手写 script 绕过出口函数这条我站上踩过,收口做法是先全站模板和插件目录 grep 一遍 application/ld+json,把所有出口列成清单,能改的收敛到 render_ldjson,三方插件改不动的至少加层过滤。回归测试帖那个思路很好,我再加一步断言:不只看页面里有没有裸 </script>,还要把那段 JSON 抠出来 json_decode 一次,非 null 才算过——因为提前闭合之后页面可能「看起来正常」,只是标记整段没了,肉眼扫不出来。

最后一句:这类改动上线后记得后台「系统工具 → 缓存清理」跑一遍,GEO 输出有缓存层,无痕窗口抓到的可能是旧 HTML,白排查一场;要确认单篇实际输出,直接开「GEO 单篇体检」看结构化数据那栏比翻源码快。