无尘阁日记

无尘阁日记

GEO 改造全记录:让 17542 篇文章被生成式引擎读懂、引用、转述
六个层次的完整拆解:通道、结构、内容、实体、规模、持续
silcano

本文要点一次真实的 GEO 施工复盘,把整条链路切成六个互不重叠的层次:通道、结构、内容、实体、规模、持续。每一层写清问题现象、解决办法、踩过的坑和验证方式,附可直接照抄的检查清单与最终覆盖数据。

这篇文章不是「GEO 是什么」的科普,而是一次真实施工的复盘。2026 年 10 月 28 日,无尘阁日记(jiangtian.top)做了一轮生成式引擎优化改造,涉及 17542 篇已发布内容、1 个独立页面、6 个静态子站。下面把整条脉络摊开,每一层都写清楚三件事:问题原本长什么样、用什么办法解决的、踩了哪些坑。

先把一件事说明白:GEO 不改变文章写得好不好,它只改变一件事——当搜索引擎和生成式引擎来读你的页面时,它们能不能准确判断「这一页是什么、谁写的、什么时候写的、在讲哪个东西、哪句话可以直接引用」。做不到这一点,内容再好也只是躺在自己的域名里。

一、先把问题切成六个互不重叠的层次

做改造最容易犯的错是「哪里看着不顺眼就改哪里」,改完说不清还剩什么没做。所以第一步不是动手,是把问题切开。

这里用的切分原则是:一个页面从被服务器响应,到被生成式引擎复述出来,必须依次通过哪几道关口。这个切法有三个好处:关口之间是前后依赖关系,天然不重叠;任何一道没过,后面全部白搭;六道合起来就是完整链路,不会遗漏。

  • 通道层——引擎能不能拿到这个页面,拿到的是不是唯一的一个地址
  • 结构层——拿到之后,机器能不能识别页面层级和页面里的实体
  • 内容层——识别之后,能不能直接摘出一句可以引用的话
  • 实体层——摘出来的话,能不能挂到知识图谱的某个节点上
  • 规模层——上面四层在 17542 篇上是不是普遍成立
  • 持续层——以后新发一篇、换一次主题,这些是不是还在

以下逐层展开。凡是有数字的地方,都是实测值,不是估算。

二、通道层:让引擎拿得到,而且只拿到一个地址

问题长什么样

改造前的体检结果:robots.txt 存在但很含糊;sitemap.xml 有 17537 条 URL;没有 llms.txt;更要命的是同一个页面存在两个地址——/tags-339.html 和 /?tags-339.html 都返回 200,内容一模一样。分类页、首页、独立页面还全都没有 canonical,只有文章页有(主题自带的)。

这件事对 GEO 的伤害是直接的:生成式引擎在建立引用时,会把两个地址当成两份材料,权重被摊薄,甚至互相冲突。这不是玄学,是索引层面的重复。

做了什么

  • 补 llms.txt:用一段结构化说明交代站点是谁、写什么、主要栏目在哪、哪些页面是内容入口。这个文件的读者不是人,是模型。
  • 补 robots.txt 的显式放行与 sitemap 指向。
  • 在 nginx 伪静态层加 301:凡是「带问号的伪静态形式」一律跳转到干净路径。
  • 全站补 canonical:首页、分类、标签、归档、分页、独立页面全部加上;文章页不加,因为主题已经输出了一条,加第二条等于制造新的歧义。

踩到的坑:不能一律拼成 /$args

第一版规则写得很顺手:

if ($args ~ "^(tags-\d+\.html|post-\d+\.html)$") { return 301 https://$host/$args; }

结果 /?post-16385.html 被跳到了 /post-16385.html,而那个地址是 404。因为这套站的各类真实路径并不统一:

类型 真实路径 容易写错成
文章/post/16385.html/post-16385.html(404)
标签/tags-339.html/tag-339.html(404)
分类/category-3.html—
分页/page_2.html(下划线)/page-2.html(404)
日期归档/date-2023-4.html—
作者/author-1.html—

所以规则必须按类型分开写:文章单独映射到 /post/$1.html,其余才用 /$args。这个教训值得记下来——凡是「统一处理」的规则,先验证每一种类型的真实形态,再写正则。

怎么验证的

验证分两步,缺一不可:一是每个 301 的终点必须跟到底返回 200(不能只看有没有跳转);二是正常请求不能被误伤——/?q=notion 这种搜索参数、/post/16385.html 这种正常页面,必须保持 200。

最终六种问号形态全部 301 到 200 的终点,正常请求零误伤。

三、结构层:让机器看懂这一页是什么

问题长什么样

改造前抓一个文章页的 DOM 出来看,问题很清楚:

  • 全文没有 h1。主题把文章标题写成了 <h2 class="title">,而作者在各个小节里手写了 h1。结果页面主标题彻底错位,机器读到的「最重要的东西」是某个小节的标题。
  • 没有任何结构化数据。没有 JSON-LD,没有 og,没有 twitter card。
  • 列表页(首页、分类、标签、归档)一个 h1 都没有,页面标题只存在于 <title> 和面包屑里。
  • 独立页面更彻底:只有一个光秃秃的 h1,别的什么都没有。

做了什么

核心手段是写一个 Z-Blog 插件,挂 Filter_Plugin_Template_Compiling_Begin 钩子,在主题模板编译期改模板源码。这样不改主题文件本身——主题升级不会冲掉改动,换主题则自动失效,边界干净。

  • 标题层级:单篇模板里 <h2 class="title"> 换成 <h1 class="title">。判据是模板里有没有 {$article.Content}——只有单篇模板输出正文,用这个判据能精确排除列表模板。
  • 日期与作者:原来的日期是纯文本 2026-10-28,改成 <time datetime="ISO8601">,并补上作者别名。
  • JSON-LD:输出一个 @graph,里面按页面类型组合 WebSite、Person、BlogPosting、WebPage、CollectionPage、BreadcrumbList。
  • og / twitter:按类型给 title、description、url、type,文章另给发布时间、修改时间、作者、所属分类。
  • 列表页 h1:注入页面级标题,形如「技术笔记 · 共 357 篇」。
  • 独立页面:单独处理成 WebPage(用 name 而不是 headline,因为 headline 只对 CreativeWork 系有意义),og:type 用 website。

踩到的三个坑

坑一:把日期挪出 h1,正文被裁成三行。

第一版为了语义干净,把 <div class="date"> 移到了 </h1> 外面。结果文章正文只剩开头三行,后面全被隐藏。查了很久才发现是主题里一条 CSS 选择器在起作用:

#content article:not(:has(.title .date)) .text { -webkit-line-clamp:3; overflow:hidden }

主题靠「标题里有没有日期」来区分列表摘要和正文全文。日期一挪,正文就被当成了列表摘要裁掉。所以正确做法是:只换标签名,日期必须留在 h1 内部。这也意味着这个非标准的「div 套在 h1 里」结构必须原样保留——它不是脏代码,是功能依赖。

坑二:用 <header> 做页面级标题,结果整个标题消失。

列表页注入的标题一开始用了语义更合适的 <header>,页面上完全看不到。因为主题把 header 这个标签名征用了:

header { position: fixed; width: 260px }
header:nth-of-type(n+2) { display: none }

它拿 <header> 当固定侧栏用,第二个及以后的 header 直接不显示。改成普通 <div class="gb-page-head"> 就好了。教训:改别人的模板,先看它把哪些标签名挪作他用。

坑三:裸替换让分类页出现了十个 h1。

第一版直接 str_replace('<h2 class="title">', '<h1 class="title">'),结果分类页每条摘要的标题都变成了 h1,一页十个 h1,比没有 h1 更糟。加上「只在单篇模板生效」的判据之后才正确。

怎么验证的

每个页面类型都要过一遍断言:h1 数量必须恰好 1;日期必须在 h1 内部;JSON-LD 必须存在且类型符合预期;og 必须存在。抽样了 20 篇到 30 篇不等,多轮回归,异常数为 0。

四、内容层:让机器能直接摘出一句话

问题长什么样

这一层的问题最隐蔽,因为页面上看不出毛病。

  • 摘要全部在第 150 字硬切。17542 篇的 description 长度精确卡在 151(150 字 + 一个省略号)。中文句子常常断在半句中间,机器摘出来就是半句话。
  • CSS 源码漏进了列表摘要。主题生成摘要的函数对 HTML 只做去标签处理,正文里内联的 <style> 内容原样变成了文字,首页和分类页的摘要里能看到 font-size:16px 这种东西。
  • &nbsp; 实体没还原。它以六个字符的长度混进摘要,既占篇幅又难读,还让某篇的 description 虚高到 167 字。
  • 部分正文开头残留 undefined。21 篇里,有的是小红书格式器漏出的 CSS 类名,有的是编辑器漏出的裸文本。
  • 标题之下没有一句话交代「这篇给了什么」。读者和机器都得自己从正文里扒。

做了什么

  • 摘要按句读截断,不再按字数硬切。规则是三级:优先收在句末标点(且长度不少于上限的 45%);退一步收在逗号级停顿;实在没有标点才硬切。长度永远不超过上限。
  • 清洗顺序调整:先删 <style>、<script>、HTML 注释,再去标签。顺序反了就会漏。
  • 实体还原:解码 HTML 实体、把不换行空格换成普通空格,输出时统一重新转义(所以不会带出注入风险)。
  • 导读段:在正文顶部加一个高亮框。手工写的标「本文要点」,机器从正文开头抽的标「导读」——名实必须相符,抽出来的确实是开头而不是要点,就不能叫要点。
  • 双标题:主标题之下加一行检索意图副标题,同时作为 alternativeHeadline 写进结构化数据。

踩到的两个坑

坑一:摘要和 og 各说各话。

页面上唯一那条 <meta name="description"> 是主题模板生成的,而我最初改的是插件里的 og 分支。结果同一页的 meta 用的是「正文前 150 字」,og 用的是手写要点,两个字段互相矛盾。修法是让两者同源,优先级统一为:手写要点 → 主题自带的自定义描述 → 正文截断。修完抽 26 篇,不一致数为 0。

坑二:副标题的「标题拆分」规则是错的。

一开始设计了三条副标题来源,其中一条是「标题里有冒号,就取后半段」。看起来合理,实际跑出来是这样的:标题《北京「相亲特种兵」:一年相亲 100 次,仍然遇不到爱情》,副标题生成「一年相亲 100 次,仍然遇不到爱情」——这段文字本来就在标题里,等于什么都没加。这条规则是系统性地产生零信息量,不是偶发,所以整条删掉,只保留「从正文里取第一个有信息量的小节标题」这一条。

同时给副标题加了严格的黑名单和判重:归一化之后(去实体、去标点、去空白)如果和标题重复、被标题包含、或者和导读第一句重合,一律丢弃;「小结」「实施注意事项」「结论」这类标题级别的废话也丢弃。宁可不出,也不出一堆废话。

怎么验证的

抽样 400 篇统计摘要结尾形态:句末收尾 381 篇(95.2%),硬切 19 篇(4.8%,都是正文本身没有标点的代码或列表类内容),平均长度从 151 降到 130。注意这里是长度变短了,不是变差了——从「断在半句的 151 字」变成「完整的 130 字」,信息密度反而更高。

五、实体层:让内容挂到知识图谱的节点上

问题长什么样

体检出来的两个数字很说明问题:

  • 93.4% 的文章一个标签都没有(16383 篇 / 17542 篇)。
  • 分类页和标签页的 JSON-LD 只有 WebSite 和 Person,没有任何「这是一组围绕某个主题的内容集合」的声明,og 标签也是一个都没有。

这两条合起来意味着:站内的实体聚合页,在机器眼里跟首页没有区别。

做了什么

  • 分类页、标签页补 CollectionPage 和 BreadcrumbList,并把 about 指向同名实体;补完整的 og / twitter。
  • 文章页的标签渲染成 rel="tag" 链接,成为实体页的入口。
  • 给无标签文章自动打标。

自动打标:三次调参,每次都推翻上一次

这件事看着简单,实际做了四轮,前三轮的结果都不能用。记录下来比结论更有价值。

轮次 标签池 判据 覆盖率 为什么不能用
一1782 个(全部)正文命中 ≥ 2 次99.5%「自己」命中 351/800 篇,纯噪声
二130 个(被 ≥5 篇用过)正文命中 ≥ 2~3 次80.5%「关系」命中「台海关系」,「家长」命中政治文
三130 个标题命中 或 正文 ≥ 5 次55.8%可用,但抽象词会造出一堆大杂烩页
四79 个(再排掉抽象词)标题命中 或 正文 ≥ 5 次25.3%采用

第四轮排掉的抽象词是:认知、成长、生活、关系、人生、自由、情绪、行动、坚持、表达、效率、心态、沟通、情感、中年、内耗、家长等等。理由很直接——这类词铺开之后,「认知」会从 183 篇涨到近千篇,「生活」涨到两千篇,变成没有指向性的聚合页。而人名、书名、地名、栏目这类真实体页小但准,才是生成式引擎认的那种「实体」。

还有一条路试过但放弃了:按分类学标签。统计发现 57 个分类里有 31 个一篇已打标文章都没有,认知突围 4951 篇里只有 2 篇带标签——先验根本学不出来。所以主判据只能是全站关键词匹配,分类只作为加分项(同分类下用过的标签 +3 分)。

最终结果:3447 篇补上标签,无标签比例从 93.4% 降到 73.7%。覆盖不完整是有意的——规则化打标的上限就在这,剩下 73.7% 里大部分是纯叙事文,本来就没有明确的实体可以挂。

六、规模层:让上面四层在 17542 篇上成立

问题长什么样

前四层每一层单独看都不难,难在乘以 17542。手工做是不可能完成的,必须批量化,而批量化会带来三个新问题:内存、失败隔离、数据安全。

踩到的坑

坑一:一次 select 全部正文,内存直接爆。

第一次写批量脚本,一句 select log_ID, log_Content from zbp_post 把 1.7 万篇正文全拉进内存,PHP 进程报 Allowed memory size of 134217728 bytes exhausted。改成按 log_ID > lastId 分批,每批 400 篇,处理完立刻 unset,问题消失。

坑二:写入报 Incorrect string value。

部分老正文里存在被截断的多字节字符(不是全角符号问题,是真正的非法 UTF-8 字节)。抽取摘要时这些字节被带进 meta 字段,写库直接失败:

Incorrect string value: '\xE9\xAD";s:...' for column 'log_Meta' at row 1

排查过程中还发现一个次生问题:PHP 的 preg_replace 带 /u 修饰符遇到非法 UTF-8 会返回 null,而不是抛异常,于是下游一路 null 下去,报出一堆看不懂的 deprecation。修法是所有文本处理前先过一遍 iconv('UTF-8', 'UTF-8//IGNORE', $s) 洗掉非法字节。

坑三:一篇失败拖死整批。

第一版没有异常处理,第 N 篇报错整个脚本就停了,前面写的没提交、后面的一篇没做。改成每篇单独 try / catch,失败只记录 ID,最后一起报告。最终全量 17494 篇,失败 0,耗时 1 分 42 秒。

批处理的固定套路

这套流程后来成了标准动作,任何涉及上万篇的改动都照这个走:

  • 默认干跑,要真写必须显式加 --apply。
  • 先小批量试(200~300 篇),看样本质量、看耗时,没问题再全量。
  • 写前备份:把每篇被改字段的原始值存成 JSON,文件名带时间戳。
  • 单篇失败隔离,不中断整批。
  • 一致性校验:改内容时,把新旧两版里待删除的词元都剥掉,剩下的必须完全相等,否则跳过该篇。这一条拦住了所有「顺手删多了」的风险。

最终覆盖数据

项目 覆盖 比例
已发布内容总数17542 篇—
有导读段 / 要点段17264 篇98.4%
有副标题4742 篇27.0%
有标签4606 篇26.3%
结构层(h1 / JSON-LD / og / canonical)全站,与篇数无关100%

最后一行要特别说明:结构层不需要逐篇处理。它是模板编译期改写的,17542 篇和 1 篇的工作量完全一样。真正需要逐篇的只有「每篇自己的那一句话」,也就是导读和副标题。

七、持续层:以后新发的内容是不是自动继承

怎么证明

这个问题不能靠推理,只能实测。做法是:临时建一篇测试文章,发布,抓页面验证,确认无误后删除。

验证结果:新发布的内容一次性自动获得了 h1(日期在 h1 内)、BlogPosting、BreadcrumbList(首页 → 分类 → 标题)、og:title / og:type / og:url、按句截断的 description、以及所属分类字段。不需要重建模板,不需要跑任何脚本。

唯一不会自动有的是「手写的要点段和副标题」——那是每篇独有的数据,机器无从得知。但缺了也不影响结构:没有要点段时,description 会退回按句截断的正文开头,导读框干脆不显示,页面依然完整。

代价与回滚

这次改造全程遵循两条约束:不改主题文件(改的是编译期模板源码,主题升级不会冲突)、不改正文观点(只清理明确的垃圾字符)。所有批量写入都有带时间戳的 JSON 备份,任何一步都可以单篇回滚。

顺带说一句:上面那篇用于验证的临时文章 ID 是 17601,验证完被删掉了。后来按原 ID 重建成了现在这篇——所以你现在读到的,就是那篇测试文章的第二次生命。ID 序列没有留空。

八、边界:这次没有做的事

一份诚实的复盘必须写清楚没做什么,否则「做完了」会变成一句空话。

  • 没有做外链建设。GEO 的有效性很大程度取决于外部引用,这属于站外工作,不在本次范围。
  • 没有追关键词排名。本次只解决「机器能不能读懂」,不解决「这个词该不该抢」。
  • 没有改 URL 结构。17542 篇的历史地址全部保持原样,只加 301 收敛重复形态。
  • 没有合并重复标签。「认知」和「认知突破」这类是不同颗粒度,合并的收益远小于风险。
  • 没有改正文观点和表达。只清理了 18 篇里编辑器漏出的 undefined,且只在确认为垃圾形态时才动——正文里作为代码出现的 null/undefined、出现在图片文件名里的 undefined,一律保留。
  • 没有给 73.7% 的无标签文章强行打标。规则打标的上限就在这里,硬凑只会制造噪声。

九、可以直接复用的检查清单

如果你也想给自己的站点做一轮,下面这份清单可以照着走。顺序不能乱,因为后一层依赖前一层。

  • 通道:robots.txt 是否有 sitemap 指向;sitemap.xml 是否可达且条数对得上;是否有 llms.txt;同一内容是否存在两个以上可访问地址;每个页面是否恰好一条 canonical。
  • 结构:每个页面 h1 是否恰好一个;标题里是否带日期(如果主题靠这个做判断);JSON-LD 是否存在且类型与页面类型匹配;og 是否齐全。
  • 内容:description 是否与 og:description 一致;摘要是否断在句子中间;摘要里是否有 &nbsp;、CSS 片段、undefined 之类的残留。
  • 实体:分类页/标签页是否有 CollectionPage;文章标签是否渲染成可点击的实体链接;无标签文章占比是多少。
  • 规模:批量脚本是否默认干跑;是否有单篇失败隔离;是否写前备份;是否有一致性校验。
  • 持续:新建一篇测试内容,走一遍完整检查,确认无误后删除。

最后一句:GEO 的收益不是即时的。它把「你的内容有没有资格被引用」从一件碰运气的事,变成一件结构上已经成立的事。剩下的,交给时间和内容本身。

— 无尘阁 · 红尘炼心,知行一体 —