首页
/ DevDocs 新增文档完整实战:Scraper 开发、Filter 流水线与 thor 命令工作流

DevDocs 新增文档完整实战:Scraper 开发、Filter 流水线与 thor 命令工作流

2026-09-05 11:16:25作者:虞亚竹Luna

本文为 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_responseprocess_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.rbprocess_response? 要求响应成功、是 HTML,且 base_url.contains?(url) 成立。

二、第一步:创建 Scraper 子类

docs/adding-docs.md 的步骤 1~3,首先在 lib/docs/scrapers/ 目录下创建一个 Docs::UrlScraperDocs::FileScraper 的子类。类名必须是文件名的 PascalCase 形式(如 my_docMyDoc),仓库中已有上百个示例可参考,例如 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
      &copy; 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 有完整细节):

  1. CleanHtml 过滤器:负责清洗 HTML 标记(例如给标题补 id 属性),并移除一切冗余或非必要内容,最终只保留核心文档正文;
  2. Entries 过滤器:负责确定页面的元数据——entries 列表,每个 entry 含 name、type 与 path。

过滤器栈的操作方法

两个栈(html_filterstext_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.rbDocs.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#methodobject.method 约定区分。

四、第三步:用 thor docs:page 验证单页

步骤 5:用 thor docs:page [my_doc] [path] 命令验证 Scraper 是否正常工作,产物会出现在 public/docs/[my_doc]/ 目录(该命令不触碰索引文件,所以页面对应用尚不可见)。这里的 pathUrlScraper 指远端路径,对 FileScraper 指本地路径。

命令的实际定义在 docs.thor

thor docs:page (<doc> | <doc@version>) [path] [--verbose] [--debug]
  • path 必须是绝对路径(以 / 开头),否则直接报错 ERROR: [path] must be an absolute path.
  • --verbose 会额外安装 :store reporter,输出写入动作;
  • --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:安装 :store reporter,可以看到哪些文件被创建/更新/删除,便于对比两次运行之间的变化;
  • --debug:安装 :scraper reporter,可以看到哪些 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 的 paramsheaders 会使缓存失效。文件格式遵循 HTTP Archive(HAR)规范,方便人工查看抓到的原始内容,包含 startedDateTimerequestresponse(status/headers/content)与 _effectiveUrl(重定向链最终落点)等字段。

迭代节奏

步骤 7、8 描述的是循环:启动服务 → 打开应用 → 启用该文档 → 观察效果 → 调整 Scraper/Filter → 重复执行 docs:page / docs:generate(此时命中缓存,速度很快),直到页面与元数据都满意为止。

六、进阶:过滤器选项与预处理钩子

在迭代中,你大概率需要用到 Scraper Reference 中定义的 options 键。最常用的是 InternalUrlsFilter 的链接控制选项:

  • :skip_linksfalse 时不转换/不跟随任何内部 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 决定:typevite 的文档会加载 app.views.VitePage 这类页面视图类,可参考同目录其他文件的写法。

图标(步骤 11)

把文档图标放入 public/icons/docs/[my_doc]/ 目录,同时提供 16x16 与 32x32 两种尺寸。图标雪碧图(spritesheet)在你(重新)启动本地 DevDocs 实例时自动重新生成,无需手工处理。

版权信息(步骤 12)

把版权细节写入 options[:attribution](一个 HTML 字符串)。这段数据会展示在 About 页的表格中,并按字母序排列。排版风格直接参考现有 Scraper,例如 Vite:

options[:attribution] = <<-HTML
  &copy; 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_tagsget_github_file_contentsget_latest_github_commit_dateget_gitlab_tags。这些结果的定期汇总会以 "Documentation versions report" issue 的形式上报,帮助维护者跟踪过期文档——所以这一步不是可有可无的形式检查。

八、规模化建议与代码质量约定

docs/adding-docs.md 最后两段给出两条重要约定:

  1. 大文档优先本地抓取:如果文档有数百页以上且可下载,尽量用 FileScraper 在本地抓取(例如利用 download_and_extract 拉取官方压缩包)。这会显著加快开发迭代速度,也避免对源站造成过大压力。Scraper 与本地环境耦合不是问题,只需在 Pull Request 中说明其工作方式即可;
  2. 写足注释:尽可能用注释记录 Scraper 与 Filter 的行为——为什么忽略某些 URL、为什么移除某些 HTML 标记、元数据为什么这样取。这会让后续的文档更新成本低得多。

此外提交前请阅读仓库的 contributing 规范,并在 thor docs:list 中确认新 Scraper 已被列出(步骤 3)。该命令遍历 Docs.all 以分页形式输出所有文档名与版本(name@version 格式),是接入完成与否的第一道验收。

九、接入流程自检清单

把 13 个步骤浓缩为提交前 checklist:

  1. [ ] lib/docs/scrapers/my_doc.rb 存在,类名为文件名 PascalCase,thor docs:list 能看到它;
  2. [ ] name / slug / type / release / base_url(或 dir)已设置,多版本场景用 version 块;
  3. [ ] lib/docs/filters/my_doc/ 下至少实现 clean_html.rbentries.rb,并已 html_filters.push 'my_doc/entries', 'my_doc/clean_html'
  4. [ ] thor docs:page my_doc /some/path 单页输出正常,thor docs:generate my_doc --force 全量生成成功;
  5. [ ] 应用内启用该文档后,侧边栏 entries、页面渲染、链接跳转均正常;
  6. [ ] 样式(_[type].scss)与页面视图(assets/javascripts/views/pages/)就绪,simple 类型则无需额外 SCSS;
  7. [ ] public/icons/docs/my_doc/ 提供 16x16 与 32x32 图标;
  8. [ ] options[:attribution] 已填写版权与许可证;
  9. [ ] thor updates:check my_doc 返回正确的最新版本;
  10. [ ] 代码注释解释了所有"特殊处理"(跳过的 URL、移除的标记、entry 推断规则)。

完成上述各项后,这份新文档就按 DevDocs 的既有架构完整接入:抓取、过滤、索引、缓存、样式、图标、版本监控六个环节全部就位,后续维护者可依据你的注释以较低成本完成下一轮更新。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384