☰
devdocs 过滤器体系完全指南:从 HTML::Pipeline 到自定义 Filter 的编写实践
2026/10/10 5:32:18 网站建设 项目流程
  • 文档
  • 开发工具
  • 后端
  • 前端

【免费下载链接】devdocs

API Documentation Browser

项目地址:https://gitcode.com/GitHub_Trending/de/devdocs
点击查看免费下载

导读

本指南围绕 devdocs 项目的 docs/filter-reference.md 展开,系统讲解项目核心的过滤器(Filter)机制:从基于 HTML::Pipeline 的管道模型、HTML 过滤器与文本过滤器的分工,到Docs::Filter基类提供的全部实例方法、13 个内置核心过滤器,再到自定义CleanHtmlFilter与EntriesFilter的完整编写规范。读完本文,你将掌握如何为任意文档站点编写一个可用的 scraper 过滤器,并理解页面元数据(entries)是如何被提取、索引并最终呈现在 devdocs 侧边栏中的。


一、Overview:过滤器与管道的运行模型

在 devdocs 中,过滤器是处理文档页面的最小单元。它们基于 HTML::Pipeline 库实现:每个过滤器接收一段 HTML 字符串或一个 Nokogiri 节点对象作为输入,执行修改和/或信息提取,然后输出结果。多个过滤器首尾相接形成一条管道(pipeline),前一个过滤器的输出恰好是后一个过滤器的输入。每一份文档页面在写入本地文件系统之前,都必须完整经过这条管道。

从 lib/docs/core/scraper.rb 的源码可以看到管道是如何组装的:

def pipeline @pipeline ||= ::HTML::Pipeline.new(self.class.filters).tap do |pipeline| pipeline.instrumentation_service = Docs end end

而self.class.filters正是由两类过滤器堆栈拼接而成(html_filters + text_filters),对应 lib/docs/core/filter_stack.rb 的实现。

1.1 两类过滤器:HTML 过滤器与文本过滤器

过滤器按操作对象分为两类,这是 devdocs 管道的核心设计约束:

  • HTML 过滤器:操作 Nokogiri 节点对象(doc及其相关方法)。它们必须先于文本过滤器执行。
  • 文本过滤器:操作文档的字符串表示(html)。它们绝不能在 Nokogiri 节点对象上做手脚。

⚠️硬性规则:HTML 过滤器禁止修改 HTML 字符串,文本过滤器禁止修改 Nokogiri 节点对象。call方法的返回值必须与过滤器类型一致——HTML 过滤器返回doc,文本过滤器返回html。

这样分工的唯一原因是避免文档被反复解析:既然 HTML 过滤器已经完成了解析并提取了节点对象,后续对字符串的清洗就不必再次 Nokogiri 化。这一点在 docs/scraper-reference.md 的 "Filter stacks" 一节中有同样明确的说明。

1.2 默认过滤器堆栈

lib/docs/core/scraper.rb 中定义了所有 scraper 共享的默认堆栈:

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 过滤器堆栈依次为:apply_base_url→container→clean_html→normalize_urls→internal_urls→normalize_paths→parse_cf_email;文本过滤器堆栈为:images→inner_html→clean_text→attribution。子类 scraper 通过html_filters.push/text_filters.push追加自己的自定义过滤器(如async的async/entries、async/clean_html)。

FilterStack 的堆栈操作方法(见 lib/docs/core/filter_stack.rb):

push(*names) # 在栈尾追加一个或多个过滤器 insert_before(index, *names) # 在某个过滤器之前插入(index 可以是名称) insert_after(index, *names) # 在某个过滤器之后插入(index 可以是名称) replace(index, name) # 用另一个过滤器替换(index 可以是名称)

1.3 最小的过滤器实现

所有过滤器都继承自Docs::Filter(lib/docs/core/filter.rb),并必须实现call方法。一个最基本的 HTML 过滤器长这样:

module Docs class CustomFilter < Filter def call doc end end end

1.4 命名与文件位置约定

自定义过滤器存放在 lib/docs/filters 目录下,类名必须是文件名的 CamelCase 形式。例如文件lib/docs/filters/async/clean_html.rb中的类名是Async::CleanHtmlFilter;而 lib/docs/core/filter_stack.rb 中filter_const的常量解析逻辑(Docs.const_get "#{name}_filter".camelize)也印证了这一点——堆栈中写的'async/clean_html'最终会被转成Async::CleanHtmlFilter类。


二、Instance methods:Filter 基类实例方法全解

lib/docs/core/filter.rb 是Docs::Filter的完整实现,它继承了HTML::Pipeline::Filter。以下是文档列出的全部实例方法及其源码级说明。

2.1 核心数据访问

方法类型说明
docNokogiri::XML::Node容器元素的 Nokogiri 表示,可用 Nokogiri 的节点 API 进行操作
htmlString容器元素的字符串表示
contextHash(frozen)即 scraper 的options,外加:base_url、:root_url、:root_page、:url等额外键
resultHash存储页面元数据并向 scraper 回传信息的结果容器

context的构成可从 lib/docs/core/scraper.rb 的options方法还原:它由self.class.options深度拷贝后合并:base_url、:root_url、:root_path、:initial_paths、:version、:release等键,最终被freeze冻结。此外pipeline_context(lib/docs/core/scraper.rb)还会在每次响应处理时把url: response.url并入其中,即context[:url]是当前页面的真实 URL。

result的可用键:

  • :path—— 页面归一化后的路径(不含扩展名,根页为index)
  • :store_path—— 页面实际存储路径,等于:path加上.html后缀
  • :internal_urls—— 页面内发现的、去重后的内部 URL 列表
  • :entries—— 需要加入索引的Entry对象数组

result键的写入方是核心过滤器::subpath由InternalUrlsFilter写入(lib/docs/filters/core/internal_urls.rb),:path与:store_path由NormalizePathsFilter写入(lib/docs/filters/core/normalize_paths.rb),:entries由EntriesFilter写入(lib/docs/filters/core/entries.rb)。

2.2 查询与 URL 快捷方法

  • css、at_css、xpath、at_xpath:doc.css、doc.xpath等的简写,实现于 lib/docs/core/filter.rb,底层直接委托给 Nokogiri 的节点 API。
  • base_url、current_url、root_url:分别为context[:base_url]、context[:url]、context[:root_url]的简写,返回Docs::URL对象。Docs::URL继承自URI::Generic(见 lib/docs/core/url.rb),额外提供了subpath_to、relative_path_to等路径计算能力。
  • root_path:context[:root_path]的简写。
  • version、release、links:基类中还提供了这三个方法(lib/docs/core/filter.rb),分别对应context[:version]、context[:release]、context[:links],文档中未提及但实际可用。

2.3 路径计算:subpath 与 slug

  • subpath:当前 URL 相对 base URL 的子路径。实现于 lib/docs/core/filter.rb,通过base_url.subpath_to(current_url, ignore_case: true)计算。底层算法在 lib/docs/core/url.rb:先比较 origin(scheme + host + port),再取 dest 在 base 之后的部分。
    • 示例:base_url为example.com/docs、current_url为example.com/docs/file?raw时,返回/file。
  • slug:subpath去掉开头的/和末尾的.html扩展名。实现为subpath.sub(/\A\//, '').remove(/\.html\z/)(lib/docs/core/filter.rb)。
    • 示例:subpath为/dir/file.html时,返回dir/file。

2.4 页面身份判断

  • root_page?:当前页面是否为根页面。判定条件是subpath.blank? || subpath == '/' || subpath == root_path(lib/docs/core/filter.rb)。
  • initial_page?:当前页面是否为根页面,或subpath命中 scraper 的initial_paths之一(lib/docs/core/filter.rb)。

2.5 URL 类型判断与路径清洗辅助方法

基类还提供了一组实用的工具方法(文档未逐一列举,但在核心过滤器中被广泛使用):

fragment_url_string?(str) # 是否为 #fragment data_url_string?(str) # 是否为 data: URI relative_url_string?(str) # 是否为相对 URL absolute_url_string?(str) # 是否为绝对 URL(含 scheme) clean_path(path) # 将 !;: 替换为 -,将 + 替换为 _plus_ parse_html(html) # 解析 HTML;若非测试环境重复解析会发出警告

例如InternalUrlsFilter正是用absolute_url_string?筛选候选内部链接,NormalizePathsFilter用relative_url_string?判断需要归一化的 href。此外clean_path在decode_and_clean_paths选项开启时会被调用。


三、Core filters:13 个内置核心过滤器逐一解析

以下每个核心过滤器都位于 lib/docs/filters/core 目录,其源码可在仓库中直接查阅。

3.1ContainerFilter(container.rb)

职责:更换文档的根节点,移除容器以外的所有内容。

实现要点:它读取context[:container]——既可以是一个 CSS 选择器字符串,也可以是一个Proc(执行后返回选择器)。找到容器后,doc.at_css(container)作为新的根;找不到则抛出ContainerNotFound异常。若未配置容器,则退回body(对完整 HTML 文档)或原doc(对片段文档)。

3.2CleanHtmlFilter(clean_html.rb)

职责:移除 HTML 注释、<script>、<style>、<link>等非文档内容,并把普通文本中的连续空白折叠为单个空格。

注意两点实现细节:空白折叠会跳过<pre>、<code>以及带prismclass 的<div>内的文本(避免破坏代码与语法高亮);content.valid_encoding?检查保证了非法编码文本不会被处理。每个 scraper 还必须自定义一个CleanHtmlFilter(见下文第四节),用于处理该站点特有的脏标记。

3.3NormalizeUrlsFilter(normalize_urls.rb)

职责:把a[href]、img[src]、iframe[src]上的所有 URL 替换为完整限定的绝对 URL(跳过#fragment和data:URI)。

源码中的处理链:剥离空白 → 空格转%20→ 可选:fix_urls_before_parse回调 → 解析为绝对 URL → 循环应用:redirections(路径大小写不敏感重定向)、:replace_paths、:replace_urls、:fix_urls等修复规则 → 输出。若 URL 非法(URI::InvalidURIError),非 iframe 的链接被替换为#。

3.4InternalUrlsFilter(internal_urls.rb)

职责:识别内部 URL(即后续需要抓取的页面),并把它们替换为非限定、相对的链接;同时把去重后的内部 URL 列表写入result[:internal_urls],供 scraper 递归爬取。

关键机制:

  • to_internal_url要求 URL 是绝对 URL、且subpath_to(url)不为空(即同源且位于 base 之下),再经过normalize_subpath(受:trailing_slash选项控制)与skip_subpath?过滤。
  • skip_subpath?综合:only/:only_patterns/:skip/:skip_patterns选项决定哪些子路径不视为内部链接。
  • internal_path_to通过effective_url.relative_path_to(url)(lib/docs/core/url.rb)计算从当前页面到目标页面的相对路径,并保留 query 与 fragment;指向根 URL 的链接会被归一化为索引页路径。
  • skip_links?与follow_links?分别受:skip_links与:follow_links选项控制(两者都支持Proc形式的动态判断)。

3.5NormalizePathsFilter(normalize_paths.rb)

职责:让内部路径保持一致(例如统一以.html结尾),并写入result[:path]与result[:store_path]。

路径归一化规则(normalize_path,normalize_paths.rb):整体转小写;开启:decode_and_clean_paths时先做 URL 解码再用clean_path清洗;.变成index;以/结尾的路径追加index;.html/.md后缀被去掉。根页面路径固定为index。该过滤器还会把文档中仍为相对路径的href/xlink:href逐一归一化。

3.6CleanLocalUrlsFilter(clean_local_urls.rb)

职责:仅当base_url.host == 'localhost'(即FileScraper抓取本地文件)时生效——移除指向http://localhost的图片与 iframe,并把指向 localhost 的<a>降级为<span>(去掉 href),从而防止离线文档出现指向本机的死链接。

3.7InnerHtmlFilter(inner_html.rb)

职责:HTML 过滤器阶段与文本过滤器阶段的转换器——把 Nokogiri 节点转回字符串(doc.inner_html),供后续文本过滤器使用。若字符串非法编码,会先转成 UTF-16(丢弃非法字节)再转回 UTF-8,保证下游拿到的是合法字符串。

3.8CleanTextFilter(clean_text.rb)

职责:删除“空节点”——用正则EMPTY_NODES_RGX反复匹配并删除内容仅为空白的标签对(<td>、<th>、iframe、mspace及若干 SVG 元素被排除,避免误删表格单元格与图形)。可通过context[:clean_text] == false关闭。该过滤器会调用html.strip!并返回字符串,是一个典型的文本过滤器。

3.9AttributionFilter(attribution.rb)

职责:把版权/许可信息(context[:attribution],可以是字符串或Proc)以_attribution块的形式追加到文档末尾,并附上指向原始页面的链接(本地抓取localhost时不生成链接)。正是它保证了 devdocs 中的文档都保留原作者署名与出处。

3.10ImagesFilter(images.rb)

职责:下载页面图片并内联为 data URI,同时做体积优化与格式重编码——PNG/GIF 转为无损 WebP,JPEG 转为有损 WebP(q=80、开启-sharp_yuv保持截图与示意图边缘锐利)。关键参数:DEFAULT_MAX_SIZE = 120_000(120 KB),可通过:max_image_size覆盖;context[:download_images] == false可整体关闭;context[:optimize_images] == false可跳过 image_optim 优化。转换结果比原图大时(webp.bytesize < data.bytesize)会自动放弃转换,因此不会出现“越优化越大”的情况。所有请求经Request.run异步完成,损坏/非图片/超限资源会以broken.image、invalid.image、too_big.image等事件上报。

3.11TitleFilter(title.rb)

职责:在文档开头插入一个<h1>标题节点,默认关闭。标题来源优先级:根页面的:root_title→:title(字符串或Proc)→ 默认取result[:entries].first.name,即首个 entry 的名称。开启后适合给原本无标题的抓取页面补充标题层级。

3.12EntriesFilter(entries.rb)

职责:抽象过滤器,用于提取页面元数据,是所有自定义 entries 过滤器的基类。其默认实现只做一件事:result[:entries] = entries,随后把默认 entry 与additional_entries合并为Entry对象列表(详细机制见下节)。它是一个HTML 过滤器,必须加入html_filters堆栈。

补充:core 目录下还有一个未在文档中列出的 parse_cf_email.rb,它作为默认 HTML 堆栈的一环处理 Cloudflare 邮箱混淆链接,与上述过滤器一起构成了完整的默认管道。


四、Custom filters:自定义过滤器的编写规范

每个 scraper 可以拥有任意数量的自定义过滤器,但至少必须实现下面两个:CleanHtmlFilter与EntriesFilter。它们位于 lib/docs/filters 目录下,类名必须是文件名的 CamelCase 形式。

4.1CleanHtmlFilter:清洗页面标记

CleanHtmlFilter的任务是在必要处清洗 HTML 标记,移除一切多余或非必要的元素,最终只保留核心文档内容。Nokogiri 提供了大量 jQuery 风格的查询与修改方法(css、at_css、remove、name=、content=等)让这项工作变得简单。

文档给出的覆盖最常见场景的完整示例:

module Docs class MyScraper class CleanHtmlFilter < Filter def call css('hr').remove css('#changelog').remove if root_page? # 把空 <a> 上的 id 转移到 <h3> 上 css('h3').each do |node| node['id'] = node.at_css('a')['id'] end # 把伪表头 <td class="header"> 改成真正的 <th> css('td.header').each do |node| node.name = 'th' end # 去除代码高亮(把 pre 内的 HTML 展开为纯文本) css('pre').each do |node| node.content = node.content end doc end end end end

编写要点(文档明确强调):

  1. 空元素无需手动删除——管线后续的核心CleanTextFilter会自动清理空节点。
  2. 目标是得到干净页面,但修改次数应尽量少,以便维护。页面样式归一化优先用自定义 CSS 完成;隐藏内容永远通过删除标记实现,而非 CSS。
  3. 尽量为过滤器的每个行为(尤其是只影响部分页面的修改)写注释,这会显著降低后续文档升级时的维护成本。

仓库中的真实案例 lib/docs/filters/async/clean_html.rb 展示了更多技巧:用node.before(node.children).remove把section/header/article等容器“解包”成子节点、用正则node.name.sub(/\d/) { |i| i.to_i - 1 }把 h3–h5 整体降级为 h2–h4、把<dd><ul>列表合并成纯文本、并为<pre>注入data-language属性。

4.2EntriesFilter:提取页面元数据

EntriesFilter负责提取页面的元数据,由一组entries表示,每个 entry 包含名称(name)、类型(type)和路径(path)三个属性。底层使用两个模型(均在 lib/docs/core/models 目录):

  • Entry(name, type, path):索引中的最小条目。构造时校验 name/path/type 均非空(根条目除外,其path == 'index');as_json输出{name, path, type}供前端索引使用。
  • Type(name, slug, count):条目的类型分组。slug由name.parameterize生成,as_json会合并 slug。

每个 scraper 必须通过继承Docs::EntriesFilter实现自己的 entries 过滤器。基类已实现call方法(lib/docs/filters/core/entries.rb):合并默认 entry 与 additional entries,逐个构造Entry对象后写入result[:entries]。子类只需覆盖以下四个方法:

可覆盖方法类型说明默认值
get_nameString默认 entry 的名称(即页面名),通常从slug或 HTML 标记中推断slug的变体:下划线换成空格、斜杠换成点
get_typeString默认 entry 的类型。无类型的 entry 可以被搜索到,但不会出现在应用侧边栏(除非没有其他带类型的 entry)nil
include_default_entry?Boolean是否包含默认 entry。用于:页面只有附加 entries 而无自身名称/类型时;或把整页从索引中移除(此时页面不会被写入本地文件系统,指向它的链接会断裂——这也是保持:skip/:skip_patterns选项体积可控、或处理无法从别处到达的链接页面的手段)true
additional_entriesArray附加 entries 列表[]

additional_entries的数组格式:每个元素是三个属性的数组[name, fragment, type]——名称、锚点标识、类型。锚点标识指向 HTML 元素的id(通常是标题),它会与页面路径拼接成 entry 的完整路径;缺省或为nil时用页面路径;类型缺省或为nil时用默认 type。例如:

[ ['One'], ['Two', 'id'], ['Three', nil, 'type'] ]

表示三个附加 entries:名 "One"(默认路径、默认类型)、名 "Two"(路径带#id锚点、默认类型)、名 "Three"(默认路径、类型为 "type")。该列表通常通过遍历标记构造,特定页面的例外也可以硬编码。

以下访问器已实现、但禁止覆盖(它们提供了 memoized 版本,避免重复计算):

  • name:get_name的 memoized 结果(根页面为nil)。源码实现见 lib/docs/filters/core/entries.rb,注意root_page?时直接返回nil而不调用get_name。
  • type:get_type的 memoized 结果(根页面为nil)。

关键注意事项(文档逐条强调):

  1. name 与 type 的首尾空白会被自动去除(Entry#name=/#type=内部调用strip)。
  2. name 在整个文档库中必须唯一,且尽可能短(理想小于 30 字符)。方法尽量用()后缀与属性区分;实例方法与类方法尽量遵循Class#method或object.method约定。
  3. 可以在get_type里调用name,或在get_name里调用type,但二者同时调用会栈溢出(只能单向推导)。不要直接调用get_name/get_type,因为它们的值没有被 memoized。
  4. 根页面没有 name 和 type(均为nil),此时get_name、get_type不会被调用,但additional_entries会。
  5. Docs::EntriesFilter是HTML 过滤器,必须加入 scraper 的html_filters堆栈。
  6. 尽量为代码(尤其是特殊分支)写注释,便于后续更新维护。

完整示例(文档提供):

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

这个例子几乎覆盖了所有典型手法:get_name从<h1>取标题并按type追加 "event" 后缀、按class追加();get_type从slug的路径段推断类型(两级路径视为"对象/方法",单级归为 "Miscellaneous");additional_entries遍历所有<h2>生成带锚点的条目;include_default_entry?在页面含.obsolete元素时排除默认 entry。

仓库中 lib/docs/filters/async/entries.rb 是一个真实范例:它遍历.nav.methods li导航节点,遇到toc-header就切换当前类型(type),其余节点则把名称、id(从a[href]去掉#)、当前类型组合成三元组加入 entries——完整演示了"类型分组 + 锚点条目"的常见模式。


五、把过滤器接到 scraper 上:端到端流程

要理解过滤器如何工作,还需要看它在 scraper 中的完整调用链(lib/docs/core/scraper.rb):

  1. 爬取:build_pages从initial_urls(根 URL +initial_paths)出发,递归抓取,每次发现新的internal_urls就加入队列继续(scraper.rb)。
  2. 解析:process_response用Parser把响应体解析为 HTML 片段与标题,构建context = options.merge(url: response.url)(scraper.rb)。
  3. 过管道:pipeline.call(html, context, data)依次执行 HTML 过滤器堆栈 → 文本过滤器堆栈,所有过滤器共享同一个context,各自向data(即result)写入键值。
  4. 产出:handle_response返回包含:path、:store_path、:entries、:internal_urls等键的数据,最终页面写入本地文件系统,entries 汇入 JSON 索引。

值得注意的是options在进入管道前会被freeze(scraper.rb),因此过滤器只能读取context,不能修改——这保证了同一 scraper 处理多个页面时的确定性。


六、编写过滤器的最佳实践清单

综合 docs/filter-reference.md 与源码实现,编写高质量的过滤器应遵循以下原则:

  1. 保持最小修改:能用 CSS 归一化的样式问题不要用过滤器处理;需要隐藏的内容一律通过移除标记完成。
  2. 严格遵守过滤器类型边界:HTML 过滤器只动doc并返回doc;文本过滤器只动html并返回html。这是防止反复解析的关键,也是文档强调的核心约束。
  3. entries 命名规范化:name 全库唯一、尽量短(<30 字符)、用()/Class#method约定区分方法、实例方法与类方法。
  4. 善用 memoized 访问器:在get_type中用name、或在get_name中用type做单向推导,但切忌双向互推导致栈溢出。
  5. 充分注释:尤其对只影响部分页面的修改和硬编码例外,让后续文档更新者能快速理解意图。
  6. 验证真实调用链:新过滤器加入堆栈前,先确认它在默认管道(apply_base_url→container→clean_html→normalize_urls→internal_urls→normalize_paths→parse_cf_email→images→inner_html→clean_text→attribution)中的位置是否合理——例如EntriesFilter必须在internal_urls之后(依赖result[:path])、ImagesFilter必须在所有 HTML 过滤器之后运行。

至此,从 HTML::Pipeline 的管道模型、Filter 基类的每个实例方法、13 个核心过滤器的内部实现,到自定义CleanHtmlFilter/EntriesFilter的完整编写范式,你已经掌握了 devdocs 文档抓取体系中最核心的过滤器机制。结合 docs/scraper-reference.md 中的配置项与堆栈操作说明,即可为任意目标文档站点编写出自己的 scraper。

  • 文档
  • 开发工具
  • 后端
  • 前端

【免费下载链接】devdocs

API Documentation Browser

项目地址:https://gitcode.com/GitHub_Trending/de/devdocs
点击查看免费下载

相关推荐

上一篇:如何用FontForge征服AR/VR字体设计的终极挑战:5个关键技巧
下一篇:Statping高可用架构:5大设计策略避免监控服务自身单点故障

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询