用声明式 Rust 选择 HTML 组件
有了 scraper_query,你不需要学习另一个语言(CSS)才能清理 HTML
作为LLM 工程师和前端新手,我不是很懂 CSS,但我有时候确实要爬一些网页,解析、转换变成我的智能体的知识库。所以,我就想用,能不能用 Rust 来搞定这个?毕竟 Rust 是我最喜欢的语言。
我在网上简单查了一下,找到了 scraper 这个库。它可以用来解析 HTML 文件,还可以用 CSS 选择器来选择 HTML 组件。看起来很简单,但是······什么是 CSS 选择器?我就只是想删一些组件,比如 <meta>、<script> 和带有 promotion 类的 <div>。
“冷”知识:
在 HTML 中,一个有两个类 a 和 b 的组件(比如 <div>)写作 <div class="a b"></div>。但是在 CSS 中怎么选择这个组件呢?要写 div.a.b!我这样习惯了面向对象语言,这种语法看起来有点奇怪。不过算了,规则就是规则,我可以自适应。
那在 CSS 里怎么选一个类名是 a 或 b 的 <div> 呢?它写作 div.a, div.b。GitHub Copilot 在补全上一句话的时候甚至给了我很多个别的选项!
我实际仔细看我的 HTML 文件的时候,就没这么简单了。我要选择并且删除这些组件:
- 类名是
promotion或ads的<div> - 类名是
search的<text> - 类名不是
link-to-documentation的<a> - 类名是
misc的<p>,除非它同时也是documentation类 - 所有是
[li, ol, ul ...]其中之一的组件 - …….. 这个列表有点长,我就不全列出来了
那我要怎么写 CSS 选择器来描述我想要的内容?而且我想用编程的方式动态更新这些筛选条件,那我就不得不操作字符串,但是这本质上是写得很混乱的元编程。 虽然但是,我确实也这样做过了!但是这种做法,在一个编程语言里,用字符串嵌入另一个 DSL(领域特定语言),写程序就会很痛苦。
我确实也知道很多库都是用 CSS 来选择 HTML 组件的。存在即合理。而且我知道像 dom_query 这样的库很有用。 不过我真的想先考虑易用性和简单性,而不是说性能或者单纯想学 CSS。
所以,我们先不管 CSS。已知的是:我们有一些 HTML 组件,它们有一些属性,比如名称、类名和 ID。我们想基于一些逻辑选择其中的一些。看起来我们只是需要一个查询引擎。或者更准确地说,一个 SAT 求解器(这是个冷笑话哈哈)
声明式语言的思考方式
我们很熟悉布尔值了。如果变量命名得当,逻辑表达式基本上就像读句子一样。比如说,component.is_div() && component.is_class("a") 看起来就很容易读出来。 但是这种面向对象风格的 API 跟 scraper 的 API 设计不太搭,因为用 scraper 的时候,我们用 Node 和 NodeId 来索引它的树结构,并且操作 HTML 文件和组件。
不过,我们可以让查询语句更加靠近声明式的风格。与其像 CSS 那样写 div.a.b,我们可以写 Tag::Div & class("a") & class("b")。这个逻辑语句几乎可以读出来了。
这就是 scraper_query 可以做到的。下面是一个完整的示例
use scraper::Html;
use scraper_query::*; // 使用 `HTMLIndex`、`Tag`、`class`、`id`
use markup5ever::interface::tree_builder::TreeSink;
let mut document = Html::parse_document(HTML);
let index = HTMLIndex::new(&document);
let query = Tag::Div & class("a") & class("b"); // 查找所有带有类 "a" 和 "b" 的 div
let node_ids = index.query(query);
// 简单的操作
for id in node_ids {
document.remove_from_parent(&id);
}
现在我们可以使用 &、|、! 当然还有 () 来组合查询语句。如果还想要更明确的表达,我们可以写成 Tag::Div.and(class("a").and(class("b"))) 这样。
有了
&、|、!,这个伪装在 Rust 逻辑表达式里的迷你 DSL 是图灵完备的!
实现原理
scraper_query 很简单,只有几百行代码。最重的部分,也就是查询引擎是 Polars 实现的。有了 Polars,只要:
- 遍历所有 HTML 节点,建一个Polars DataFrame
- 列分别是
NODE_ID、CLASS、ID(即 HTML 组件中的id)和TAG(即组件的名称) - 每个组件在表格中有一行记录
- 这个表封装在
HTMLIndex里面
- 列分别是
- 用像逻辑表达式这样的表达式查这个表,比如
Tag::Div & class("a") & class("b")- 这些表达式在
std::ops::{BitAnd, BitOr, Not}的实现里被转换为 Polars 的表达式 - 关于 Polars 表达式的详细信息,可以看
polars_plan::dsl
- 这些表达式在
例如:
<div class="a b">
<div class="c d">
<p> Hello </p>
</div>
<div class="e" id="f">
<p> World </p>
</div>
</div>
这个 HTML 文件,在解析和构建完 HTMLIndex 之后,我们会有这样的 Polars 数据表:
| NODE_ID | TAG | CLASS | ID |
|---|---|---|---|
| 0 | div | [ “a”, “b” ] | |
| 1 | div | [ “c”, “d” ] | |
| 2 | p | [] | |
| 3 | div | [ “e” ] | “f” |
| 4 | p | [] |
如果我们用 class("a") | class("c") 这个语句来查询组件,本质上生成的 Polars 表达式是:
use polars::prelude::Expr;
let class_a_expr: Expr = col("CLASS").list().contains(lit("a"));
let class_c_expr: Expr = col("CLASS").list().contains(lit("c"));
let query_expr: Expr = class_a_expr.or(class_c_expr); // 这将用于过滤数据框
简单明了,但是能用。
之后的改进
一个是,Polars 这个依赖看起来可能太重了。毕竟它是一个的完整工具集,用来操作数据表。可能我可以只拿它的查询引擎来用。或者,专门为这个用例写一个小 SAT 求解器。
另一个是,我现在检查和清理 HTML 文件的过程不直观,也不能交互。可能我会写一个网页,基于像 Tag::Div & class("a") & class("b") 这样的语句来选择组件,然后即时渲染处理完的 HTML 文件。
元数据
版本:0.0.1
日期:2024.11.04
许可证:CC BY-SA 4.0