
HY
折腾进行时
Hello, I'm HY.








前阵子给评论区挂了个 AI 小助手:访客留完言,它过一会儿会自己回一条,身份是「Gemini助理」,回复前面挂一枚「AI」徽标和一行免责声明。
它一直能用,我也一直没细看。直到有人在友链页留言问事,它回得像在回文章评论 —— 翻代码才发现,它根本没读到正文。
不是”读得少”,是稳定地只读到 23 个字。
小助手的工作流是这样:评论落库 → 触发插件 → 去抓这条评论所在页面的 HTML → 从中抽一段正文当上下文 → 连同留言一起发给模型。
问题出在第三步的”抽”。当时的写法是正则里取页面上第一个 <article>:
1const articleMatch = source.match(/<article[^>]*>([\s\S]*?)<\/article>/i);2if (articleMatch) body = articleMatch[1];听着挺合理。但我这站每页的第一个 <article> 都不是正文,是首页那张壁纸卡片:
1<article id="home-wallpaper-card" class="home-wallpaper-card">…</article>里面只有头像、名字和一句 Hello, I'm HY.。所以不管评论发在哪一页、哪篇文章,小助手拿到的”文章内容”永远是这 23 个字。它只能盯着留言那一句话硬编,回复自然就往客服话术上飘。
把每个页面抽出来的东西打出来看,齐刷刷一模一样:
| 页面 | 改造前喂给模型的「文章内容」 |
|---|---|
/friends/ 友链页 | 23 字 |
/posts/deploy/deployfolia 文章页 | 23 字 |
/booknav/ 书签导航 | 23 字 |
/guestbook/ 留言板 | 23 字 |
/about/ 关于页 | 23 字 |
这次改的核心不是”把正文抽得更全”,而是先回答一个问题:这条评论发在站内的什么界面。
有了这一层,后面才有得选 —— 文章页该喂全文,友链页该喂友链清单,留言板根本不该硬塞内容。
所以给每条评论先做一次页面分类:
| 路径 | 判定 | 提示词里带给模型的场景 |
|---|---|---|
/posts/** | 文章详情页 | 页面正文就是这篇文章全文,访客大概率在聊它 |
/friends/ | 友情链接页 | 列出博主的朋友站点(站名/简介/网址)与自助申请流程 |
/booknav/ | 书签导航页 | 按分类整理的网站合集,每条含站名、简介、网址 |
/dynamic/<memo id>/ | 动态(随手记) | 博主的一条短动态,访客说的「这条」就是它 |
/guestbook/ | 留言板 | 自由留言,不针对某篇具体文章 |
/gallery/、/about/、首页… | 各自归类 | 对应页面一句话说明 |
只有”哪一页”还不够。真正要连着喂的是页面上有什么内容,而不同页面的内容根本不在同一个 DOM 节点里:
| 页面类型 | 内容从哪取 |
|---|---|
| 文章页 / 关于页 | [data-pagefind-body](主题给正文打的标记) |
| 友链页 / 书签页 / 相册页 / 列表页 | #content-wrapper(主内容列) |
| 留言板 | 抓不到 —— 整页由前端渲染,改用站点说明补语境 |
| 动态 | 页面根本不存在,从 /dynamic/rss.xml 按 memo id 反查 |
pagefind-body 里#主题给正文打的 data-pagefind-body 本该是最干净的抓手,但它只标”进搜索索引的文字”。友链页上它标的是「申请友链」那段说明,真正的友链卡片在它外面 —— 只认这个标记,友链页就只能喂一段申请流程说明,对方问”某个友链站的地址”照样答不出来。
所以规则定成:文章页和关于页优先 data-pagefind-body,其余页面优先 #content-wrapper,再退化 <main> → <body>。
顺带一个容易看错的细节:文章页其实有两个 data-pagefind-body。一个是标题(带 data-pagefind-meta="title",给搜索记标题用的),一个是正文。两个都要。
动态页的评论区不是内嵌在页面里的,是滚到那条动态时才挂一个 iframe 上去,评论用 /dynamic/<memo id>/ 这个合成路径投进 Waline。也就是说这个 URL 请求出去只会拿到 404 页 —— 想靠抓 HTML 拿动态正文,路一开始就是死的。
改从 /dynamic/rss.xml 反查:那份 RSS 里每条都带 #dynamic-<memo id> 锚点,按 id 匹配就能拿到那条随手记的正文。
留言板整页是 Svelte 在前端渲染的,静态 HTML 里只有一个空壳。硬喂进去会得到一句”留言板 · 正在连接留言服务”,还不如告诉模型「这一页是留言板,访客在这随便聊」。
补语境的活交给 /llms.txt:那份文件里本来就有站点名、一句话简介,还有每个功能页的官方说明(「友链:这里是我的朋友们,欢迎互相访问交流」「留言:欢迎在这里留下你的足迹」)。读一次缓存 5 分钟,留言板、相册这类抓不到正文的页面全靠它撑住场景。
顺便加了个判断:正文短于 30 字就当没抓到,直接走”只给场景”的分支,免得把空壳当正文塞进去。
这是整件事里最费劲的地方,单独放一节讲。
为了确认抽取逻辑真的对,我另写了一版独立实现:用 jsdom 建真 DOM 树、递归遍历出文本,再和插件里那套正则实现逐字符比对。本地构建产物 109 页、线上 17 页、astro dev 渲染 11 页,一开始只对了 74/109。
不一致的地方全指向同一类错误:正则用 [^>]*> 匹配标签,属性值里只要出现 < 或 > 就会切错。
我这站的代码块有个复制按钮,长这样:
1<button title="Copy to clipboard" data-code="http://<你的NAS-IP>:8080">三个坑都是从它开始的。
一、属性值里的 > 让标签提前收尾。 <button\b[^>]*> 会停在 data-code 里面的那个 > 上,剩下一截 :8080"> 被当成正文。实测 30 多篇文章的正文里都混着这种碎片,长得像 --dump-dom">、展开收起 之类 —— 模型拿到这些只会更糊涂。
二、属性值里的 <div 被当成真开标签。 这个更隐蔽:容器切片靠统计 <div> 的嵌套深度找收尾,属性值里冒出一个 <div 就让深度失衡,整个正文多抓 2–3 KB。有一篇实测切了 53205 字符,jsdom 那边真实的只有 51240。
三、嵌套隐藏元素删两层时下标错位。 这个是我修前两个时新引入的:去掉隐藏节点时先算出所有要删的区间、再倒序删;如果内外两层都要删,倒序删完内层,外层的结束下标就偏了 —— 结果多删一段、还留下半个标签。/archive/ 的正文被啃出 onclass="archive-year-toggle…" 这种鬼东西,就是它干的。
修法是换掉整个标签处理:先写个 shieldTagGt() 把属性值里的 > 顶成占位符,再写个引号感知的分词器 scanTags(),扫到标签就跳到它的结束位置。这样属性值里出现的 <div 永远不会被当成标签。容器切片、属性查找、整块删除全部改走分词器。
顺手补的几个:
<div hidden> 这种布尔属性要认(统计页图表的空状态就是它),光查 class="hidden" 会漏 —— 但也不能把 overflow-hidden 一起误伤,得按 class 分词精确匹配。<button>、select、option 整块去掉:展开、收起、复制、筛选标签都是 UI 外壳,不是正文。<img alt> 里的,剥标签时会连图说一起丢。全站文章页 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_CONTEXT | true | 是否抓页面内容喂给模型 |
AI_CONTEXT_CHARS | 12000 | 正文最多喂多少字;超了掐中间留头尾,不是直接砍尾巴 |
AI_SITE_SUMMARY | true | 是否读一次 /llms.txt 补站点与页面说明 |
AI_PATH_ALLOW / AI_PATH_DENY | 空 | 只让某些路径触发 |
AI_CONTEXT_CHARS 给到 12000 是拿数据定的:我这边文章正文中位数 3000 字出头、九成在 6000 字以内,12000 基本等于整篇都喂过去。真遇到两万字的长文才会截断,截的时候掐中间留首尾,因为结论通常在结尾。
#content-wrapper,文章页要 data-pagefind-body,动态页走 RSS 反查,留言板靠 /llms.txt 补场景。[^>]*> 这种正则 —— 属性值里一个 > 就能让整段正文跑偏。引号感知的分词器才稳。如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!
部分内容可能已过时
分享你的想法,与大家交流讨论