Meta 审计的正确做法是用半角当量宽度而非字符数判断截断(中文占 2、英文占 1,title 上限约 60、description 约 155),脚本负责提取和量化,模型负责识别模板化占位与关键词堆砌并直接给出改写候选。缺失应阻断构建,质量问题只报告。
先说结论
上一篇《死链与断链审计 Skill》查的是通不通——链接指向的页面存在还是 404,答案非黑即白。
这一篇查的是好不好——title 是不是太长、description 是不是套话、H1 和 title 是不是重复。这类问题没有唯一正确答案,所以反而更需要把判定标准写死:
能量化的写成阈值表,不能量化的给模型判断依据,而不是让它自由发挥。
另外有两件事,本篇会花力气讲清楚,因为它们是大多数 Meta 检查工具在中文站上直接失效的原因:
- 用字符数判断截断是错的,中英文混排必须用宽度当量
- Meta 问题不该阻断构建——这和上一篇的结论正好相反,理由在最后一节
一、先划清边界:哪些事已经有人做了
如果你的站是 Astro / Next.js 这类带内容层校验的框架,有一部分检查已经在构建时做掉了,别重复实现。
以本站为例,astro check 会对照 src/content.config.ts 里的 schema 校验每篇文章的 frontmatter:description 缺失会直接报错,pillar 填了枚举外的值构建都过不去。
所以边界是:
框架的 schema 校验管「字段存在且类型对」,Meta 审计 Skill 管「字段写得好不好」。
| 检查项 | 归谁 |
|---|---|
description 字段是否存在 |
框架 schema 校验 |
description 渲染后会不会被截断 |
本 Skill |
title 是不是字符串 |
框架 schema 校验 |
title 跟另外 3 篇是不是撞了 |
本 Skill |
| 日期能不能解析 | 框架 schema 校验 |
| H1 和 title 是不是完全一样 | 本 Skill |
还有个更根本的原因:schema 校验看的是源码,Meta 审计必须看渲染产物。很多站的 <title> 是模板拼出来的({文章标题} - {站名}),源码里的 frontmatter 只是其中一段,只有渲染完才知道最终多长、会不会撞。
二、Meta 三件套各自影响什么
别把它们当成一类东西,三者的作用机制完全不同:
| 元素 | 是否排名因素 | 主要作用 | 写砸的后果 |
|---|---|---|---|
<title> |
✅ 是,且是最强的页面级信号之一 | 告诉搜索引擎这页是什么 + 决定 SERP 点击率 | 排名和点击双输 |
<meta description> |
❌ 不是 | 只影响点击率 | 搜索引擎自行从正文截一段,通常更难看 |
<h1> |
🟡 弱信号 | 页面内容结构 + 无障碍 | 结构混乱,辅助技术难以导航 |
两个常见误解值得纠正:
description 不是排名因素。 这一点 Google 官方说过多次。但它决定点击率,而点击率会间接影响表现——所以仍然值得认真写,只是别指望靠堆关键词提排名。
description 写不好,搜索引擎会自己动手。 它会从正文里截一段它认为更相关的。这不一定更差,但你失去了控制权。实测下来,写得好的 description 会被采用,写成套话的基本都会被替换掉——「本文介绍了 XXX 的相关内容」这种句式,等于没写。
还有一层新的:AI 搜索引用你的内容时,title 往往就是那条引用的标签。写得含糊,在 AI 答案里的辨识度就低。
三、要查的 13 类问题
| # | 类型 | 说明 | 严重度 |
|---|---|---|---|
| 1 | title 缺失 | 极少见但致命 | 🔴 高 |
| 2 | title 跨页重复 | 多页共用同一 title,搜索引擎无法区分 | 🔴 高 |
| 3 | title 超长 | 在 SERP 被截断,尾部信息丢失 | 🟡 中 |
| 4 | title 过短 | 信息量不足,浪费展示位 | 🟢 低 |
| 5 | title 关键词堆砌 | 同一词重复 3 次以上 | 🟡 中 |
| 6 | description 缺失 | 交给搜索引擎自行截取 | 🟡 中 |
| 7 | description 跨页重复 | 常见于模板站 | 🟡 中 |
| 8 | description 超长 | 被截断 | 🟢 低 |
| 9 | description 模板化占位 | 「本文介绍了…」这类套话 | 🟡 中 |
| 10 | H1 缺失 | 结构信号缺失 | 🟡 中 |
| 11 | H1 多个 | 结构混乱 | 🟢 低 |
| 12 | 标题层级跳级 | H1 直接跳到 H3 | 🟢 低 |
| 13 | og:title / og:description 与主 meta 不一致 | 社交分享显示错乱 | 🟢 低 |
其中 2、7、9 这三条是只有跨页面对比才能发现的——单看任何一页都挑不出毛病。这也是为什么 Meta 审计必须全站扫,不能一页页看。
四、中文站最大的坑:用字符数判断截断是错的
「title 不要超过 60 个字符」——这条规则你一定见过。它对中文站是错的。
Google 的搜索结果不是按字符数截断的,是按像素宽度。一个汉字的渲染宽度约等于两个英文字母。所以:
| 内容 | 字符数 | 实际占用 | 60 字符规则的判定 | 真实结果 |
|---|---|---|---|---|
| 60 个英文字母 | 60 | ≈ 60 单位 | 通过 | 刚好 |
| 60 个汉字 | 60 | ≈ 120 单位 | 通过 | 被腰斩 |
| 35 个汉字 | 35 | ≈ 70 单位 | 通过 | 已超 |
按字符数检查的工具,在中文站上会漏掉几乎所有真正的超长问题。
用半角当量代替字符数
不必真去算像素——像素依赖字体和字号,且 Google 从未公布确切值,改版还会变。更稳的做法是用半角当量宽度:全角字符(汉字、全角标点、假名)算 2,其余算 1。
// 半角当量宽度:East Asian Width 属性为 W / F 的字符算 2
export function displayWidth(str = '') {
let w = 0;
for (const ch of str) {
const c = ch.codePointAt(0);
const wide =
c >= 0x1100 && (
c <= 0x115f || // 韩文字母
(c >= 0x2e80 && c <= 0xa4cf && c !== 0x303f) || // CJK 部首、假名、汉字
(c >= 0xac00 && c <= 0xd7a3) || // 韩文音节
(c >= 0xf900 && c <= 0xfaff) || // CJK 兼容汉字
(c >= 0xfe30 && c <= 0xfe6f) || // 全角标点
(c >= 0xff00 && c <= 0xff60) || // 全角字符
(c >= 0xffe0 && c <= 0xffe6)
);
w += wide ? 2 : 1;
}
return w;
}
这样英文站的经验阈值可以直接沿用,中文自动折半:
| 元素 | 理想区间(半角当量) | 换算成纯中文 | 换算成纯英文 |
|---|---|---|---|
| title | 30 – 60 | 15 – 30 字 | 30 – 60 字母 |
| description | 110 – 155 | 55 – 78 字 | 110 – 155 字母 |
⚠️ 这些阈值是社区长期测量的经验值,不是官方规范,会随 Google 改版波动。所以要写进配置文件,别硬编码进脚本——改阈值不该需要改代码。
五、Skill 设计:只讲和①的差异
三层架构(说明书 + 确定性脚本 + 参考资料)和第一篇完全一样,不重复。差异在分工比例:
死链审计里模型的活相对少——状态码是客观的,模型主要处理误报。Meta 审计不一样,模型承担的判断多得多:
| 任务 | 归属 | 说明 |
|---|---|---|
| 提取 title / description / H1 / og | 脚本 | 纯解析 |
| 计算宽度、比对阈值 | 脚本 | 纯计算 |
| 精确重复检测 | 脚本 | 哈希分组 |
| 判断是否模板化套话 | 模型 | 需要语义理解 |
| 判断是否关键词堆砌 | 模型 | 「重复 3 次」是启发式,得看是否自然 |
| 判断两条相似 title 是否真该区分 | 模型 | 分页、系列文章可能本就该相似 |
| 给出改写候选 | 模型 | 这是本 Skill 最大价值 |
最后一条要特别强调:
纯脚本方案只能告诉你「超长 18 个当量」,模型能直接告诉你删哪几个字。
一份只说「这里有问题」的报告,和一份「这里有问题,建议改成 XXX」的报告,被真正执行的概率差好几倍。这也是把 Meta 审计做成 Skill、而不是写个纯脚本的核心理由。
六、说明书:判定规则与改写依据
六块骨架沿用①,这里只给内容变化最大的两块。
判定规则表
## 判定规则
阈值读取 references/thresholds.json,不要硬编码。
| 检查项 | 判定 | 级别 |
|---|---|---|
| title 缺失或为空 | 阻断 | 🔴 |
| title 宽度 > 60 当量 | 会被截断,给改写建议 | 🟡 |
| title 宽度 < 30 当量 | 信息量不足,提示可补充 | 🟢 |
| title 与其他页面完全相同 | 阻断,必须区分 | 🔴 |
| title 去掉品牌后缀后与其他页相同 | 疑似重复,人工确认 | 🟡 |
| 同一词在 title 中出现 ≥ 3 次 | 疑似堆砌,由模型确认是否自然 | 🟡 |
| description 缺失 | 提示,不阻断 | 🟡 |
| description 宽度 > 155 当量 | 会被截断 | 🟢 |
| description 宽度 < 70 当量 | 可能被搜索引擎替换 | 🟢 |
| description 与其他页面完全相同 | 需区分 | 🟡 |
| H1 数量 ≠ 1 | 结构问题 | 🟡 |
| H1 与 title 完全相同 | 浪费一次表达机会,建议差异化 | 🟢 |
| 标题层级跳级(如 H1→H3) | 结构问题 | 🟢 |
注意「去掉品牌后缀后再比」这条。大量站点的 title 模板是 文章名 - 站名,直接做全串比对永远发现不了重复——必须先剥掉共同后缀。这是实测中最容易漏的一类。
改写依据(这块是①没有的)
不能只让模型「看着改」,要给标准:
## 改写依据
改写 title 时:
- 保留最重要的关键词,且尽量靠前
- 删修饰词、语气词,不删信息词
- 品牌后缀可省略(搜索引擎常会自行补上)
- 改完必须重新计算宽度,确认落在 30–60 当量区间
- **不改变原意**——宁可略超长,也不要为了压字数改错意思
改写 description 时:
- 必须包含页面的核心结论或独特信息,不写"本文介绍了…"
- 前 70 个当量要能独立成句(可能被截断)
- 一个页面一个说法,不复用其他页面的句式
- 不堆关键词,通顺优先
每条只给 1–2 个候选,并说明改动理由。给一堆候选等于没给。
最后一句是经验教训:让模型给 5 个候选,人还得挑,等于把工作推回去了。给 1 个最优 + 1 个备选,是最容易被直接采纳的形式。
七、脚本:提取与量化
复用第一篇的遍历逻辑,换掉提取部分:
// scripts/extract-meta.mjs
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { displayWidth } from './display-width.mjs';
const DIST = process.argv[2] ?? 'dist';
const TH = JSON.parse(await readFile('references/thresholds.json', 'utf8'));
async function walk(dir) {
const out = [];
for (const e of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...await walk(p));
else if (e.name.endsWith('.html')) out.push(p);
}
return out;
}
const pick = (html, re) => (html.match(re)?.[1] ?? '').trim();
const decode = (s) => s
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
const pages = [];
for (const file of await walk(DIST)) {
const html = await readFile(file, 'utf8');
const url = '/' + relative(DIST, file).replace(/index\.html$/, '').replace(/\\/g, '/');
const title = decode(pick(html, /<title[^>]*>([\s\S]*?)<\/title>/i));
const desc = decode(pick(html, /<meta\s+name="description"\s+content="([^"]*)"/i));
const ogTitle = decode(pick(html, /<meta\s+property="og:title"\s+content="([^"]*)"/i));
const ogDesc = decode(pick(html, /<meta\s+property="og:description"\s+content="([^"]*)"/i));
// 标题层级:收集所有 hN 用于查跳级和多 H1
const heads = [...html.matchAll(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi)]
.map((m) => ({ level: +m[1], text: decode(m[2].replace(/<[^>]+>/g, '').trim()) }));
const h1s = heads.filter((h) => h.level === 1);
const issues = [];
if (!title) issues.push({ type: 'title-missing', level: 'blocker' });
else {
const w = displayWidth(title);
if (w > TH.title.max) issues.push({ type: 'title-too-long', width: w, limit: TH.title.max });
if (w < TH.title.min) issues.push({ type: 'title-too-short', width: w, limit: TH.title.min });
}
if (!desc) issues.push({ type: 'description-missing' });
else {
const w = displayWidth(desc);
if (w > TH.description.max) issues.push({ type: 'description-too-long', width: w });
if (w < TH.description.min) issues.push({ type: 'description-too-short', width: w });
}
if (h1s.length === 0) issues.push({ type: 'h1-missing' });
if (h1s.length > 1) issues.push({ type: 'h1-multiple', count: h1s.length });
if (h1s[0] && h1s[0].text === title) issues.push({ type: 'h1-equals-title' });
if (ogTitle && title && ogTitle !== title) issues.push({ type: 'og-title-mismatch' });
if (ogDesc && desc && ogDesc !== desc) issues.push({ type: 'og-desc-mismatch' });
// 层级跳级检测
for (let i = 1; i < heads.length; i++) {
if (heads[i].level - heads[i - 1].level > 1) {
issues.push({ type: 'heading-skip', from: heads[i - 1].level, to: heads[i].level });
break;
}
}
pages.push({ url, title, desc, h1: h1s[0]?.text ?? null, issues });
}
// 跨页重复:剥掉共同品牌后缀后再比
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // 品牌名可能含正则字符
const brandRe = new RegExp(`\\s*[-||—]\\s*${esc(TH.brand)}\\s*$`);
const stripBrand = (t) => t.replace(brandRe, '').trim();
const group = (list, keyFn) => {
const m = new Map();
for (const p of list) {
const k = keyFn(p);
if (!k) continue;
if (!m.has(k)) m.set(k, []);
m.get(k).push(p.url);
}
return [...m].filter(([, urls]) => urls.length > 1);
};
const dupTitle = group(pages, (p) => stripBrand(p.title));
const dupDesc = group(pages, (p) => p.desc);
await mkdir('.audit', { recursive: true });
await writeFile('.audit/meta.json',
JSON.stringify({ pages, dupTitle, dupDesc }, null, 2));
const blockers = pages.filter((p) => p.issues.some((i) => i.level === 'blocker')).length
+ dupTitle.length;
console.log(`扫描 ${pages.length} 页:阻断级 ${blockers},重复 title 组 ${dupTitle.length},重复 description 组 ${dupDesc.length}`);
process.exit(blockers ? 1 : 0);
配套的阈值文件:
// references/thresholds.json
{
"brand": "BLKTECH",
"title": { "min": 30, "max": 60 },
"description": { "min": 70, "max": 155 }
}
阈值和品牌名都在这里改,脚本不动。 换个站接手时,只改这个 JSON。
八、报告长什么样
脚本吐 JSON,模型按说明书转成带改写候选的报告:
# Meta 审计报告
扫描 47 页 / 阻断级 1 / 建议修复 6 / 提示 9
## 🔴 阻断级
### title 跨页重复(2 页)
| 页面 | 当前 title |
|---|---|
| /guides/codex-basic-usage/ | Codex 使用教程 |
| /guides/codex-real-project/ | Codex 使用教程 |
**建议**:两篇定位不同,应分别体现
- `/guides/codex-basic-usage/` → Codex 基础使用:常用命令与审批模式(宽度 44)
- `/guides/codex-real-project/` → Codex 实战:在真实项目里跑通一次任务(宽度 48)
## 🟡 建议修复
### /guides/hyperframes-sandwich-template-architecture/
| 项 | 现状 | 问题 |
|---|---|---|
| title | HyperFrames 模板化进阶:用片头—内容—片尾架构批量生产视频 | 宽度 74,超出 14 |
**建议**:HyperFrames 模板化:片头—内容—片尾批量生产视频(宽度 52)
**理由**:删「进阶」「架构」两个修饰词,核心信息与关键词全部保留
### /guides/domain-dns-basics/
| 项 | 现状 | 问题 |
|---|---|---|
| description | 本文介绍了域名和 DNS 的相关基础知识。 | 模板化套话,宽度 38 偏短 |
**建议**:域名解析到网站的完整链路:A 记录、CNAME、TTL 与常见解析失败排查。(宽度 76)
**理由**:换成具体覆盖点,读者能判断这篇是否解决他的问题
对比一下同样问题在普通工具里的输出——title too long: 74 chars。差别就是改与不改。
九、修复策略
| 类型 | 动作 | 注意 |
|---|---|---|
| title 缺失 | 立即补 | 通常是模板 bug,不是内容问题 |
| title 重复 | 找差异化角度重写 | 别加「(一)(二)」序号糊弄 |
| title 超长 | 删修饰词,保信息词 | 关键词尽量靠前 |
| title 堆砌 | 只保留一次关键词 | 通顺优先于密度 |
| description 缺失 | 补写,或确认让搜索引擎自取 | 列表页可以不写 |
| description 套话 | 换成本页独有的具体信息 | 「本文介绍了」直接列为禁用开头 |
| H1 多个 | 只留一个,其余降级 H2 | 常见于组件里误用 h1 |
| H1 与 title 相同 | H1 可以更口语、更长 | 两者面向的读者不同 |
| og 不一致 | 统一到主 meta | 除非社交端刻意要不同文案 |
单独说重复 title 的修法:
重复 title 不是「加编号」能解决的问题,是「这两页凭什么分开存在」的问题。
如果两个页面实在想不出差异化的 title,往往说明它们本来就该合并成一页,或者其中一个该 noindex。Meta 审计经常顺带暴露内容规划的问题——这是它的额外价值。
十、接 CI:和上一篇相反的结论
上一篇的结论是内链 404 必须阻断构建。这一篇的结论正好反过来:
Meta 的质量问题不该阻断构建,只有缺失才阻断。
理由很实际:
| 死链(①) | Meta 质量(②) | |
|---|---|---|
| 问题性质 | 正确性——客观错误 | 质量——程度问题 |
| 有无标准答案 | 有,页面存在或不存在 | 没有,阈值是经验值 |
| 阻断的后果 | 逼你修好,合理 | 逼你为了过 CI 写垃圾文案 |
想象一下:description 差 3 个当量就超限,构建失败。你会怎么办?大概率是随手删掉三个字,而不是认真重写。这时 CI 不但没提升质量,还制造了新的垃圾。
所以分级:
# .github/workflows/meta-audit.yml
name: meta-audit
on: [push]
jobs:
meta:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install && bun run build
# 只有 title 缺失 / 跨页重复会让退出码非零
- run: bun scripts/extract-meta.mjs dist
- uses: actions/upload-artifact@v4
if: always()
with: { name: meta-audit, path: .audit/meta.json }
阻断线只画在两处:title 缺失和 title 跨页重复。这两条是客观错误,没有「见仁见智」的空间。其余全部走报告,人来判断。
更一般的原则:
CI 该阻断的是「客观错误」,不是「主观质量」。把质量指标硬编码成红线,得到的是应付红线的产物。
十一、系列导航
「SEO Skills 工具箱」系列:
- 死链与断链审计
- 当前:Meta 审计
- 结构化数据与 JSON-LD 校验
- Sitemap 与实际路由的差异审计、孤儿页发现
- 内链结构审计:权重分布、内容孤岛与锚文本
- 内置 seo:audit 与 Skill 封装
- 配套资源:说明书、规则注册表与检查清单
到这里,系列的两条核心原则都出来了:
- 确定性交给脚本,判断交给模型(第一篇)
- 能量化的写成阈值表,不能量化的给判断依据;CI 只拦客观错误(本篇)
相关阅读:
- Astro 内容站搭建指南——内容模型怎么设计,直接影响 Meta 的生成质量
- 展示型独立站进阶优化——搜索意图与页面结构的对应关系
RELATED / 相关推荐
接着读这些
按同一栏目、标签与技术栈为你挑选。
AI Skills 高阶:把 SEO 检查内置进项目,做成一条命令 + 一个 Skill
把分散的五类 SEO 检查合并成项目内置的 seo:audit 脚本,用统一的规则注册表管理 24 项检查与 error/warn 分级,再封装成 Skill,让检测、解析和修复方案一次给全。
AI Skills 实战:把网站死链与断链审计做成一个可复用 Skill
把死链检查的判定规则和输出格式固化成一份 Skill 说明书,配合两个确定性脚本,让 AI Agent 每次都按同一套 SOP 产出可执行的审计报告,并接入 CI 持续拦截。
AI Skills 实战:结构化数据校验 Skill——语法对不代表标记对
JSON-LD 校验分语法、类型、一致性三层,校验器只覆盖第一层。用本地规则表查必填字段与类型选择,让模型比对标记内容与页面可见内容,堵住真正会丢富媒体资格的问题。