DevDocs 新增文档完整实战:Scraper 开发、Filter 流水线与 thor 命令工作流
本文为 DevDocs(API Documentation Browser)的文档贡献者指南:以 Adding a documentation 为主线,完整讲解在 DevDocs 中新增一份文档的 13 个步骤——从编写 Scraper 子类、实现 CleanHtml 与 Entries 两个必备 Filter,到用 thor docs:page / thor docs:generate 迭代验证,再到前端 SCSS/JS 定制、图标与版权信息的收尾工作。读完并对照仓库源码后,你可以独立完成一份文档的接入,并理解其背后的抓取-过滤-索引流水线原理。
一、背景:DevDocs 的文档处理流水线
在动手之前,先理解 DevDocs 是如何把一份在线文档"搬进"应用的。根据 Scraper Reference 的说明:
- Scraper 从根 URL 出发,递归跟踪符合规则的链接,每个有效响应都会经过一条 Filter 流水线,最终把文件写到本地文件系统,并生成页面元数据索引(一个 JSON 文件);
- 底层依赖三个库:Typhoeus(HTTP 请求)、HTML::Pipeline(Filter 链)、Nokogiri(HTML 解析);
- 当前有两种 Scraper:
UrlScraper(经 HTTP 下载)与FileScraper(从本地文件系统读取)。两者工作方式几乎一致(都操作 URL),区别在于FileScraper在读文件前把 base URL 替换为本地路径,默认使用localhost作为占位 base URL,并在流水线末端用CleanLocalUrls过滤器清掉所有指向它的链接。
一个响应要被处理,必须满足:200 状态码、HTML 内容类型、重定向后的有效 URL 位于 base URL 之内;FileScraper 则只检查文件存在且非空。每个 URL 只会被请求一次(大小写不敏感)。
从源码结构看,这条流水线的入口在 Scraper 基类:build_pages 方法维护一个队列(Set 记录已访问 URL),对每个响应调用 handle_response → process_response,后者把页面交给 HTML::Pipeline 执行 html_filters + text_filters 合并而成的过滤器链,并把结果(含新发现的内部 URL)交回队列继续爬取(lib/docs/core/scraper.rb#L72-L84)。基类预置的默认过滤器栈为(lib/docs/core/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'
其中 HTML 过滤器操作 Nokogiri 节点对象,文本过滤器操作字符串,二者分层是为了避免重复解析文档。UrlScraper 侧的响应校验逻辑在 url_scraper.rb:process_response? 要求响应成功、是 HTML,且 base_url.contains?(url) 成立。
二、第一步:创建 Scraper 子类
按 docs/adding-docs.md 的步骤 1~3,首先在 lib/docs/scrapers/ 目录下创建一个 Docs::UrlScraper 或 Docs::FileScraper 的子类。类名必须是文件名的 PascalCase 形式(如 my_doc → MyDoc),仓库中已有上百个示例可参考,例如 vite.rb:
module Docs
class Vite < UrlScraper
self.name = 'Vite'
self.slug = 'vite'
self.type = 'simple'
self.links = {
home: 'https://vite.dev/',
code: 'https://github.com/vitejs/vite'
}
options[:root_title] = 'Vite'
options[:attribution] = <<-HTML
© 2019-present, VoidZero Inc. and Vite contributors<br>
Licensed under the MIT License.
HTML
options[:skip] = %w(team.html team)
options[:skip_patterns] = [/\Ablog/, /\Aplugins/]
self.initial_paths = %w(guide/)
html_filters.push 'vite/entries', 'vite/clean_html'
version do
self.release = '8.2.1'
self.base_url = 'https://vite.dev/'
end
version '7' do
self.release = '7.3.1'
self.base_url = 'https://v7.vite.dev/'
end
def get_latest_version(opts)
get_npm_version('vite', opts)
end
end
end
这个真实例子几乎浓缩了全部要点:类属性(name/slug/type/links)、options 中的 attribution 与 URL 排除规则、initial_paths、往 html_filters 中追加自定义过滤器、version 块(同一文档的多个大版本可以共存),以及 get_latest_version 实现(用于版本检查任务)。注意 type = 'simple' 表示它复用通用样式,见第九节。
类属性速查
属性的完整定义见 Doc 基类(attr_accessor :name, :slug, :type, :release, :abstract, :links),语义以 Scraper Reference 为准:
| 属性 | 类型 | 说明 |
|---|---|---|
name |
String | 必须唯一,默认取类名 |
slug |
String | 必须唯一、小写、不含连字符(下划线可用),默认取 name 小写化 |
type |
String | 必填,可继承。决定每页加载的 CSS 类名([type])与自定义 JS 类(app.views.[Type]Page);结构相似的文档应共用同一 type 以复用 CSS/JS,只允许小写字母 |
release |
String | 必填。Scraper 上次运行时对应的软件版本,仅信息性用途,不影响抓取行为 |
base_url |
String | UrlScraper 中必填。文档所在位置,只有"位于其内部"的 URL 会被抓取;未设 root_path 时初始 URL 即 base_url |
base_urls |
Array | 多 base URL 场景(需 include MultipleBaseUrls),见 typescript.rb |
root_path |
String | 可继承。根 URL 相对 base_url 的路径 |
initial_paths |
Array | 可继承。初始队列要加入的路径列表,适合抓取彼此孤立的文档,默认 [] |
dir |
String | FileScraper 必填。文件在本地文件系统中的绝对路径 |
params |
Hash | UrlScraper 专用。追加到每个 URL 的查询参数(如 { format: 'raw' }) |
abstract |
Boolean | 将 Scraper 设为抽象类/不可运行,用于向其他 Scraper 共享行为(如 MDN 各 Scraper 都继承抽象的 Mdn 基类),默认 false |
版本支持由 Doc 类 的 version 类方法实现:每次调用 version('7') do ... end 都会生成一个匿名子类并压入 @versions,其 slug 会被改写为 slug~version_slug 形式(如 vite~7),从而让多版本文档以独立目录存储。
三、第二步:编写 Filters(CleanHtml 与 Entries)
步骤 4 要求在 lib/docs/filters/[my_doc]/ 目录下创建该 Scraper 专属的 Filter,并把它们加入类的过滤器栈。Filter 数量不限,但至少需要两个(Filter Reference 有完整细节):
CleanHtml过滤器:负责清洗 HTML 标记(例如给标题补id属性),并移除一切冗余或非必要内容,最终只保留核心文档正文;Entries过滤器:负责确定页面的元数据——entries 列表,每个 entry 含 name、type 与 path。
过滤器栈的操作方法
两个栈(html_filters 与 text_filters)由 FilterStack 类 实现,本质是有序集合,提供四个修改方法:
push(*names) # 在末尾追加一个或多个过滤器
insert_before(index, *names) # 在某过滤器之前插入(index 可以是名称)
insert_after(index, *names) # 在某过滤器之后插入
replace(index, name) # 用另一个过滤器替换(index 可以是名称)
"names" 是相对于 Docs 的 require 路径,例如 'vite/entries' 会解析为 Docs::Vite::EntriesFilter(解析逻辑见 filter_stack.rb:Docs.const_get "#{name}_filter".camelize)。另外,Scraper 类在 inherited 时会自动 autoload_all 对应 docs/filters/[类名]/ 目录下的文件(scraper.rb),所以你只需按 vite/entries 这样写名字,无需手动 require。
CleanHtml 示例
Filter Reference 给出的覆盖最常见用法的示例实现:
module Docs
class MyScraper
class CleanHtmlFilter < Filter
def call
css('hr').remove
css('#changelog').remove if root_page?
# Set id attributes on <h3> instead of an empty <a>
css('h3').each do |node|
node['id'] = node.at_css('a')['id']
end
# Make proper table headers
css('td.header').each do |node|
node.name = 'th'
end
# Remove code highlighting
css('pre').each do |node|
node.content = node.content
end
doc
end
end
end
end
官方给出的注意事项:空元素会被流水线后段的核心 CleanTextFilter 自动清理;修改应尽量克制,能用自定义 CSS 规范页面的就不要在 Filter 里改(隐藏内容除外,那必须移除标记);对只作用于部分页面的特殊逻辑务必写注释。Vite 的实际实现可对照 filters/vite/clean_html.rb。
Entries 示例
每个 Scraper 必须继承 Docs::EntriesFilter 实现自己的 Entries 过滤器。基类已实现 call,子类只需覆写四个方法:
get_name:默认 entry(即页面名)的名称,通常由slug推断或从标记中查找。默认值:slug的修饰版(下划线换空格、斜杠换点);get_type:默认 entry 的类型;没有类型的 entry 可以被搜索但不会出现在侧边栏。默认nil;include_default_entry?:是否包含默认 entry。常用于让包含多个 entry 但没有自己名称的页面不入索引(此时该页不会被写入文件系统,指向它的链接会 404——这正是用include_default_entry?替代超长:skip列表的技巧)。默认true;additional_entries:附加 entry 列表,每个 entry 是[名称, 片段标识符, 类型]三元组。片段标识符对应 HTML 元素(通常是标题)的id属性,与页面路径组合成 entry 的 path;缺省或nil时分别使用页面路径与默认类型。默认[]。
示例实现(节选自 Filter Reference):
module Docs
class MyScraper
class EntriesFilter < Docs::EntriesFilter
def get_name
node = at_css('h1')
result = node.content.strip
result << ' event' if type == 'Events'
result << '()' if node['class'].try(:include?, 'function')
result
end
def get_type
object, method = *slug.split('/')
method ? object : 'Miscellaneous'
end
def additional_entries
return [] if root_page?
css('h2').map do |node|
[node.content, node['id']]
end
end
def include_default_entry?
!at_css('.obsolete')
end
end
end
end
Vite 的对应实现见 filters/vite/entries.rb。命名规范提醒:名称在整个文档中必须唯一且尽量短(理想小于 30 字符);方法名尽量以 () 与属性区分;类方法与实例方法用 Class#method 或 object.method 约定区分。
四、第三步:用 thor docs:page 验证单页
步骤 5:用 thor docs:page [my_doc] [path] 命令验证 Scraper 是否正常工作,产物会出现在 public/docs/[my_doc]/ 目录(该命令不触碰索引文件,所以页面对应用尚不可见)。这里的 path 对 UrlScraper 指远端路径,对 FileScraper 指本地路径。
命令的实际定义在 docs.thor:
thor docs:page (<doc> | <doc@version>) [path] [--verbose] [--debug]
path必须是绝对路径(以/开头),否则直接报错ERROR: [path] must be an absolute path.;--verbose会额外安装:storereporter,输出写入动作;--debug会关闭 GC 并安装:filter、:request、:doc三组 reporter,可以逐个 Filter、逐次请求地观察执行过程;- 命令通过
Docs.generate_page(name, version, path)走单页路径(Scraper#build_page),失败时提示 "Failed! (try running with --debug for more information)"。
单页调试是定位问题的第一现场:页面没生成、HTML 脏了、entry 缺失,都能在这一步暴露。
五、第四步:全量生成与响应缓存
步骤 6:用 thor docs:generate [my_doc] --force 生成完整文档。完整签名(docs.thor):
thor docs:generate (<doc> | <doc@version>) [--all] [--verbose] [--debug] [--force] [--package]
各选项的作用:
--force:跳过确认提示。对UrlScraper而言,未加--force时命令会打印告警("Some scrapers send thousands of HTTP requests in a short period of time...")并要求交互式确认Proceed? (y/n);--verbose:安装:storereporter,可以看到哪些文件被创建/更新/删除,便于对比两次运行之间的变化;--debug:安装:scraperreporter,可以看到哪些 URL 被请求、哪些被加入队列,便于定位是哪一页引入了不想要的 URL;--all:生成该文档的所有版本(配合version块多版本场景);--package:生成后顺带打包为.tar.gz(对应thor docs:package)。
生成成功时,store_pages 会把每个页面的 HTML 写入存储目录,同时构建 index.json(entry 索引)与 db.json(页面路径→内容摘要),成功后再执行 generate_manifest 重建全局清单。
响应缓存机制
步骤 8 的关键前提:只有第一次运行会真正下载页面,之后的运行都从响应缓存读取,直到你执行 thor docs:clean。缓存细节见 Scraper Reference 的 Response cache 一节:
UrlScraper把每个响应存入tmp/cache/[slug],后续运行直接复用,因此反复调 Filter 并重新生成很快、也不给源站施压(FileScraper读本地文件,无需缓存);- 缓存永不过期,
thor docs:clean会清空它(该命令同时删除*.tar.gz文档包,见 docs.thor);只有成功响应会被缓存,超时、404、服务器错误下次会重新请求; - 每个响应存为一个独立 JSON 文件,文件名是请求的哈希——修改 Scraper 的
params或headers会使缓存失效。文件格式遵循 HTTP Archive(HAR)规范,方便人工查看抓到的原始内容,包含startedDateTime、request、response(status/headers/content)与_effectiveUrl(重定向链最终落点)等字段。
迭代节奏
步骤 7、8 描述的是循环:启动服务 → 打开应用 → 启用该文档 → 观察效果 → 调整 Scraper/Filter → 重复执行 docs:page / docs:generate(此时命中缓存,速度很快),直到页面与元数据都满意为止。
六、进阶:过滤器选项与预处理钩子
在迭代中,你大概率需要用到 Scraper Reference 中定义的 options 键。最常用的是 InternalUrlsFilter 的链接控制选项:
:skip_links:false时不转换/不跟随任何内部 URL(生成单页文档);:follow_links/:skip/:skip_patterns:排除不想抓取的路径;:skip接受路径数组(大小写不敏感),:skip_patterns接受正则数组——Vite 中即options[:skip] = %w(team.html team)与options[:skip_patterns] = [/\Ablog/, /\Aplugins/];:only/:only_patterns:白名单模式,只抓取列出的路径;:trailing_slash:统一为内部 URL 添加或去除尾部斜杠,是去重页面的一种手段;NormalizeUrlsFilter的:replace_urls/:replace_paths/:fix_urls:在完全限定化之后改写 URL,用于消除多入口重复页、修复跳转链(MDN 系列 Scraper 是典型用例)。
ContainerFilter 的 :container 选项则决定"页面主体"是哪个元素(CSS 选择器,默认 <body>),容器外的内容会被移除、其内部链接也不会被跟随。
此外还有两个 Filter 流水线之外的钩子(详见 Scraper Reference):
process_response?(response):在流水线之前判定响应是否处理,返回false即丢弃。适合按内容过滤空页、坏页。参考 kotlin.rb;parse(response):默认把响应体解析为 Nokogiri 文档;若需保留非<pre>代码块中的空白(Nokogiri 可能删除它们),可覆写此方法先改写 HTML 源码。参考 go.rb。
FileScraper 场景下,若文档源是下载的压缩包,可覆写 download_source 并调用内置的 download_and_extract(url, subdirectory) 完成下载、解压(支持 .zip / .tar.gz / .tar.bz2)与落地到 source_directory,完整实现见 file_scraper.rb,更多模式可阅读 docs/file-scrapers.md。
七、收尾:样式、脚本、图标与版权
步骤 9~12 处理"文档进站之后"的呈现问题:
自定义样式(步骤 9)
在 assets/stylesheets/pages/ 目录创建 SCSS 文件,并在 application.css.scss 中引入。文件名与 CSS 类名都应为 _[type],其中 [type] 等于 Scraper 的 type 属性——共用同一 type 的文档共享同一份自定义 CSS 与 JS。若几乎不需要样式调整,把 type 设为 simple 即可直接套用 assets/stylesheets/pages/_simple.scss 中的通用排版规则(Vite 即此用法)。仓库中该目录已有上百个按 type 划分的样式文件可供借鉴,如 _rubydoc.scss、_sphinx.scss 等。
语法高亮与自定义 JS(步骤 10)
若页面需要语法高亮或自定义 JavaScript,在 assets/javascripts/views/pages/ 目录创建文件(assets/javascripts/views/pages)。对应关系由 type 决定:type 为 vite 的文档会加载 app.views.VitePage 这类页面视图类,可参考同目录其他文件的写法。
图标(步骤 11)
把文档图标放入 public/icons/docs/[my_doc]/ 目录,同时提供 16x16 与 32x32 两种尺寸。图标雪碧图(spritesheet)在你(重新)启动本地 DevDocs 实例时自动重新生成,无需手工处理。
版权信息(步骤 12)
把版权细节写入 options[:attribution](一个 HTML 字符串)。这段数据会展示在 About 页的表格中,并按字母序排列。排版风格直接参考现有 Scraper,例如 Vite:
options[:attribution] = <<-HTML
© 2019-present, VoidZero Inc. and Vite contributors<br>
Licensed under the MIT License.
HTML
版本检查(步骤 13)
确保 thor updates:check [my_doc] 显示正确的最新版本。该任务的实现在 updates.thor:对每个文档取 get_scraper_version(默认来自 options[:release],见 doc.rb)与你在 Scraper 中实现的 get_latest_version,再按 semver 规则比较得出 Outdated major version / Outdated minor version / Up-to-date 三档状态。
get_latest_version 的约定(见 doc.rb):
- 若定义了
self.release,应返回文档的最新版本号; - 若未定义
release,返回文档最后修改的 Epoch 时间戳; - 若文档永不变化,直接返回
1.0.0。
基类提供了一组可直接调用的工具方法(doc.rb):fetch / fetch_doc / fetch_json(通用 HTTP 三件套,访问 GitHub API 时自动附加 GITHUB_TOKEN)、get_npm_version(package, opts)、get_latest_github_release(owner, repo, opts)(自动去除前导 v)、get_github_tags、get_github_file_contents、get_latest_github_commit_date、get_gitlab_tags。这些结果的定期汇总会以 "Documentation versions report" issue 的形式上报,帮助维护者跟踪过期文档——所以这一步不是可有可无的形式检查。
八、规模化建议与代码质量约定
docs/adding-docs.md 最后两段给出两条重要约定:
- 大文档优先本地抓取:如果文档有数百页以上且可下载,尽量用
FileScraper在本地抓取(例如利用download_and_extract拉取官方压缩包)。这会显著加快开发迭代速度,也避免对源站造成过大压力。Scraper 与本地环境耦合不是问题,只需在 Pull Request 中说明其工作方式即可; - 写足注释:尽可能用注释记录 Scraper 与 Filter 的行为——为什么忽略某些 URL、为什么移除某些 HTML 标记、元数据为什么这样取。这会让后续的文档更新成本低得多。
此外提交前请阅读仓库的 contributing 规范,并在 thor docs:list 中确认新 Scraper 已被列出(步骤 3)。该命令遍历 Docs.all 以分页形式输出所有文档名与版本(name@version 格式),是接入完成与否的第一道验收。
九、接入流程自检清单
把 13 个步骤浓缩为提交前 checklist:
- [ ]
lib/docs/scrapers/my_doc.rb存在,类名为文件名 PascalCase,thor docs:list能看到它; - [ ]
name/slug/type/release/base_url(或dir)已设置,多版本场景用version块; - [ ]
lib/docs/filters/my_doc/下至少实现clean_html.rb与entries.rb,并已html_filters.push 'my_doc/entries', 'my_doc/clean_html'; - [ ]
thor docs:page my_doc /some/path单页输出正常,thor docs:generate my_doc --force全量生成成功; - [ ] 应用内启用该文档后,侧边栏 entries、页面渲染、链接跳转均正常;
- [ ] 样式(
_[type].scss)与页面视图(assets/javascripts/views/pages/)就绪,simple类型则无需额外 SCSS; - [ ]
public/icons/docs/my_doc/提供 16x16 与 32x32 图标; - [ ]
options[:attribution]已填写版权与许可证; - [ ]
thor updates:check my_doc返回正确的最新版本; - [ ] 代码注释解释了所有"特殊处理"(跳过的 URL、移除的标记、entry 推断规则)。
完成上述各项后,这份新文档就按 DevDocs 的既有架构完整接入:抓取、过滤、索引、缓存、样式、图标、版本监控六个环节全部就位,后续维护者可依据你的注释以较低成本完成下一轮更新。
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