DevDocs 抓取器体系参考:UrlScraper 与 FileScraper 的配置、过滤管线与响应缓存
本文以 docs/scraper-reference.md 为主线,系统讲解 DevDocs(一个 API 文档浏览器)文档抓取器的完整工作模型:如何从根 URL 递归发现并抓取页面、如何为每份文档配置属性与过滤栈、如何在过滤器之前预处理响应,以及响应缓存与版本追踪机制。读完本文,你可以独立编写、调试一个抓取器,并利用 typescript.rb 等现成示例作为参照。
1. 抓取器概述:从根 URL 到本地文件系统
抓取器的工作流程可以概括为一句话:从一个根 URL 出发,递归地跟随符合规则的一组链接,让每个合法响应依次经过一条过滤器链,最终把文件写到本地文件系统;同时构建页面元数据索引(由 EntriesFilter 确定),并在结束时将其转储为 JSON 文件。
抓取器依赖三个核心库(见 docs/scraper-reference.md):
- Typhoeus:发起 HTTP 请求;
- HTML::Pipeline:将各过滤器串成管线执行;
- Nokogiri:解析 HTML。
1.1 两类抓取器
从源码结构看,仓库中存在两种抓取器,分别位于 lib/docs/core/scrapers/url_scraper.rb 与 lib/docs/core/scrapers/file_scraper.rb,二者均继承自 lib/docs/core/scraper.rb 中的抽象基类 Docs::Scraper:
UrlScraper:通过 HTTP 下载文件。FileScraper:从本地文件系统读取。它在读取前把 base URL 替换成本地路径,默认使用占位 base URLlocalhost,并在过滤栈末尾加入CleanLocalUrls过滤器,用于移除所有指向localhost的 URL。
这一默认值可以在源码中直接确认(file_scraper.rb#L14-L16):
self.base_url = 'http://localhost/'
html_filters.push 'clean_local_urls'
FileScraper 本质上和 UrlScraper 使用同一套 URL 操作逻辑,唯一的区别是把 base_url 替换为 dir 来读文件(见 file_scraper.rb#L88-L90 的 url_to_path 实现)。
1.2 响应被处理的准入条件
一个响应要进入过滤管线,必须同时满足:
- 状态码为 200;
- 内容类型为 HTML;
- 重定向之后的有效 URL(effective URL)位于
base_url之内。
其中“之内”大致等同于“以 base_url 开头”,但有边界规则:/docs 不算 /doc 的内部(/doc/ 则算)。UrlScraper 的这段判定逻辑实现在 url_scraper.rb#L51-L67:
def process_response?(response)
if response.error?
raise <<~ERROR ...
elsif response.blank?
raise "Empty response body: #{response.url}"
end
response.success? && response.html? && process_url?(response.effective_url)
end
def process_url?(url)
base_url.contains?(url)
end
而 FileScraper 的判定简单得多——只检查文件存在且非空(file_scraper.rb#L84-L86):
def process_response?(response)
response.body.present?
end
另外,每个 URL 只会被请求一次(大小写不敏感)。基类 build_pages 用一个 Set 维护已访问历史,仅当 history.add?(url.downcase) 返回 true 时才把该 URL 加入下一轮队列(scraper.rb#L72-L84),这既防止了环路,也天然去除了大小写重复。
2. 配置体系:类属性
抓取器全部通过类属性配置,分为三大类:属性(Attributes)、过滤栈(Filter stacks)、过滤器选项(Filter options)。
命名约束:抓取器位于 lib/docs/scrapers 目录,类名必须是文件名的 CamelCase 等价形式(例如 kotlin.rb 对应 Docs::Kotlin)。
2.1 属性一览
| 属性 | 类型 | 说明 |
|---|---|---|
name |
String | 必须唯一。默认为类名。 |
slug |
String | 必须唯一、小写、不能含连字符(下划线可以)。默认是 name 的小写形式。 |
type |
String | 必填,可继承。定义添加到每个页面的 CSS 类名(_[type])和自定义 JS 类(app.views.[Type]Page)。结构相似的文档(同一工具生成或源自同一网站)应复用同一 type,避免重复维护 CSS/JS。只能含小写字母。 |
release |
String | 必填。抓取器最后一次运行时软件所处的版本,仅作信息记录,不影响抓取行为。 |
base_url |
String | UrlScraper 中必填。文档所在地址,只会抓取“内部”URL。FileScraper 中默认为 localhost(指向它的内容会被 CleanLocalUrls 过滤器移除;若文档在线可访问,应覆盖该值)。未设置 root_path 时,根/初始 URL 即等于 base_url。 |
base_urls |
Array | 需 include MultipleBaseUrls 模块。用于文档分散在多个 URL、或多个 URL 组合才完整的情况。 |
root_path |
String | 可继承。根 URL 相对 base_url 的路径。 |
initial_paths |
Array | 可继承。加入初始队列的路径列表(相对 base_url),适合抓取相互孤立的文档。默认 [](运行时会把 root_path 追加进数组)。 |
dir |
String | FileScraper 专属必填。文件在本地文件系统的绝对路径。 |
params |
Hash | 可继承,UrlScraper 专属。追加到每个 URL 上的查询参数,如 { format: 'raw' } → ?format=raw。默认 {}。 |
abstract |
Boolean | 将抓取器标记为抽象/不可运行,用于与其他类共享行为(例如所有 MDN 抓取器都继承自抽象基类 Mdn)。默认 false。 |
这些属性的默认值与继承行为可以在 lib/docs/core/doc.rb 中看到:name 未显式设置时回退为类名去掉模块前缀,slug 由 name.downcase 推导(doc.rb#L47-L53);abstract 类在实例化时直接抛出异常(doc.rb#L162-L164)。
继承机制的关键在于基类的 inherited 钩子(scraper.rb#L8-L23):子类生成时会 deep_dup 一份 options、inheritable_copy 一份两个过滤栈,因此子类的修改不会污染父类——这也是多版本文档(version 块)能够各自定制过滤栈的基础。
base_urls 的实现在 url_scraper.rb#L82-L126 的 MultipleBaseUrls 模块中:设置 base_urls 时首个 URL 同时充当 base_url,initial_urls 会额外并入其余 base URL 作为初始抓取点,而 process_url? 改为“任一 base_url 包含该 URL 即通过”。
2.2 完整配置示例
lib/docs/scrapers/typescript.rb 是一个典型的抓取器配置,集中展示了 name、type、root_path、过滤栈追加、过滤器选项与多版本声明:
module Docs
class Typescript < UrlScraper
self.name = 'TypeScript'
self.type = 'typescript'
self.root_path = 'docs/'
self.links = {
home: 'https://www.typescriptlang.org',
code: 'https://github.com/Microsoft/TypeScript'
}
html_filters.push 'typescript/entries', 'typescript/clean_html', 'title'
options[:only_patterns] = [
/\Adocs\Z/,
/\Adocs\/handbook/,
/\Atsconfig/,
]
options[:skip_patterns] = [
/\Abranding/,
/\Acommunity/,
/\Adocs\Z/,
/\Atools/,
/react.*webpack/,
/release-notes/,
/dt\/search/,
/play/
]
options[:attribution] = <<-HTML
© 2012-2026 Microsoft<br>
Licensed under the Apache License, Version 2.0.
HTML
version do
self.release = '6.0.3'
self.base_url = 'https://www.typescriptlang.org/'
end
version '5.1' do
self.release = '5.1.3'
end
def get_latest_version(opts)
get_latest_github_release('Microsoft', 'TypeScript', opts)
end
end
end
其中 version DSL 会为每个版本生成一个匿名子类并复制父类的 name/slug/release 等属性(doc.rb#L16-L29),版本化的 slug 形如 typescript~5_1(+ 转 p、# 转 s 等规则见 doc.rb#L56-L63)。
3. 过滤栈(Filter Stacks)
每个抓取器持有两个过滤栈(lib/docs/core/scraper.rb#L41-L42):
html_filters:先执行,操作的是解析后的文档(Nokogiri 节点对象);text_filters:后执行,把文档当作字符串操作。
二者最终合并为一条 HTML::Pipeline 管线(scraper.rb#L110-L114),每个过滤器的输出成为下一个过滤器的输入。HTML/文本分离的意义在于避免对文档重复解析。
3.1 过滤栈的修改方法
过滤栈的行为类似有序集合,可用 FilterStack 提供的以下方法修改:
push(*names) # 在末尾追加一个或多个过滤器
insert_before(index, *names) # 在某过滤器之前插入(index 可以是过滤器名)
insert_after(index, *names) # 在某过滤器之后插入(index 可以是过滤器名)
replace(index, name) # 用另一个过滤器替换(index 可以是过滤器名)
“names” 是相对 Docs 的 require 路径。名字到常量的解析逻辑见 filter_stack.rb#L44-L50:
def filter_const(name)
...
Docs.const_get "#{name}_filter".camelize
end
即 'jquery/clean_html' 会解析为 Docs::Jquery::CleanHtmlFilter 这样的类。此外,Scraper.inherited 里还有 autoload_all "docs/filters/..." 的自动装载(scraper.rb#L11-L14),子类的专属过滤器目录(如 lib/docs/filters/typescript)会在需要时自动加载。
3.2 默认过滤器
文档列出的默认 html_filters(按顺序):
ContainerFilter— 更换文档根节点(移除容器之外的一切);CleanHtmlFilter— 移除 HTML 注释、<script>、<style>等;NormalizeUrlsFilter— 把所有 URL 替换为完整限定形式;InternalUrlsFilter— 识别内部 URL(待抓取的链接)并替换为未限定的相对形式;NormalizePathsFilter— 让内部路径保持一致(例如始终以.html结尾);CleanLocalUrlsFilter— 移除指向 localhost 的链接、iframe 与图片(仅FileScraper)。
从源码结构看,基类实际 push 的默认栈比文档列表更完整(scraper.rb#L44-L46):
html_filters.push 'apply_base_url', 'container', 'clean_html', 'normalize_urls', 'internal_urls', 'normalize_paths', 'parse_cf_email'
text_filters.push 'images' # ensure the images filter runs after all html filters
text_filters.push 'inner_html', 'clean_text', 'attribution'
其中 apply_base_url 和 parse_cf_email 在官方文档中未单独列出,文本栈首部的 images 被特意保证在所有 HTML 过滤器之后运行。默认的 text_filters 即文档所述的:
InnerHtmlFilter— 把文档转成字符串;CleanTextFilter— 移除空节点;AttributionFilter— 追加版权与原文链接。
此外还有两个特殊过滤器:
TitleFilter:核心 HTML 过滤器,默认禁用,为文档头部添加标题(<h1>);EntriesFilter:抽象 HTML 过滤器,每个具体抓取器必须实现,负责提取页面元数据(即最终写入索引的条目)。
4. 过滤器选项(Filter Options)
过滤器选项统一存放在 options Hash 中,该 Hash 可继承(递归拷贝)、默认空。每个抓取器实例化管线时会拿到 options.deep_dup 的副本,并自动合并 base_url、root_url、root_path、initial_paths、version、release 等运行时上下文(scraper.rb#L116-L133)。以下是各过滤器常用选项的完整说明。
4.1 ContainerFilter — :container
:container[String 或 Proc]:容器元素的 CSS 选择器。容器之外的一切会被移除,其他过滤器也无法再访问。多个元素匹配时取 DOM 中最靠前的一个;若无匹配则抛出错误。值是 Proc 时,对每页以过滤器实例为参数调用,应返回选择器或nil。默认容器是<body>。
注意:容器之外的链接不会被抓取器跟随。要移除那些“其实需要跟随”的链接,应在栈的后半段使用一个 CleanHtml 过滤器。
4.2 NormalizeUrlsFilter — URL 改写规则
该过滤器用于移除重复页(同一页面可从多个 URL 访问)以及修复“重定向过多”的网站(本应被抓取的 URL 藏在 base_url 之外的重定向后面,MDN 抓取器即为此类示例)。
:replace_urls[Hash]:把某个 URL 的所有出现替换为另一个。格式{ 'original_url' => 'new_url' };:replace_paths[Hash]:把某个子路径(相对base_url)的所有出现替换为另一个。格式{ 'original_path' => 'new_path' };:fix_urls[Proc]:对每个 URL 调用;返回nil表示不修改,否则返回值用作替换。
这些规则应用之前,所有 URL 会先被转换为完整限定形式(http://...)。
4.3 InternalUrlsFilter — 抓取范围控制
内部 URL 即位于 base_url 之内的 URL(/docs 不属于 /doc 内部)。它们默认会被抓取,除非被以下规则排除;所有内部 URL 在页面内都会转换为相对 URL:
:skip_links[Boolean 或 Proc]:为false时不转换、也不跟随任何内部 URL(形成单页文档);为 Proc 时对每页以过滤器实例为参数调用;:follow_links[Proc]:对每页调用;返回false时不把该页的内部 URL 加入队列;:trailing_slash[Boolean]:true给所有内部 URL 追加末尾斜杠,false则移除。这是去除重复页的又一手段;:skip[Array]:忽略子路径在数组中的内部 URL(大小写不敏感);:skip_patterns[Array]:忽略子路径匹配任一正则的内部 URL;:only[Array]:忽略子路径不在数组中(大小写不敏感)且不匹配:only_patterns中任一正则的内部 URL;:only_patterns[Array]:忽略子路径不匹配任一正则且不在:only中的内部 URL。
两条自动规则(文档规则,且与源码一致,见 scraper.rb#L122-L128):
- 若抓取器设置了
root_path,空路径与/会被自动加入跳过; - 若设置了
:only或:only_patterns,根路径会被自动追加进:only。
提示:也可以借助
Entries过滤器按内容把页面排除出索引。此时这些 URL 仍会在其他页面中被转成相对链接,点击会得到 404。虽非理想,但通常比维护一长串:skip列表更好。
4.4 AttributionFilter 与 TitleFilter
AttributionFilter的:attribution[String](必填):包含版权与许可信息的 HTML 字符串,可参考其他抓取器中的写法(如上节typescript.rb示例)。TitleFilter(默认禁用)::title[String 或 Boolean 或 Proc]:值不为false时为每页添加标题。值为nil时取Entries过滤器确定的页面名;否则取该 String 或 Proc 的返回值(Proc 每页调用一次,返回nil/false时不添加标题);:root_title[String 或 Boolean]:仅对根页覆盖:title。
5. 过滤器之前的响应预处理
过滤栈执行前有两个可直接处理响应的扩展点(文档原文如此,基类中均为 raise NotImplementedError,见 scraper.rb#L145-L147):
5.1 process_response?(response)
决定是否处理某个响应:返回 false 时该响应被丢弃。适合按内容过滤空页、无效页或重定向页。参考示例:lib/docs/scrapers/kotlin.rb。UrlScraper 的默认实现(url_scraper.rb#L51-L63)即上文 1.2 节的三重检查,遇到非 2xx 会直接抛错并把请求头打印出来便于排障。
5.2 parse(response)
解析 HTTP/文件响应,默认转换为 Nokogiri 文档。若希望在 Nokogiri 解析前修改 HTML 源码,可覆盖此方法——典型用途是保留非 <pre> 代码块中代码片段的空白(Nokogiri 可能会删除它们)。参考示例:lib/docs/scrapers/go.rb。默认的解析实现在 scraper.rb#L187-L190,经由 Parser 返回 [html, title]。
6. 响应缓存
UrlScraper 会把获取到的每个响应存到 tmp/cache/[slug] 目录,后续运行直接从中读取。因此调整过滤器后再次执行 thor docs:generate 很快,也不会给源站点造成任何负担(FileScraper 不需要缓存,它本来就读本地文件系统)。
关键行为(结合 lib/docs/core/response_cache.rb 源码):
- 缓存永不过期。运行
thor docs:clean清空,这是在源站更新后拿到新内容的必要步骤。ResponseCache.clean通过每个缓存目录里的.scraper_cache标记文件来定位各抓取器的缓存(response_cache.rb#L29-L33); - 只缓存成功响应,超时、404 与服务器错误会在下次运行时重新请求;
- 每个响应单独存为一个文件,文件名取自请求的哈希——因此修改抓取器的
params或headers会使缓存失效; - 文件为 JSON,条目结构遵循 HAR(HTTP Archive)规范,方便直接查看抓取器拿回了什么。示例(无关字段已省略):
{
"startedDateTime": "2026-08-15T11:05:10.430Z",
"time": 412,
"request": {
"method": "GET",
"url": "https://vite.dev/guide/",
"headers": [{ "name": "User-Agent", "value": "DevDocs" }]
},
"response": {
"status": 200,
"headers": [{ "name": "Content-Type", "value": "text/html; charset=utf-8" }],
"content": { "size": 57302, "mimeType": "text/html; charset=utf-8", "text": "<!doctype html>…" }
},
"_effectiveUrl": "https://vite.dev/guide/"
}
重定向被透明跟随,所以一条目只保存重定向链上最后一个响应,_effectiveUrl 记录最终到达的 URL。缓存与抓取器的接线见 url_scraper.rb#L47-L49:
def response_cache
@response_cache ||= ResponseCache.new(File.join(Docs.cache_path, self.class.slug))
end
7. 保持抓取器与文档同步
要让抓取器持续跟进上游文档版本,应覆盖 get_latest_version(opts) 方法,其契约(doc.rb#L186-L192):
- 若定义了
self.release:返回文档的最新版本; - 若未定义
release:返回文档最后修改的 Epoch 时间; - 若文档永不变化:直接返回
1.0.0。
该方法的结果会被定期汇总到一个 “Documentation versions report” issue 中,帮助维护者追踪过时的文档。基类还提供了 outdated_state(doc.rb#L205-L220),按 semver 风格比较前两位版本号,区分 “Outdated major version”“Outdated minor version” 与 “Up-to-date”(补丁版本变化不视为过时);不符合 semver 习惯的文档应自行覆盖。
为简化 get_latest_version 的编写,Doc 提供了一批工具方法(doc.rb#L228-L307):
通用 HTTP 方法
| 方法 | 说明 | 示例 |
|---|---|---|
fetch(url, opts) |
GET 请求,返回响应体 | lib/docs/scrapers/bash.rb |
fetch_doc(url, opts) |
GET 请求,返回转为 Nokogiri 文档的 HTML | lib/docs/scrapers/git.rb |
fetch_json(url, opts) |
GET 请求,返回转为字典的 JSON | lib/docs/scrapers/mdn/mdn.rb |
包仓库方法
get_npm_version(package, opts):返回指定 npm 包的最新版(内部请求https://registry.npmjs.com/<package>并读取dist-tags)。示例:lib/docs/scrapers/bower.rb。
GitHub 方法
get_latest_github_release(owner, repo, opts):返回最新 release 的 tag 名,前缀v会被去掉。示例:lib/docs/scrapers/jsdoc.rb;get_github_tags(owner, repo, opts):返回仓库的 tag 列表。示例:lib/docs/scrapers/liquid.rb;get_github_file_contents(owner, repo, path, opts):返回默认分支上指定文件的内容(对返回的 base64 内容解码)。示例:lib/docs/scrapers/minitest.rb;get_latest_github_commit_date(owner, repo, opts):返回默认分支最近一次提交的日期(Epoch 秒)。示例:lib/docs/scrapers/reactivex.rb。
GitLab 方法
get_gitlab_tags(hostname, group, project, opts):返回指定 GitLab 项目的 tag 列表。示例:lib/docs/scrapers/gtk.rb。
注意 fetch 系列会自动携带 GitHub Token:优先 opts[:github_token],回退到环境变量 GITHUB_TOKEN(doc.rb#L228-L235),这对避免 API 限流很有用。前文 typescript.rb 末尾的 get_latest_version 就是最典型的用法——直接调用 get_latest_github_release('Microsoft', 'TypeScript', opts)。
8. 小结:一次抓取的完整调用链
把前述机制串起来,UrlScraper 处理一页文档的完整链路为:
build_pages从initial_urls(根 URL +initial_paths)出发并发请求(带缓存命中检查);process_response?做 200/HTML/base_url 三重准入;parse产出 Nokogiri 文档(可覆盖以预处理源码);- HTML::Pipeline 依次执行
html_filters+text_filters,选项来自options; InternalUrlsFilter产出下一批待抓 URL(经:skip/:only/大小写历史去重);- 各页的
entries与output汇总后写入存储:页面文件、index.json(索引)、db.json(页面数据库)与meta.json(含 mtime、db_size,见 doc.rb#L154-L159)。
若某份文档需要先“构建”出 HTML 再抓取(如 R、Rails、Scala 3 等),则改用 FileScraper 并按 docs/file-scrapers.md 中对应小节的说明准备 docs/<slug> 目录;更多过滤器细节可查阅 docs/filter-reference.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00