Skip to content

Commit 3dfd3f6

Browse files
committed
Add new post for 「HTML Selector」
1 parent 7171870 commit 3dfd3f6

File tree

6 files changed

+249
-3
lines changed

6 files changed

+249
-3
lines changed

content/zh/post/2025/reinvent_file_backup.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
+++
22
title = "重新造轮子系列(二):文件备份"
33
date = 2025-03-02T11:57:00-08:00
4-
lastmod = 2025-03-02T21:24:24-08:00
4+
lastmod = 2025-03-03T17:58:39-08:00
55
tags = ["reinvent"]
66
categories = ["ReInvent: 重新造轮子系列"]
77
draft = false
88
toc = true
99
+++
1010

11+
项目 GitHub 地址: [File Backup](https://github.com/ramsayleung/reinvent/tree/master/file_backup)
12+
13+
1114
## <span class="section-num">1</span> 前言 {#前言}
1215

1316
既然我们已经有[单元测试]({{< relref "reinvent_unit_test" >}})框架来测试软件了,我们肯定不想已经写好的代码丢失掉。
@@ -207,6 +210,8 @@ drwxr-xr-x@ 11 ramsayleung wheel 352 2 Mar 21:02 .
207210

208211
如果想要实现一个更健壮易用的备份文件,可以参考下关于这 [rsync 系列的文章](https://michael.stapelberg.ch/posts/2022-06-18-rsync-overview/) , `rsync` 是Linux 上非常流行的增量备份的文件,不仅可以备份本地文件,更可以把文件备份把远程服务器,非常强大。
209212

213+
[回到本系列的目录]({{< relref "reinvent_project" >}})
214+
210215

211216
## <span class="section-num">5</span> 参考 {#参考}
212217

content/zh/post/2025/reinvent_project.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
+++
22
title = "ReInvent: 重新造轮子系列(序言)"
33
date = 2025-02-16T22:10:00-08:00
4-
lastmod = 2025-03-02T21:34:27-08:00
4+
lastmod = 2025-03-15T14:31:09-07:00
55
tags = ["reinvent"]
66
categories = ["ReInvent: 重新造轮子系列"]
77
draft = false
@@ -35,3 +35,4 @@ GitHub: <https://github.com/ramsayleung/reinvent>
3535

3636
1. [单元测试框架]({{< relref "reinvent_unit_test" >}})
3737
2. [文件备份]({{< relref "reinvent_file_backup" >}})
38+
3. [HTML Selector]({{< relref "reinvent_selector" >}})
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
+++
2+
title = "重新造轮子系列(三): HTML Selector"
3+
date = 2025-03-15T10:53:00-07:00
4+
lastmod = 2025-03-15T14:31:01-07:00
5+
tags = ["reinvent"]
6+
categories = ["ReInvent: 重新造轮子系列"]
7+
draft = false
8+
toc = true
9+
+++
10+
11+
项目 GitHub 地址: [Selector](https://github.com/ramsayleung/reinvent/tree/master/html_selector)&nbsp;[^fn:1]
12+
13+
14+
## <span class="section-num">1</span> 前言 {#前言}
15+
16+
以前写爬虫的时候,必不可少的一个工具就是 HTML selector, 就是用于匹配指定的 HTML 标签。
17+
18+
毕竟爬虫的本质就是找出需要的标签里面的内容,然后解析出来。
19+
20+
而 selector 主要有两个流派,一个是 [CSS selector](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors)&nbsp;[^fn:2], 另外一个是 [XPath selector](https://developer.mozilla.org/en-US/docs/Web/XML/XPath/Guides)&nbsp;[^fn:3] ,本质都是通过某种语法来匹配指定的标签,区别只是一个用的是 CSS 的语法,另外一个是 XML 语法.
21+
22+
这次我们就来写个基于 CSS 语法的 Selector, 来深入理解下 HTML 的 DOM 模型
23+
24+
25+
## <span class="section-num">2</span> DOM {#dom}
26+
27+
写过前端的朋友应该都知道,前端代码主要是由所谓的三剑客组成的:HTML + CSS + JavaScript, 其中的三剑客各司其职,相互配合。
28+
29+
HTML 负责内容展示, CSS 负责布局和样式,而 JavaScript 是负责用户与页面之间的动态交互。
30+
31+
而对于如下的 HTML 代码:
32+
33+
```html
34+
<html>
35+
<head>
36+
<title>Example</title>
37+
</head>
38+
<body>
39+
<h1>Title</h1>
40+
<blockquote id="important">
41+
<p>Opening</p>
42+
<p>Explanation</p>
43+
<p class="highlight">Warning</p>
44+
</blockquote>
45+
<p>Closing</p>
46+
</body>
47+
</html>
48+
```
49+
50+
浏览器会将其进行解析,并生成名为 [Document Object Model](//developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model)(DOM) 的数据结构,听着好像很玄乎,但本质就是一棵多叉树 (Multiway Tree):
51+
52+
{{< figure src="/ox-hugo/reinvent_dom_tree.jpg" >}}
53+
54+
知道 `DOM` 是多叉树, 我们就可以写出简化版本 `DOM` 的数据结构了:
55+
56+
```javascript
57+
export interface DomNode {
58+
type: string;
59+
name?: string;
60+
attribs?: {
61+
id?: string;
62+
class?: string;
63+
[key: string]: string | undefined;
64+
};
65+
children?: DomNode[];
66+
data?: string;
67+
parent?: DomNode;
68+
}
69+
```
70+
71+
一个节点可能有多个子节点 `(children?)` 或者一个父节点 `(parent?)`, 也可能都没有,所以标记成 `?(optional)`;
72+
73+
一个节点可能有多个属性 `attribs`.
74+
75+
而节点的=type= 可能是 `tag`, `text`, `comment`, `script`, `style`, 而对于 `text``comment` 类型的节点, `name` 也是为空的.
76+
77+
这个 `DOM` 结构只是我们的简化版本,完整的 DOM 还有很多的属性和回调函数,详情可以查看文档: [Document Object Model (DOM)](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model)
78+
79+
80+
## <span class="section-num">3</span> BFS vs DFS {#bfs-vs-dfs}
81+
82+
理解到 `DOM` 的本质是个多叉树之后,我们很快就能意识到, `selector` 本质也就是遍历多叉树,找到符合要求的所有节点, 比如按 `tag` 名来匹配,按 `id` 来匹配,按 `class` 来匹配等等。
83+
84+
而用于遍历多叉树的常用算法就是广度优先搜索(Breadth First Search, BFS)和深度优先搜索(Depth First Search, DFS)
85+
86+
{{< figure src="/ox-hugo/reinvent_dfs_vs_bfs.jpg" >}}
87+
88+
通常来说,BFS 和 DFS 都能完成多叉树遍历,时间复杂度也是相同的,BFS通常使用一个 `queue` 来记录遍历待节点,所以会使用更多的内存,但是能找到最短路径;而 DFS 通常使用递归,如果遇到个循环图,就会 StackOverflow,无法找到结果。
89+
90+
因为我们明确知道 DOM 是个多叉树(有向无环图),所以我们就使用 DFS 来遍历查找。
91+
92+
93+
## <span class="section-num">4</span> Strategy 设计模式 {#strategy-设计模式}
94+
95+
分析好问题之后,我们的实现也差不多能出来了, 按 tag 名来匹配,无非是 `domNode.name === tagName`; 按 `class` 来匹配, 即 `domNode.attribs.class=== class`.
96+
97+
为了解耦和易于扩展,我们可以使用个策略设计模式([Strategy Design Pattern](https://refactoring.guru/design-patterns/strategy)&nbsp;[^fn:4]).
98+
99+
```js
100+
interface Selector {
101+
match(node: DomNode): boolean;
102+
}
103+
104+
const findByTagName = (tag: string): Selector => ({
105+
match: (node: DomNode): boolean => {
106+
return node.name.toLowerCase() === tag.toLowerCase()
107+
}
108+
});
109+
110+
const findById = (id: string): Selector => ({
111+
match: (node: DomNode): boolean => {
112+
return node.attribs.id === id;
113+
}
114+
})
115+
116+
const findByClass = (clazz: string): Selector => ({
117+
match: (node: DomNode): boolean => {
118+
return node.attribs.class === clazz;
119+
}
120+
});
121+
```
122+
123+
然后遍历节点的时候,只需要判断 `Selector` 是否符合要求,而具体的匹配条件则由 `selector` 决定:
124+
125+
```js
126+
const isMatch = (node: DomNode, selectors: Selector[]): boolean => {
127+
return selectors.every(selector => selector.match(node));
128+
}
129+
```
130+
131+
这样的话,要增加一个根据属性keyValue值的匹配条件也是非常容易的, 如 `div[align=center]`, 即匹配属性 `align` 和value 为 `center`:
132+
133+
```js
134+
const findByAttributes = (key: string, value: string): Selector => ({
135+
match: (node: DomNode): boolean => {
136+
return node.attribs[key] === value;
137+
}
138+
})
139+
```
140+
141+
142+
## <span class="section-num">5</span> 测试验证 {#测试验证}
143+
144+
DFS + Strategy design pattern 就实现了一个基础的 CSS Selector, 我们自然需要测试验证下是否正确:
145+
146+
```js
147+
describe('HTML selector testsuite', () => {
148+
const HTML = `<main>
149+
<p>text of first p</p>
150+
<p id="id-01">text of p#id-01</p>
151+
<p id="id-02">text of p#id-02</p>
152+
<p class="class-03">text of p.class-03</p>
153+
<div>
154+
<p>text of div / p</p>
155+
<p id="id-04">text of div / p#id-04</p>
156+
<p class="class-05">text of div / p.class-05</p>
157+
<p class="class-06">should not be found</p>
158+
</div>
159+
<div id="id-07">
160+
<p>text of div#id-07 / p</p>
161+
<p class="class-06">text of div#id-07 / p.class-06</p>
162+
</div>
163+
</main>`
164+
165+
it.each([
166+
['p', 'text of first p'],
167+
['p#id-01', 'text of p#id-01'],
168+
['p#id-02', 'text of p#id-02'],
169+
['p.class-03', 'text of p.class-03'],
170+
['div p', 'text of div / p'],
171+
['div p#id-04', 'text of div / p#id-04'],
172+
['div p.class-05', 'text of div / p.class-05'],
173+
['div#id-07 p', 'text of div#id-07 / p'],
174+
['div#id-07 p.class-06', 'text of div#id-07 / p.class-06']
175+
])('test select %s %s', async (selector, expected) => {
176+
const doc = htmlparser2.parseDOM(HTML)[0];
177+
const node = select(doc, selector);
178+
const actual = getText(node);
179+
expect(actual).toBe(expected);
180+
})
181+
})
182+
```
183+
184+
使用 Jest 框架编写了如上的单元测试用例, unit test 都通过了,完工.
185+
186+
值得一提的是,这种相同的验证逻辑, 但是输入多个不同的参数以验证不同case的做法,叫做 `Parameterized Test`
187+
188+
我在《[测试技能进阶系列](https://ramsayleung.github.io/zh/categories/%E6%B5%8B%E8%AF%95%E6%8A%80%E8%83%BD%E8%BF%9B%E9%98%B6/)》的第二篇也曾经介绍过: [Parameterized Tests](https://ramsayleung.github.io/zh/post/2024/%E6%B5%8B%E8%AF%95%E6%8A%80%E8%83%BD%E8%BF%9B%E9%98%B6%E4%BA%8C_parameterized_tests/)
189+
190+
191+
## <span class="section-num">6</span> 总结 {#总结}
192+
193+
这个简单的 CSS Selector 全部代码仅有 **103** 行, 但麻雀虽小,五脏俱全,功能还是齐备的:
194+
195+
```sh
196+
> tokei simple-selectors.ts
197+
===============================================================================
198+
Language Files Lines Code Comments Blanks
199+
===============================================================================
200+
TypeScript 1 131 103 9 19
201+
===============================================================================
202+
Total 1 131 103 9 19
203+
===============================================================================
204+
```
205+
206+
所以标题也可以修改成 100 行代码实现一个简单的 CSS Selector :)
207+
208+
如果细看实现,还是有不少的优化之处的,比如 `parseSelector` 函数可以实现得更优雅些,以便进一步扩展支持其他的语法。
209+
210+
另外就是目前支持的都是所有 selector 完全匹配的情况,即 `and`, 但是目前不支持 `or` 的功能,即类如: `h1,h2,h3`, 可以匹配 `h1`, `h2`, 或者 `h3`.
211+
212+
---
213+
214+
如果想要看下较完整版本的 CSS Selector, 可以看下我六年多前我用 C++ 实现的[版本](https://github.com/ramsayleung/crawler), 实现从字符串解析并生成 `DOM`, 再实现 CSS 解析器,纯正的 OOP 风味。
215+
216+
当时初学 C++, 这个算是我早期写得比较大的 C++17 项目,核心代码大概1000行,还有几百行的单元测试。
217+
218+
现在再翻看自己的代码,会惊讶于当时自己代码写的工整,可谓是有板有眼,像极了书法初学者写的楷书。
219+
220+
> <span class="org-target" id="org-target--Unix----"></span>这本砖头书读过, 其他的C++书籍, 如<span class="org-target" id="org-target--C---Primer"></span>, <span class="org-target" id="org-target--Effective-C--"></span>, <span class="org-target" id="org-target--Modern-C--"></span>也读过, 感觉不把书中的内容实践下, 很容易遗忘。
221+
>
222+
> 但是日常的工作内容并不会涉及底层网络服务, 一切底层细节内容都被框架给包掉了, 开发的主力语言是Java, 也不会使用到C++.
223+
>
224+
> 因此决定创造个机会实践下这些知识,最终决定只用C/C++内置函数库实现。
225+
226+
的确所有工具都是用C/C++内置函数库实现的,甚至测试框架还是自己用宏实现的.
227+
228+
只是我未曾想到的是,写了这段话后不足一年,C++就成为了我下一家公司干活的主力语言; 而现在,我又在重新写 Java, 着实是「白衣苍狗」。
229+
230+
[回到本系列的目录]({{< relref "reinvent_project" >}})
231+
232+
[^fn:1]: <https://github.com/ramsayleung/reinvent/tree/master/html_selector>
233+
[^fn:2]: <https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors>
234+
[^fn:3]: <https://developer.mozilla.org/en-US/docs/Web/XML/XPath/Guides>
235+
[^fn:4]: <https://refactoring.guru/design-patterns/strategy>

content/zh/post/2025/reinvent_unit_test.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
+++
22
title = "重新造轮子系列(一):单元测试框架"
33
date = 2025-02-16T22:27:00-08:00
4-
lastmod = 2025-03-02T21:15:29-08:00
4+
lastmod = 2025-03-03T17:57:56-08:00
55
tags = ["reinvent"]
66
categories = ["ReInvent: 重新造轮子系列"]
77
draft = false
88
toc = true
99
+++
1010

11+
项目 GitHub 地址: [Unit Test](https://github.com/ramsayleung/reinvent/tree/master/unit_test)
12+
13+
1114
## <span class="section-num">1</span> 前言 {#前言}
1215

1316
单元测试的重要性无须多言,它是保证项目质量的基石.
@@ -754,5 +757,7 @@ const main = async (args: Array<string>) => {
754757
755758
## <span class="section-num">4</span> 参考 {#参考}
756759
760+
[回到本系列的目录]({{< relref "reinvent_project" >}})
761+
757762
- <https://third-bit.com/sdxjs/unit-test/>
758763
- <https://blog.youxu.info/2008/11/30/pearl-in-smalltal/>
132 KB
Loading
119 KB
Loading

0 commit comments

Comments
 (0)