GEO 改造全记录:让 17542 篇文章被生成式引擎读懂、引用、转述六个层次的完整拆解:通道、结构、内容、实体、规模、持续
这篇文章不是「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这种东西。 实体没还原。它以六个字符的长度混进摘要,既占篇幅又难读,还让某篇的 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 一致;摘要是否断在句子中间;摘要里是否有
、CSS 片段、undefined之类的残留。 - 实体:分类页/标签页是否有
CollectionPage;文章标签是否渲染成可点击的实体链接;无标签文章占比是多少。 - 规模:批量脚本是否默认干跑;是否有单篇失败隔离;是否写前备份;是否有一致性校验。
- 持续:新建一篇测试内容,走一遍完整检查,确认无误后删除。
最后一句:GEO 的收益不是即时的。它把「你的内容有没有资格被引用」从一件碰运气的事,变成一件结构上已经成立的事。剩下的,交给时间和内容本身。
— 无尘阁 · 红尘炼心,知行一体 —
发表评论: