☰
caveman:一个将网页正文提取为Markdown的命令行工具
2026/10/29 13:22:58 网站建设 项目流程

搜索caveman这个词,能搜出来的图片大多是举着棒子、围着兽皮的史前人类。我最近手头的项目也叫caveman,但它和穴居人没什么关系:这是一个命令行工具,输入一个网页链接,输出干净的文章正文,格式是Markdown。导航、广告、弹窗、侧边栏推荐流全部剥掉,只留下你真正想读的那几段字。

做这个工具的原因很朴素。我每天要读十几篇网页资料,标签页开了一排,真正有用的文字往往只有一两屏。复制到笔记里,样式和无关字符跟着一起进来,整理比阅读还累。更烦的是有些页面为了让你多停留,把正文拆成好几页,或者塞进一段"推荐阅读"里。caveman这个名字就是想提醒自己:处理信息的方式可以回到最原始的状态——只把文字带回来,别的都不碰。整个项目大约400行Python,依赖三个库,一个晚上能跑通核心链路。如果你经常读网页做笔记、写博客需要参考资料、研究竞品页面,或者想把长文转成干净文本交给AI做摘要,这个工具应该能直接塞进你的工作流。

1. 项目定位:这个工具只做四件事,其他一概不管

1.1 一个"功能太少"的工具,为什么反而值得做

很多人问我,网上已经有Pocket、Jina Reader、各种"阅读模式"插件,为什么还要自己写一个命令行工具?我的回答是:那些工具解决的是"在线阅读"问题,我要解决的是"本地文本流"问题。

Pocket适合收藏,阅读模式适合在浏览器里看,但它们都不适合做下面这件事:

caveman https://example.com/long-article.md

把文章变成纯文本文件,落到我的笔记目录里,然后我可以对它做任意操作——grep、拼接、交给本地AI总结、丢进Pandoc转PDF。caveman不是一个"阅读器",它是一个"文本管道起点"。它输出的不是给人看的网页,而是给下一个程序用的原料。

工具的核心流程只有四步:

  • 抓取HTML:请求目标页面,拿到原始响应。
  • 解析DOM:把HTML变成可以查询的文档树。
  • 提取正文:从乱糟糟的标签里找到文章主体。
  • 转换输出:把正文HTML转成Markdown,写文件或打印到标准输出。

每一步都是commonsense,没有任何炫技成分。但正是这种"功能少到没有想象力"的设计,让它非常稳定、非常快,也几乎不会坏。

1.2 必须划清的三个边界

做工具最容易犯的错,是想把所有相关需求都塞进去。caveman在立项那一刻就明确了三个不做:

第一,不做需要登录的内容。付费墙、会员体系、需要登录才能看的论坛帖子,通通不在支持范围内。不是技术上完全做不到,而是做进去之后会产生一连串伦理和维护问题:会话过期怎么办、账号安全怎么保障、别人用了你的工具是否算越权访问。caveman只处理公开可访问的静态页面,这是底线。

第二,不做语义分析和总结。抓下来的正文是"原材料",不是"结论"。摘要、情感分析、关键词提取,这些应该交给下游的AI或脚本去做,而不是由caveman集成。集成越多,工具的可复用性就越差。你把摘要功能做进去,下次你想换一个模型,就得改这个工具。

第三,不做常驻服务和数据库。我不需要它跑在服务器上,也不需要它存储历史记录。它就是一条命令,用完即走。数据流经它,但不被它占据。这种"零状态"的设计让它在任何机器上都能跑,没有任何部署负担。

边界划清楚之后,所有功能决策都变得简单:对正文提取有帮助的,做;和正文提取无关的,不加。后面你会发现,这个判断标准帮我砍掉了一堆看起来很酷的需求。

1.3 真实使用场景与不适合它的场景

适合caveman的场景,我列几个实际在用的:

  • 把一篇几百KB的网页转成几十KB的Markdown,存进知识库。
  • 抓取多篇竞品公告,统一转换成文本后做差异对比。
  • 把网页长文转成纯文本,复制给AI对话窗口做上下文。
  • 配合定时任务,每天早上抓取指定页面的正文,生成简报。

不适合它的场景也需要心里有数:抓整个站点的所有文章,请用真正的爬虫框架;需要页面渲染后的截图,请用Playwright或Puppeteer;需要识别猫猫狗狗图片,请用视觉模型。caveman的定位就是:它只是一把瑞士军刀里的小刀片,你不要指望它当锤子用。

2. 技术选型:为什么是Python,以及我放弃无头浏览器的原因

2.1 三条实现路线的对比

动手之前,我先列了一下可能的实现方案。

第一个方案是Python加requests加BeautifulSoup加html2text。这是最经典的技术组合,每个环节都有无数人用过,资料多,行为可预期,依赖加起来不到100MB。开发时间大约两小时。

第二个方案是Node.js加Puppeteer。它能执行JavaScript,等页面渲染完再抓内容,对付单页应用很好用。但代价是它要下载一个Chromium,光这个就几百MB,而且启动一次要几秒钟。对大部分纯服务端渲染的文章页面来说,这是杀鸡用牛刀。

第三个方案是Go加goquery。性能和编译产物体积都很优秀,跑起来特别轻。不过Go操作DOM的体验比Python差一点,写起来啰嗦,遇到编码问题要手动处理的地方更多。

我把三个方案用一张表做了直观对比:

方案依赖体积JS渲染支持开发效率适用场景
Python + requests + bs4小不支持高大部分文章页面
Node + Puppeteer很大支持中重交互的SPA页面
Go + goquery小不支持中低对性能有极端要求的采集

最终我选了Python。理由不是Python全方面碾压,而是对这个项目的定位来说,Python正好是最优解:开发快,生态里每个环节都有成熟库,遇到问题随手能搜到答案。caveman追求的不是每秒抓取多少个页面,而是"简单、可维护、别人拿过去也能改"。

2.2 为什么先不做浏览器渲染

你可能觉得,现在很多网页都是React、Vue写的,不渲染JavaScript根本拿不到正文。这个担忧有道理,但在我的真实样本里,九成以上的"文章型页面"仍然是服务端渲染的——博客、新闻站、文档站、技术社区,打开查看源代码,正文就在HTML里躺着。

真正需要JS渲染的,是那些数据接口驱动的单页应用和内容藏在JS变量里的页面。遇到这类页面,我的第一反应不是加渲染引擎,而是去翻它的接口——很多前端展示的数据,后端会有一个JSON接口在默默提供,直接请求那个接口,数据比渲染后还干净。caveman不解析JS,不代表拿不到数据,只是换了一个入口。

还有一个现实考量:无头浏览器会把工具拖入一个不断升级的军备竞赛。Chromium每隔几周更新一次,自动更新机制在服务器上经常引发兼容问题;每个页面要等待网络、等待定时器、等待滚动事件,抓一个页面的耗时从几百毫秒变成几秒。这对"极简"的定位是一种破坏。

当然,我并不打算完全堵死这条路。caveman的入口函数设计成能接受"已经拿到的HTML字符串",这意味着将来如果需要,可以在前面加一层Playwright抓取,把渲染后的HTML喂给caveman,正文提取链路完全不用改。这是典型的"渐进增强"。第一版先不做,架构上留好接口。

2.3 依赖库的最小集合

caveman最终只用了四个库,都是Python生态里久经考验的角色:

  • requests:负责HTTP请求,它的重试机制和编码处理省了我很多事。
  • beautifulsoup4:负责解析HTML,提供的select方法几乎就是CSS选择器。
  • readability-lxml:负责正文提取,它移植了火狐阅读模式的算法,对大部分文章页面判断很准。
  • html2text:负责把HTML转Markdown,代码块、链接、列表处理都很成熟。

至于chardet,我一开始没打算加,但后来在排编码问题时还是加上了——这个在后面踩坑部分会详细说。依赖越少,越容易审计,也越不会出现版本冲突。

3. 第一版实现:核心代码逐段拆解

3.1 整体流程与入口设计

caveman的入口设计得很朴素:一进一出。输入URL,输出Markdown。为了便于测试和复用,我把它拆成四个独立函数,每个函数只做一件事:

def fetch_html(url, headers, timeout): """返回URL对应的HTML字符串,带重试与编码修正。""" ... def parse_html(html): """把HTML字符串变成BeautifulSoup对象。""" ... def extract_article(soup, url): """从soup里提取正文HTML,返回一个字符串。""" ... def to_markdown(article_html, options): """把正文HTML转成Markdown文本。""" ...

这样的结构让每个环节都能单独测试。比如parse_html后,你可以先打印soup的title,确认编码没问题;extract_article后,可以先看提取出来的HTML长度是否合理,再决定要不要调整规则。排错时不用每次从头跑全网请求。

3.2 抓取环节:请求头与超时的细节

抓取看起来最简单,但最容易翻车。直接requests.get(url)大概率能拿到页面,但一部分网站会根据User-Agent返回不同的页面,有些甚至直接拒绝没有UA的客户端。我的默认请求头是这样设置的:

def default_headers(): return { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36 caveman/0.1", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.6", "Connection": "keep-alive", }

注意我在UA末尾加了caveman/0.1。很多网站其实是允许爬虫访问的,只要它如实交代身份。与其伪装成一个浏览器,不如大大方方表明自己是什么,反而少了很多纠纷。当然,实际抓取时我还会看robots.txt。

超时设置也是必须的。如果不设timeout,遇到一个响应延迟的服务器,你的脚本可能挂在那里几分钟不动。我用的是一个元组:

resp = requests.get(url, headers=headers, timeout=(3, 10))

这里的(3, 10)含义是:连接超时3秒,读取超时10秒。也就是说,连不上对方服务器3秒就放弃,数据包10秒内没返回也放弃。一个页面如果10秒还没把正文传完,基本没必要等了。

3.3 正文提取的两个策略

这是整个工具的核心,也是我投入时间最多的地方。readability-lxml提供了一个很省事的入口,但它不是万能的,所以我在代码里做了双策略兜底:

def extract_article(soup, url): # 策略一:让 readability 先试试 try: from readability import Document doc = Document(str(soup), url=url) article_html = doc.summary() if len(article_html) > 500: return article_html except Exception: pass # 策略二:readability 失灵时,用启发式规则 article = soup.select_one("article") if article and len(article.get_text(strip=True)) > 500: return str(article) main_node = soup.select_one("main") or soup.select_one("[role='main']") if main_node and len(main_node.get_text(strip=True)) > 500: return str(main_node) # 最后兜底:找文本长度最大的 div,但要剔除嵌套的重复计算 best_div = None best_len = 0 for div in soup.find_all("div"): text_len = len(div.get_text(strip=True)) if text_len < 500: continue direct_len = text_len - sum( len(child.get_text(strip=True)) for child in div.find_all("div", recursive=False) ) if direct_len > best_len: best_len = direct_len best_div = div if best_div: return str(best_div) return str(soup.body)

策略一的逻辑很直白:readability判断的正文结果如果足够长,就信任它。文档型页面上它表现很好,尤其在英文内容上。

策略二则是我观察了大量失败案例之后写的。很多中国网站的页面没有article标签,或者readability会被页脚的大段文字干扰,这时就需要自己动手。核心技巧是:统计每个div的"直接文本量"——也就是它包含的文本总量减去所有直接子div包含的文本量。这个值能衡量一个div作为"内容容器"的纯度。如果一个div的直接文本量很大,说明它里面是自己写的段落,而不是套着无数个子模块的壳。这个算法比单纯找"文本最多的div"准确得多,因为它能排除掉包裹整个页面的根节点。

3.4 HTML到Markdown的转换配置

拿到正文HTML之后,转换成Markdown也有讲究。html2text默认配置会保留链接、图片和很多格式,但我的场景里更需要干净的文本。配置如下:

def to_markdown(article_html, options): h = html2text.HTML2Text() h.body_width = 0 # 不强制换行,保留原始段落结构 h.ignore_links = not options.get("with_links", False) h.ignore_images = not options.get("with_images", False) h.ignore_emphasis = options.get("ignore_emphasis", False) h.unicode_snob = True # 保留原字符,不转成HTML实体 h.mark_code = True # 识别代码块,用Markdown代码块标记 return h.handle(article_html)

body_width设为0很重要。默认值会让html2text在80列处强制换行,中文排版倒是问题不大,但代码块和长URL会被拆得七零八落。设为0之后,它只在HTML自身的换行和段落边界处断行,输出和原文结构一致。ignore_links之所以默认关闭,是因为如果你要把内容继续喂给AI或转成PDF,链接往往是有用的上下文,去掉就丢了信息。所以这个开关交给了命令行参数,用户自己判断。

3.5 命令行入口与"退化"到管道的设计

命令行参数用argparse实现,支持基本的几个开关:

caveman <url> # 输出到标准输出 caveman <url> -o notes/foo.md # 保存到文件 caveman <url> --with-links # 保留链接 caveman <url> --pipe # 安静模式,适合管道 caveman --from-list urls.txt # 批量处理

pipe模式是我后来加的。第一次用的时候我发现,终端里除了Markdown正文还有一堆日志信息,用管道重定向时日志也进去了。解决方法是加一个quiet开关,开启后所有进度提示和统计信息写入stderr,标准输出只留干净内容。这样caveman就可以作为上游程序,被其他工具无缝调用。

4. 真实抓取链路里的坑:编码、超时、动态页面与反爬完整排错

4.1 403与反爬:从curl对比开始的排查

工具写好后,我第一次拿一个资讯站测试,结果请求直接返回403。第一反应是对方封了我的IP,后来冷静下来,先用curl做对照实验:

curl -I https://example.com/some-article # 返回200,server头正常

curl能访问,说明IP没问题。再看requests的请求头,问题就明显了:我没有带UA,而且默认的python-requestsUA在很多反爬规则里是重点观察对象。我补上完整UA后,403变成200。

但这个站点的反爬不止这一层。加了UA后,偶尔还会收到一个跳转页面,内容是要求登录。我进一步检查发现,它依据的是Referer和Accept-Language。部分站点会拒绝没有Referer的请求,尤其是在图片和静态资源上。我在脚本里把Referer设为目标站点首页之后,请求就稳定了。这类问题排查有一个通用思路:先用curl复现,然后用requests逐步加请求头,一次只加一个,找出是哪一项被对方识别了。

4.2 中文乱码:requests的encoding推断不可靠

第二个坑是中文乱码。requests会自动从响应头里的charset和内容里的meta标签推断编码,但遇到某些老站它会判断错。症状很典型:标题正常,正文全是"锟斤拷"。

排查过程分三层递进。第一层看响应头有没有明确的charset;第二层看HTML的meta标签;第三层才用chardet做内容检测。我的实现是:

def detect_encoding(resp): # 第一优先:响应头的 charset content_type = resp.headers.get("content-type", "").lower() if "charset=" in content_type: return content_type.split("charset=")[-1].strip().strip("\"'") # 第二优先:HTML meta 标签 soup = BeautifulSoup(resp.content, "html.parser") meta = soup.find("meta", charset=True) if meta and meta.get("charset"): return meta.get("charset") meta = soup.find("meta", attrs={"http-equiv": "content-type"}) if meta and meta.get("content") and "charset=" in meta["content"].lower(): return meta["content"].lower().split("charset=")[-1].strip() # 最后兜底:chardet 检测实际字节 import chardet return chardet.detect(resp.content)["encoding"]

拿到正确的编码之后重新解码,正文立刻正常。这个坑一定要写在前面,因为如果你用resp.text,requests的推测出错时你根本看不到原始字节,很容易被误导。正确做法是拿到resp.content(字节串),自己做编码判断,再解码成文本。

4.3 正文提取把导航也算进去

正文提取算法最典型的问题是:把页面导航、相关阅读、广告区块一起当成正文提取出来。我遇到一个博客,readability返回的内容里混杂着"上一篇""下一篇""推荐给朋友"这些链接。

后来分析发现,是因为这个站点的导航区块文字密度很高,而且位置在正文中间,readability的算法被迷惑了。我的处理是在传给readability之前,先手动清理明显无关的节点:

def clean_noise_nodes(soup): for selector in [ "nav", "aside", ".nav", ".menu", ".sidebar", ".related", ".recommend", ".ad", ".advertisement", ".share", ".social", ".comment", "#comment", "footer", ".footer" ]: for node in soup.select(selector): node.decompose() return soup

这一步简单粗暴,但非常有效。把这些噪音节点从DOM树里直接移除,readability拿到的就是相对干净的页面,判断准确率立刻上升。这也是我踩坑后的一个重要体会:不要完全信任某个库,哪怕它是行业标准。好的做法是"预处理加后处理"双保险——先把明显的噪音清掉,再让算法去判断,最后对结果再做一个200字到5000字的长度校验,超出这个范围就回退到启发式。

4.4 遇到JS渲染的SPA页面怎么办

有些页面用requests拿到的HTML里没有任何正文,只有一套空壳和一堆JS文件的引用。这种情况无论怎么调提取算法都没用,因为正文根本不在HTML里。

我的处理方案是"定位接口加明确提示"两步走。先尝试在HTML里搜索常见的JSON数据块,很多SPA页面会把初始数据以JSON形式嵌入script标签,那里面往往有完整的文章内容,用正则抽取比浏览器渲染还快。如果确实找不到,就给用户一个明确的报错:该页面疑似需要JavaScript渲染,建议使用浏览器阅读,或先尝试查找页面的数据接口。一个工具可以有自己的能力边界,明确拒绝也是一种设计,比默默输出空文件强得多。

4.5 关于robots.txt与抓取伦理,我想多说两句

整个项目做下来,我越发觉得爬虫工具的重点不是"能不能爬到",而是"该不该爬"。caveman内置了两条规矩:一是在批量模式下,每次请求之间至少间隔2秒;二是默认读取并尊重目标站点的robots.txt,如果明确禁止了采集路径,就打印提示并退出。

很多人觉得robots.txt只对搜索引擎有意义,个人工具不必理会。我的看法相反:正因为它是个人的、小规模的,才更应该自律。你尊重站点的规则,站点就不会对你做更严格的限制,你的工具才能长期稳定工作。caveman的使用场景是个人阅读和整理,频率极低,哪怕不加这个逻辑也不会出事。但把规则写在工具里,等于把自己"应该怎么做"固化成了"工具会怎么做",这让我用起来心里踏实。

5. 让工具住进日常命令行:配置化与管道组合

5.1 用配置文件把个人习惯沉淀下来

工具能用之后,我发现自己每次敲命令都要带一堆参数:UA要换成自己的、输出目录要指向笔记目录、链接默认要保留。干脆加了一个配置文件,放在~/.config/caveman/config.yaml:

output_dir: ~/Documents/notes/caveman user_agent: "Mozilla/5.0 ... MyCustomUA/1.0" with_links: true with_images: false request_timeout: [3, 15] cache_dir: ~/.cache/caveman noise_selectors: [".ad", ".recommend", "#comment"]

为什么用YAML而不是JSON或者INI?因为配置项会越来越多,YAML支持注释,层级清晰,手写友好。比如noise_selectors这个字段,允许用户自定义自己的噪音选择器,不同网站风格差异很大,这个字段让每个用户都能按需调整。配置文件加载后和命令行参数合并,命令行参数优先级更高,这个规则符合常规直觉:临时需求用参数,长期习惯写配置。

5.2 管道组合:caveman是上游,不是终点

服务好个人使用后,我更大的乐趣在于把它和其他工具串联起来,让它成为数据链路的一个环节。

# 终端里直接渲染Markdown caveman <url> --pipe | glow # 转换成Word文档给不太懂技术的同事 caveman <url> --pipe --with-links | pandoc -f gfm -t docx -o out.docx # 追加进每日笔记 caveman <url> --pipe >> notes/daily-$(date +%Y-%m-%d).md # 长文转纯文本后喂给本地AI总结 caveman <url> --pipe --ignore-emphasis | llm "用三句话总结这篇文章" # 批量抓取多个文章并合并成一个Markdown caveman --from-list urls.txt > all.md

这些组合加起来几乎等于一个迷你信息流水线。我没有做GUI,没有做实时预览,因为对于"网页转本地文本"这个需求,命令行配管道已经做到了极致。有一次我甚至用一条管道把一百多页的文档转成了PDF——caveman抓正文,Pandoc转LaTeX,LaTeX再编译成PDF。整个过程没有任何手工复制粘贴。

5.3 缓存:让重复抓取不打扰目标站点

批量抓取和定时任务场景里,同一个URL可能在一天内被请求多次。我加了一个轻量缓存:请求之前,先对URL做MD5,得到的哈希作为文件名,HTML放在cache目录,转换后的Markdown也放一份。第二次请求同一个URL时,如果缓存文件存在且未超过有效期,就直接读缓存,不再发网络请求。

这个功能表面上是省流量、加快速度,实际上更大的价值是尊重目标站点。定时任务每天抓五次同一个页面,如果每次都发真实请求,虽然体量不大,但累积起来也是一种负担。有缓存之后,caveman变成了一个"即使挂在那里跑一个月,也不会给任何网站造成压力"的工具。缓存的实现本身也就二十行代码,但它让工具从"抓取器"升格成了"稳重的内容管理器"。

6. 后续扩展方向与开发过程中的几点体会

6.1 下一步可以做的事

caveman目前已经满足了我的日常需求,但我也清楚它可以在哪些方向上做扩展,按优先级排序大概是下面几个:

一是输出JSON格式。在Markdown之外增加一个--format=json选项,输出标题、URL、抓取时间、正文、字数等结构化字段。这样一来,下游程序可以直接读取caveman的结果做统计、去重、索引,而不需要再解析Markdown里的H1标题来猜文章标题。

二是自定义提取规则的插件机制。不同网站的正文区块类名差异很大,虽然默认策略能覆盖大多数场景,但遇到个别古怪的站点,还是希望可以用一个简单的JSON脚本指定选择器,比如{"selector": ".post-content", "url": "domain.com"}。这个功能不复杂,但能大幅提升工具的实用边界,前提是配置文件里把它做上去。

三是和本地模型做结合。caveman输出干净文本后,接一个本地大模型做摘要或关键词提取,回写到文本的frontmatter里,变成一张带元数据的知识卡片。整个链路完全离线,隐私上很安心。这个方向我认为比集成浏览器渲染更有价值,因为信息处理的价值在"提纯",而不是在"看到"。

6.2 对"重新造轮子"这件事的个人看法

把caveman从头做一遍之后,我对"网上已经有类似工具为什么还要自己写"这个问题有了更完整的答案。

一是确定性。你用别人的在线服务,规则和可用性都掌握在别人手里。今天能用的接口,明天可能限流,后天可能加登录。自己写的工具是本地一条命令,依赖是固定的几个库,它可以离线工作,可以任意修改,永远不用担心服务下线。

二是格式可控。我想要Markdown,就输出纯Markdown,不想看到HTML片段就不让它出现。通用工具为了实现"兼容所有人",经常做出一堆你不想要的行为,而自己的工具可以为了"合适自己"牺牲那一点点通用性。

三是学习价值。爬取、解析、提取、转换这四个环节,每一个都藏着大量细节。光是一个正文提取,我就试过readability、统计启发式、DOM结构分析三种思路,踩过编码、反爬、JS渲染的坑。这些经验不亲手写一遍,看再多文档也记不住。

其实caveman这个名字在项目快写完的时候才定下来。当时我在笔记里写了一句话:这个工具的功能简单粗粝,就像穴居人的石斧,只能用来砸开硬壳,取出里面能吃的东西。现代网页就是那层越来越厚的壳,正文就是壳里面的肉。caveman只管砸壳取肉,不负责烹饪——烹饪是下游工具的事情。

我在实际使用中最深的一个体会是:这类小工具不要追求一次做到"大全",而是先做一个能用的最小版本,然后让它在真实需求里被反复锤打。caveman第一版只支持单个URL,跑了半个月之后才逐渐加了批量处理和缓存。每个新功能都是因为我真的遇到了那个痛点,而不是因为"觉得可能会有用"。如果你也想写类似的工具,最大的建议是先挑十个真实页面跑一遍提取逻辑,再决定优化方向——你会发现,网上那些"理想化的HTML结构"和实际页面的差距,比想象中大得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询