1. 什么是 ponytail 插件,我为什么要折腾它
如果你经常需要“把一篇文章快速提炼成要点”,或者“把网页正文干净地抓下来丢给 AI 整理”,你多半经历过同样的一连串麻烦:先手动复制,再去掉广告、推荐位、评论区,最后还要应付各种乱七八糟的格式。时间长了你就会发现,真正有信息量的内容可能只有三分之一,剩下全是噪音。
我做的这个小工具叫 ponytail,定位就是一个“网页内容整理插件 + 技能包”。它的名字很直白——马尾辫。散落的头发收拢成一条干净利落的马尾,就像把网页里散乱的各种噪音收拢成一段清爽的正文。不管你是普通用户、内容创作者,还是经常折腾个人 AI 工作流的人,只要日常需要大量阅读网页并提炼信息,这个工具都能帮你省下不少重复劳动。
我把它拆成了两层:一层是可安装的浏览器插件,负责在页面里抓正文、清理杂质;另一层是 skill,也就是技能包,负责把清理完的内容进一步“上脑”——比如调用大模型做摘要、生成日报、翻译成本地语言。两个东西搭配使用,才能发挥最大价值。如果你只需要其中某一个部分,也可以单独拿着用,互不干扰。
这篇文章我会从设计思路、安装配置、核心逻辑、踩坑记录一直讲到后续扩展方向,全部基于我自己的实际使用经验。里面涉及代码的部分我会尽量贴出可运行的片段,不是那种只给个伪代码就把你打发的教程。你甚至可以照着抄一段就立刻跑起来。
2. 整体设计与核心思路拆解
2.1 为什么要把“抓取”和“理解”拆成两层
动手之前我参考过不少“一键摘要”类工具,发现它们的通病是:把页面抓取、文本提取、模型调用全部揉在一个脚本里。看着省事,可一旦哪一层出了问题,排查起来特别痛苦。比如页面改版了,你可能要跑到很深的代码里去改 CSS 选择器;想换一个模型,又要动编辑器逻辑。两个不相干的事被绑在一起,非常不利于维护。
所以我干脆把流程拆成两个独立模块:
- 插件层(collect):只负责“把乱七八糟的网页变成干净的正文”。它不关心你后面用谁来总结、总结成什么样。
- 技能层(skill):只负责“把干净的正文交给模型处理”。它不关心正文是从哪个网站、哪种页面结构里来的。
这样做的好处很直接:页面抓取变差了,我就只修插件层;模型换成新的版本或者想换摘要风格,我就只调 skill 层。两边互不牵连,出问题的范围一下就缩小了。而且抓下来的 Markdown 纯文本可以直接粘贴到任何笔记软件、聊天窗口或者本地 AI 工具里,哪怕后面不想用模型,也完全不浪费。
2.2 为什么选浏览器插件而不是后端爬虫
最早的原型我其实是用 Python 写的爬虫,直接 HTTP 请求页面再解析 HTML。听起来技术感很强,但实际用起来有致命伤:很多页面是动态渲染的,正文内容靠 JavaScript 加载,你直接抓到的只是一堆空壳和初始化代码。登录态、个性化推荐也需要处理,光 cookie 和会话维护这一块就足以让人头大。
浏览器插件没有任何这个问题。它跑在真实浏览器环境里,DOM 已经渲染完毕,登录态天然就有,浏览器自己也处理了编码和跳转。插件的 content script 可以拿到你当前看到的那个页面内容,所见即所得,这是任何后端爬虫都不可能替代的优势。
代价是插件不能用 Node.js,也没法直接跑 Python 的 BeautifulSoup。但好在浏览器的 DOM API 已经足够强大,配合一些简单的文档密度算法,实现一个轻量版正文提取器并不难。
2.3 核心组件的技术选型逻辑
整个项目实际上只有三块核心,我做的技术选型也尽量遵循“能不引入依赖就不引入”的原则:
第一个是正文提取算法。我没有用商业方案,也没有引那种动辄几百 KB 的第三方库,而是自己实现了一个简化版 Readability:先遍历页面主要区域的段落容器,计算候选节点的文本密度,再把分低的和明显是导航、广告的节点剔除。大概两百行 JavaScript,效果已经能覆盖绝大多数资讯类、博客类页面。
第二个是文本转换。从 DOM 里取出来的是 HTML,要变成对 AI 友好的纯文本或 Markdown。这一块不需要复杂库,核心就是递归遍历节点,遇到标题、段落、列表、引用就按规则加对应的 Markdown 符号。注意不要直接拿 innerHTML 去正则替换,因为嵌套标签和转义会让你的输出变成灾难。
第三个是模型调用。技能层我用的是对命令行和脚本都比较友好的方式,直接通过 HTTP 接口把清理好的文本送给模型服务,返回结果再落回本地文件。接口地址、模型名、token 上限全部做成配置项。这样今天你用一个模型,明天换另一个模型,改配置就行,代码不用碰。
3. 插件安装与核心实战
3.1 准备工作:先把环境弄干净
安装 ponytail 之前,建议先把浏览器里的其他“阅读模式”“广告拦截”类扩展暂时停用。我踩过一次大坑:开了几个广告拦截插件之后,正文区域有大量元素被动态替换,导致我的正文提取器总是抓到半截文章。排查了一个小时才发现是插件冲突。
其次,如果你要在 skill 层调大模型接口,注意确认网络环境和接口配置。这一步属于常规准备工作。日常使用不需要额外配置账号等复杂内容,插件本身的安装和加载流程非常简单。
3.2 浏览器插件安装的三种方式
Chrome 和 Edge 都支持“开发者模式加载已解压的扩展程序”。具体路径是:地址栏输入chrome://extensions或edge://extensions,打开右上角“开发者模式”开关,然后选择“加载已解压的扩展程序”,指向项目里plugin/目录即可。
如果你偏好从命令行安装,可以用:
# 先把插件目录压缩成 zip zip -r ponytail-plugin.zip plugin/ # 在支持的命令行浏览器环境下,通过策略安装或手动导入 # 这一步不同系统差别较大,实际使用还是建议直接拖拽加载还有一种更简单的用法:在浏览器地址栏直接打开chrome://extensions后,把plugin/文件夹拖进窗口,也会自动触发安装。整个过程五秒钟。
安装完成后,地址栏旁边会出现一个小图标。点开之后你会看到两个主要功能区:一个负责“立即整理当前页面”,另一个负责“整理后交给 skill 继续处理”。如果当前页面的正文提取成功,它会直接预览在弹窗里,方便你随时检查抓取质量。
3.3 核心配置项说明
我的理念是“默认配置能跑,进阶配置保手”。首次安装不需要任何配置就能用,因为插件内置的默认值适用于大多数资讯页面。下面是几个比较关键的配置项,它们都在插件的设置面板里可以改。
| 配置项 | 默认值 | 作用说明 |
|---|---|---|
max_length | 3000 | 整理后的正文最长字符数,超过则从段落末尾截断,防止给模型塞太多无用内容 |
keep_links | false | 是否保留 Markdown 链接。总结场景建议关闭,原文存档场景建议打开 |
selector | 留空 | 手动指定正文区域选择器,比如article.main。留空时自动检测 |
output_format | markdown | 输出格式,支持markdown、plaintext |
model_url | 留空 | skill 层调用的模型接口地址 |
model_name | 留空 | 模型名称,用于请求体携带参数 |
temperature | 0.3 | 模型生成温度,摘要任务越低越好,防止自由发挥 |
手动指定selector是一个容易被忽略但是非常实用的功能。某些网站结构特殊,自动检测算法找不到正文,你只要打开开发者工具,找到正文对应的 CSS 路径,然后在配置里填上这个选择器,ponytail 就会优先使用你指定的区域,不再盲猜。
3.4 skill 层怎么接入你的个人流程
skill 层本质上是一个可以被命令行或自动化流程调用的模块。最简单的用法是把它注册成一个本地的可执行命令:
export PONYTAIL_URL="https://example.com/article" export PONYTAIL_MODEL="qwen-plus" python pony_tail_skill.py --input "$PONYTAIL_URL" --output summary.md运行之后,它会先调用浏览器插件把网页整理成 Markdown 文件,然后拿着这个文件请求你配置的模型接口,最后把摘要写入summary.md。
如果你用的是带函数调用的模型,也可以把 skill 封装成一个函数。调用时模型会自己决定“这个请求需要用到 ponytail”,然后传入网页地址,拿到整理结果后再基于它回答用户问题。这一套非常适合接进个人知识库、文档助手或者简单的阅读代理里。
4. 实操过程与关键代码逻辑
4.1 页面主内容的提取逻辑
前面说了,我采用了一个简化版 Readability 算法。核心思路是先找到页面里“文字密度”最高的容器,再围绕这个容器向上向下合并区块。下面是一段可以直接放进 content script 用的简化实现:
function getMainContent(root = document) { const candidates = root.querySelectorAll( 'article, [role="main"], .post-content, .entry-content, .article-content' ); let best = null; let bestScore = -Infinity; candidates.forEach((node) => { const text = node.innerText || ''; const length = text.trim().length; // 基本密度分:文本越长分数越高,同时惩罚带有大量链接的节点 const linkDensity = node.querySelectorAll('a').length / Math.max(node.querySelectorAll('p, div').length, 1); const score = length - linkDensity * length * 0.2; if (score > bestScore) { bestScore = score; best = node; } }); return best || root.body; }这段代码的思路很简单:优先从语义化标签里找候选区,然后用“纯文字长度减掉链接噪音”作为打分标准。实际用起来你会发现,博客、新闻、帮助文档这类页面基本都能被准确命中,速度也很快。
拿到最佳候选节点之后,还要做一层清理:把藏在内部的script、style、noscript、svg和广告相关节点直接移除。注意移除前先存一份原始 HTML,万一操作失误还能还原。
4.2 HTML 转 Markdown 的细节处理
HTML 转 Markdown 是很容易被低估的一步。很多人用正则一替换就完事,结果列表全变成一团乱麻。稳定起见,我建议用递归遍历的方式,逐个节点判断类型再拼接文本,下面是核心片段:
function nodeToMarkdown(node) { if (node.nodeType === Node.TEXT_NODE) return node.textContent; const tag = node.tagName?.toLowerCase(); let text = ''; node.childNodes.forEach((child) => { text += nodeToMarkdown(child); }); if (tag === 'h1') return `\n# ${text.trim()}\n`; if (tag === 'h2') return `\n## ${text.trim()}\n`; if (tag === 'h3') return `\n### ${text.trim()}\n`; if (tag === 'p') return `${text.trim()}\n\n`; if (tag === 'li') return `- ${text.trim()}\n`; if (tag === 'blockquote') return `> ${text.trim()}\n`; if (tag === 'a') { const href = node.getAttribute('href') || ''; return keepLinksEnabled ? `[${text.trim()}](${href})` : text.trim(); } return text; }这里你必须留意两点。第一,标题下面我记得加空行,不然渲染的时候会粘连成一行;第二,列表嵌套的情况要比我想象中多,如果遇到二级无序列表,最好根据父级ul的层级自动缩进两格。这个可以在递归参数里加一个 depth,然后生成前缀空格。
清理完的文本我会统一做一次空行合并,把连续三个以上的换行压缩成两个。如果输出是纯文本,还要顺带把 Markdown 符号全部剥掉,只留内容本身。
4.3 调用模型生成摘要的参数选择
技能层调用模型时,很多人上来就问“温度调多少?max_tokens 调多少?”其实这取决于你的输入长度和需要的摘要精度。
我一般先统计整理后正文的字符数,再按这个数据推算输出长度:
content_len = len(markdown_content) # 目标摘要长度 = 输入长度的 30% 左右,取整百 target_tokens = max(200, int(content_len * 0.3 / 100) * 100) # 再留点余量给 prompt 和返回格式 max_tokens = min(8000, target_tokens + 200)温度建议直接按 0.3 固定。摘要任务本质是压缩信息,不是创作。如果调高温度,模型会忍不住“润色”内容,一不小心就把原文里没有的意思加进去,这在事实核查场景里是致命的。如果你要做的是内容改写而不是摘要,才考虑把温度调到 0.7 左右。
请求体注意携带stream: false,实现更简单。响应返回 JSON 之后,直接解析 content 字段落盘。如果你用的是新模型,记得确认一下它的上下文窗口能不能装下你整篇正文,装不下就先在插件层把max_length调小。
4.4 输出与导出:怎么接笔记软件和日报流程
我不想让 ponytail 只停留在控制台输出,所以多写了一个导出模块。它支持三种输出目标:
- 本地 Markdown 文件:最通用,适合丢进 Obsidian、Notion 或者其他笔记工具。
- 剪贴板:适合快速粘贴到聊天窗口或文档里,省去文件管理。
- 追加写入指定日志:适合做“每日阅读摘要”流程。每天固定把当天整理过的内容追加到同一个文件里,晚上直接拿出来归档。
导出模块的配置同样放在同一个配置文件里,切换目标只需要改一个参数。我个人最常用的组合是“清理后导出到剪贴板”,因为很多时候我并不会立刻让模型参与,而是先把原文存下来,等有空再统一处理。
5. 常见问题与排查技巧实录
5.1 为什么抓不到正文,或者抓到了侧边栏
这个问题我修过的次数最多,原因基本逃不开下面几个。
第一,页面是动态加载的,正文区域在滚动之后才渲染出来。我的解决办法是在开始提取前,先自动滚动页面到底部再回顶部,强制触发懒加载。这个动作在提取逻辑里加一个await new Promise(r => setTimeout(r, 800))就行。
第二,网站是单页应用(SPA),路由切换过程中 DOM 会大换血。插件需要监听history.pushState事件,在路由变化后重新触发提取,否则你只会拿到上个页面的残留内容。
第三,正文标题和正文内容分离两个容器。这点容易漏,很多页面把标题放在header里,正文在section里,如果你只取一个候选节点,标题就丢了。我后来在提取结束之后会额外补一次:在当前页面里查找h1,如果标题文本没有包含在正文里,就手动拼到正文最前面。
5.2 中文内容乱码或输出字数对不上
现代浏览器基本上不会真的乱码,除非页面本身用的是<meta charset="gb2312">这种老编码。不过插件拿到的是渲染后的 DOM,编码已经被浏览器解码过了,所以插件层通常没有乱码问题。乱码更容易出现在你“手动复制到某些老终端工具”这一步。
字数对不上主要有两个原因:一个是文本节点里包含大量空白字符和换行,统计时没过滤干净;另一个是遇到 emoji 和特殊符号,JavaScript 的length按 UTF-16 码元计算,一个 emoji 可能是两个码元。统一处理办法是统计前做text.replace(/\s+/g, ' ').replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_'),先把不可见字符清理掉再算长度。
5.3 模型接口报错或返回超时
技能层调模型接口时最常见的报错是超时和 JSON 解析失败。超时多半不是网络本身的问题,而是你输入文本太长,模型处理时间超过了接口默认的 60 秒阈值。解决办法有两个:把max_length调小,或者在插件配置里把超时时间从 60 秒提到 180 秒。
JSON 解析失败通常是因为模型返回的内容里夹带了 Markdown 代码块标记。有的模型会习惯性在摘要外面包一层 ```json,导致你直接json.loads失败。解析前先做一次清理:
import re text = re.sub(r"^```json\s*|\s*```$", "", response_text.strip()) data = json.loads(text)这个坑非常隐蔽,因为大多数时候不会报错,只有在你换了某个特定模型之后才频繁出现。如果你不想依赖这个清理逻辑,也可以在 prompt 里明确强调“不要输出 Markdown 代码块标记,直接输出纯 JSON”,能显著降低概率。
5.4 浏览器更新或版本差异导致插件失效
Chrome 更新之后插件偶尔会失效,尤其是每次大版本升级,Manifest V3 的一些 API 行为会有细微调整。最典型的例子是scripting.executeScript的权限要求比 Manifest V2 严格得多,如果你把 content script 改成动态注入的方式,必须确认host_permissions里包含了目标站点。
我建议每次浏览器提示升级时,先不要急着更新 ponytail。等插件失效了你再升级,或者升级后第一时间遍历一遍核心页面测试“整理当前页面”功能。浏览器升级带来的兼容性问题,90% 和扩展权限声明有关,重新检查manifest.json基本就能定位到问题。
另外,同一个插件在 Chrome 和 Edge 上虽然可以共用,但如果你开了严格隐私模式,第三方 cookie 和存储权限会不一样,可能导致你的配置项在其中一个浏览器上“消失了”。解决方法是把配置文件放在插件自身的chrome.storage.local里,而不是依赖页面级的 localStorage。
5.5 把 ponytail 接进 AI 工作流时的注意事项
很多人拿到工具第一反应是“我要拿它做一个全自动阅读机器人”,我劝你不要一开始就全自动。建议先手动跑通三个场景:单个网址整理、单页摘要、批量文件导出。
在这三个场景都稳定之后,再接进自动化流程。因为自动化的难点不在于调用,而在于异常处理:网页结构变了怎么办、模型接口限流怎么办、输出文件冲突了怎么办。每个问题都要有一套预案,否则自动化跑起来十分钟就断,你还要半夜爬起来看日志。
另一个容易被忽视的点是 token 成本。跑批量摘要时不控制输入长度,费用会非常夸张。我的经验是批量任务里把正文裁剪到 1000 到 1500 字,摘要长度控制在 200 字以内,信息保全率已经能到八成。为了多保那两成信息去付出三四倍的 token 开销,不划算。
6. 一些让我印象深刻的小技巧和后续扩展想法
6.1 先说几个常规文档里不会写的细节
第一个技巧是“摘要质量不好,先别急着换模型,先看正文清理干不干净”。我遇到过很多次模型发挥不稳定,排查到最后发现是正文里混了一堆推荐位文字,模型被带偏了。把清理阈值调严之后,同样的模型、同样的 prompt,效果立刻变好。数据干净比模型强重要得多。
第二个技巧是“正文提取时优先选短路径选择器”。如果自动提取总是有问题,你手动指定的选择器也不要写太长。选择器越长,页面改版的概率就越大。比如#main .article-content就比body > div#wrapper > main > section > div.article-content稳定得多。能用类名解决就不要用层级路径。
第三个技巧是“让 skill 层默认附带原文标题和来源链接”。不要只把正文丢给模型,而是在开头拼上来源:{url}和标题:{title}。这样模型在总结时会自动带上来源意识,不会凭空默认所有信息都来自同一个人。对于做信息聚合的场景,这个细节能帮你省很多后面核对的功夫。
6.2 这个工具还能往哪些方向继续折腾
目前我计划里排在第一位的扩展是给它加一个“整站配置记忆”能力。大概思路是这样:每当你手动指定过某个网站的正文选择器,插件就把这个配置记下来,下次访问同一个域名时自动应用。跑一段时间之后,你常用网站的正确选择器会被逐渐积累出来,以后基本就不需要手动干预了,这个思路在信息流整理场景里特别实用。
第二个方向是接入本地模型。跟远程接口相比,本地模型的好处是隐私和零成本,缺点是速度和效果参差不齐。你完全可以保留现在这套远程接口,只是把model_url从云端地址改成本地地址,剩下的逻辑几乎不用动。接口协议只要保持 OpenAI 风格,兼容性就能拉到最高。
第三个方向是多页面合并整理。现在的逻辑是“每页一条摘要”,但实际场景里你经常需要搜集某个话题的多篇文章,然后合并成一篇综述。我想要的是:先批量整理二十个相关页面,然后把二十份正文拼成一个临时文档,再一次性让模型输出一篇综述。这个功能非常适合做行业调研和竞品分析,省下来的时间非常可观。
坦白说,当初做 ponytail 只是被“手动复制粘贴网页内容”这件事烦得够呛。做到现在反而发现,真正让效率起飞的不是哪一段特别神奇的代码,而是你愿意为“整理与理解”这条链路多花一点心思。如果你也在被类似的问题困扰,完全可以依照我上面写的那几个核心逻辑,从最简陋的版本开始搭起。工具这东西,自己动手改过一遍,用起来才真正顺手。