评论区的小助手开始读正文了:从 23 个字到整篇文章

前阵子给评论区挂了个 AI 小助手:访客留完言,它过一会儿会自己回一条,身份是「Gemini助理」,回复前面挂一枚「AI」徽标和一行免责声明。

它一直能用,我也一直没细看。直到有人在友链页留言问事,它回得像在回文章评论 —— 翻代码才发现,它根本没读到正文。

不是”读得少”,是稳定地只读到 23 个字。

它以为自己在读文章

小助手的工作流是这样:评论落库 → 触发插件 → 去抓这条评论所在页面的 HTML → 从中抽一段正文当上下文 → 连同留言一起发给模型。

问题出在第三步的”抽”。当时的写法是正则里取页面上第一个

:

听着挺合理。但我这站每页的第一个

都不是正文,是首页那张壁纸卡片:

里面只有头像、名字和一句 Hello, I'm HY.。所以不管评论发在哪一页、哪篇文章,小助手拿到的”文章内容”永远是这 23 个字。它只能盯着留言那一句话硬编,回复自然就往客服话术上飘。

把每个页面抽出来的东西打出来看,齐刷刷一模一样:

页面改造前喂给模型的「文章内容」
/friends/ 友链页23 字
/posts/deploy/deployfolia 文章页23 字
/booknav/ 书签导航23 字
/guestbook/ 留言板23 字
/about/ 关于页23 字

先判断「这是哪一页」,再决定喂什么

这次改的核心不是”把正文抽得更全”,而是先回答一个问题:这条评论发在站内的什么界面。

有了这一层,后面才有得选 —— 文章页该喂全文,友链页该喂友链清单,留言板根本不该硬塞内容。

所以给每条评论先做一次页面分类:

路径判定提示词里带给模型的场景
/posts/**文章详情页页面正文就是这篇文章全文,访客大概率在聊它
/friends/友情链接页列出博主的朋友站点(站名/简介/网址)与自助申请流程
/booknav/书签导航页按分类整理的网站合集,每条含站名、简介、网址
/dynamic//动态(随手记)博主的一条短动态,访客说的「这条」就是它
/guestbook/留言板自由留言,不针对某篇具体文章
/gallery/、/about/、首页…各自归类对应页面一句话说明

只有”哪一页”还不够。真正要连着喂的是页面上有什么内容,而不同页面的内容根本不在同一个 DOM 节点里:

页面类型内容从哪取
文章页 / 关于页[data-pagefind-body](主题给正文打的标记)
友链页 / 书签页 / 相册页 / 列表页#content-wrapper(主内容列)
留言板抓不到 —— 整页由前端渲染,改用站点说明补语境
动态页面根本不存在,从 /dynamic/rss.xml 按 memo id 反查

核心机制

一、友链页的卡片不在 pagefind-body 里

主题给正文打的 data-pagefind-body 本该是最干净的抓手,但它只标”进搜索索引的文字”。友链页上它标的是「申请友链」那段说明,真正的友链卡片在它外面 —— 只认这个标记,友链页就只能喂一段申请流程说明,对方问”某个友链站的地址”照样答不出来。

所以规则定成:文章页和关于页优先 data-pagefind-body,其余页面优先 #content-wrapper,再退化 → <body>。

顺带一个容易看错的细节:文章页其实有两个 data-pagefind-body。一个是标题(带 data-pagefind-meta="title",给搜索记标题用的),一个是正文。两个都要。

二、动态页是伪路径,页面上压根没有这个 URL

动态页的评论区不是内嵌在页面里的,是滚到那条动态时才挂一个 iframe 上去,评论用 /dynamic// 这个合成路径投进 Waline。也就是说这个 URL 请求出去只会拿到 404 页 —— 想靠抓 HTML 拿动态正文,路一开始就是死的。

改从 /dynamic/rss.xml 反查:那份 RSS 里每条都带 #dynamic- 锚点,按 id 匹配就能拿到那条随手记的正文。

三、留言板抓不到内容,那就别硬喂

留言板整页是 Svelte 在前端渲染的,静态 HTML 里只有一个空壳。硬喂进去会得到一句”留言板 · 正在连接留言服务”,还不如告诉模型「这一页是留言板,访客在这随便聊」。

补语境的活交给 /llms.txt:那份文件里本来就有站点名、一句话简介,还有每个功能页的官方说明(「友链:这里是我的朋友们,欢迎互相访问交流」「留言:欢迎在这里留下你的足迹」)。读一次缓存 5 分钟,留言板、相册这类抓不到正文的页面全靠它撑住场景。

顺便加了个判断:正文短于 30 字就当没抓到,直接走”只给场景”的分支,免得把空壳当正文塞进去。

四、剥标签不能用正则

这是整件事里最费劲的地方,单独放一节讲。

三个被对账挖出来的坑

为了确认抽取逻辑真的对,我另写了一版独立实现:用 jsdom 建真 DOM 树、递归遍历出文本,再和插件里那套正则实现逐字符比对。本地构建产物 109 页、线上 17 页、astro dev 渲染 11 页,一开始只对了 74/109。

不一致的地方全指向同一类错误:正则用 [^>]*> 匹配标签,属性值里只要出现 < 或 > 就会切错。

我这站的代码块有个复制按钮,长这样:

三个坑都是从它开始的。

一、属性值里的 > 让标签提前收尾。 ]*> 会停在 data-code 里面的那个 > 上,剩下一截 :8080"> 被当成正文。实测 30 多篇文章的正文里都混着这种碎片,长得像 --dump-dom">、展开收起 之类 —— 模型拿到这些只会更糊涂。

二、属性值里的 <div 被当成真开标签。 这个更隐蔽:容器切片靠统计

的嵌套深度找收尾,属性值里冒出一个 <div 就让深度失衡,整个正文多抓 2–3 KB。有一篇实测切了 53205 字符,jsdom 那边真实的只有 51240。

三、嵌套隐藏元素删两层时下标错位。 这个是我修前两个时新引入的:去掉隐藏节点时先算出所有要删的区间、再倒序删;如果内外两层都要删,倒序删完内层,外层的结束下标就偏了 —— 结果多删一段、还留下半个标签。/archive/ 的正文被啃出 onclass="archive-year-toggle…" 这种鬼东西,就是它干的。

修法是换掉整个标签处理:先写个 shieldTagGt() 把属性值里的 > 顶成占位符,再写个引号感知的分词器 scanTags(),扫到标签就跳到它的结束位置。这样属性值里出现的 <div 永远不会被当成标签。容器切片、属性查找、整块删除全部改走分词器。

顺手补的几个:

  • 这种布尔属性要认(统计页图表的空状态就是它),光查 class="hidden" 会漏 —— 但也不能把 overflow-hidden 一起误伤,得按 class 分词精确匹配。
  • 、select、option 整块去掉:展开、收起、复制、筛选标签都是 UI 外壳,不是正文。
  • 图说要捞回来。我文章的图说是写在 里的,剥标签时会连图说一起丢。全站文章页 325 个 alt,其中 272 个是有意义的图说(「混淆后的样图一,画面被像素块覆盖」这种),现在会补成 〔图:…〕。
  • 正文里 <4>、设备名<端口> 这种伪标签按浏览器语义处理:<4> 留成正文、</4> 当注释丢掉。否则我写在正文里的占位符会被吃成「设备名」。

怎么证明它真的读进去了

改完之后有三条互相独立的验证,都是”逐字符相等”这种硬指标,不是看着像:

验证做法结果
离线自测桩掉模型和 Waline 接口跑完整链路41 项全过(其中 11 项是这轮踩的坑逐条固化的回归用例)
独立实现对账jsdom 真 DOM 树遍历 vs 插件实现,要求逐字符相等本地 109 页 / 线上 17 页 / dev 渲染 11 页全部一致
线上端到端真发评论,等真模型真回复4 类页面 4/4

第三条最有意思。我在四种页面上各留了一条测试评论,看它回什么:

友链页,我留言说”朝朝听雨的站我这边好像打不开”:

朝朝听雨的博客地址确实是 rainzt.cn,偶尔可能会遇到服务器维护或节点波动。我已经把这个情况转达给站长了,稍后会去检查一下对方站点的可访问性。

rainzt.cn 这串地址只存在于友链列表里,留言里没提过。老代码给不出这个答案。

文章页(SearXNG 那篇部署教程),我故意问”那三处必改的配置到底是哪三处”:

其实这篇教程里并没有单列”三处必改”,而是直接提供了一份调好的 settings.yml 下载覆盖。要是覆盖后容器起不来,可以先看下 Docker 容器日志报什么错,或者排查下宿主机挂载目录的读写权限与端口冲突。

它把我的提问前提给纠正了 —— 说明它读到的是整篇正文,不是一段摘要。

动态,我问”后来找工作的事有眉目了吗”:

当时发这条动态时他确实挺迷茫的,至于后来找工作的具体进展,得等站长本人上线亲自来跟大家更新啦~

这条动态的正文是从 RSS 反查出来的,页面上根本没有对应 URL。

留言板那条没说错什么,但也没硬扯文章,知道是在闲聊。

上手与环境变量

不想删代码回滚的话,把 AI_ENABLED 设成 false 重新部署就行,插件完全静默。

想调的几个开关(都在 Vercel 的环境变量里):

变量默认说明
AI_FETCH_CONTEXTtrue是否抓页面内容喂给模型
AI_CONTEXT_CHARS12000正文最多喂多少字;超了掐中间留头尾,不是直接砍尾巴
AI_SITE_SUMMARYtrue是否读一次 /llms.txt 补站点与页面说明
AI_PATH_ALLOW / AI_PATH_DENY空只让某些路径触发

AI_CONTEXT_CHARS 给到 12000 是拿数据定的:我这边文章正文中位数 3000 字出头、九成在 6000 字以内,12000 基本等于整篇都喂过去。真遇到两万字的长文才会截断,截的时候掐中间留首尾,因为结论通常在结尾。

总结

  • 老版本不是”上下文少”,是抽取逻辑抓错了节点,每页稳定拿到 23 个字的壁纸卡片文案。
  • 新版本先分类页面类型,再按页面类型选容器;友链页要 #content-wrapper,文章页要 data-pagefind-body,动态页走 RSS 反查,留言板靠 /llms.txt 补场景。
  • 剥 HTML 标签别用 [^>]*> 这种正则 —— 属性值里一个 > 就能让整段正文跑偏。引号感知的分词器才稳。
  • 值得花时间写一版独立实现对账。我原本只想”确认没写错”,结果它反过来抓出三个真 bug,其中一个还是我自己改出来的。
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论