跳到主要内容
BLKBLKTECH

指南 / ai

AI Skills 实战:Meta 审计 Skill——查 title、description 与 H1 的缺失、重复与超长

把 title、description、H1 的判定阈值和改写依据固化成 Skill 说明书,用半角当量宽度替代字符数解决中文截断误判,让模型直接给出改写候选而不只是报告超长。

作者 BLKTECH 编辑部更新 2026年8月1日18 分钟难度 进阶免费Node.jsBunAI AgentGitHub Actions

Meta 审计的正确做法是用半角当量宽度而非字符数判断截断(中文占 2、英文占 1,title 上限约 60、description 约 155),脚本负责提取和量化,模型负责识别模板化占位与关键词堆砌并直接给出改写候选。缺失应阻断构建,质量问题只报告。

先说结论

上一篇《死链与断链审计 Skill》查的是通不通——链接指向的页面存在还是 404,答案非黑即白。

这一篇查的是好不好——title 是不是太长、description 是不是套话、H1 和 title 是不是重复。这类问题没有唯一正确答案,所以反而更需要把判定标准写死:

能量化的写成阈值表,不能量化的给模型判断依据,而不是让它自由发挥。

另外有两件事,本篇会花力气讲清楚,因为它们是大多数 Meta 检查工具在中文站上直接失效的原因:

  1. 用字符数判断截断是错的,中英文混排必须用宽度当量
  2. 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(/&amp;/g, '&').replace(/&lt;/g, '<')
  .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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 工具箱」系列:

  1. 死链与断链审计
  2. 当前:Meta 审计
  3. 结构化数据与 JSON-LD 校验
  4. Sitemap 与实际路由的差异审计、孤儿页发现
  5. 内链结构审计:权重分布、内容孤岛与锚文本
  6. 内置 seo:audit 与 Skill 封装
  7. 配套资源:说明书、规则注册表与检查清单

到这里,系列的两条核心原则都出来了:

  • 确定性交给脚本,判断交给模型(第一篇)
  • 能量化的写成阈值表,不能量化的给判断依据;CI 只拦客观错误(本篇)

相关阅读:

NEXT ACTION / 下一步

回看系列第一篇:死链审计

把读到的方法变成一个小行动,完成后再回来迭代。

继续

RELATED / 相关推荐

接着读这些

按同一栏目、标签与技术栈为你挑选。