---
title: 面向中学生的DOM树对象结构与Python实现：构建简易HTML解析器并支持XPath查询功能详解
created: 2026-03-12
updated: 2026-03-12
type: summary
tags: ["dom", "notes", "python", "telegram", "xpath"]
external: http://go/ui/posts/2165/
---

为了让中学生能理解，我们先解释 DOM 树的对象结构，然后写一个简单的 Python 程序，它能解析一个简单的 HTML 页面，并且支持用 XPath 查找元素。

---

### 1. DOM 树的对象结构是什么样的？

我们可以把 DOM 树想象成由很多“节点”组成，每个节点就是一个 Python 对象。节点分两种：

- **元素节点**：对应 HTML 标签，比如 `<div>`、`<p>`。它里面存着：
  - 标签名（比如 `'div'`）
  - 属性字典（比如 `{'class': 'main', 'id': 'content'}`）
  - 子节点列表（它里面套着的其他标签或文本）
  - 父节点（谁把它包在里面）

- **文本节点**：对应标签之间的文字，比如 `<p>你好</p>` 里的“你好”。它里面存着：
  - 文本内容
  - 父节点

这样，整个网页就变成了一棵从根节点（`<html>`）出发的树。

---

### 2. 一个简单的 Python 实现

下面这段代码可以解析一段简单的 HTML，并支持基础的 XPath 查询（比如 `html/body/div/p`，或者带索引的 `div[1]`，或者带属性过滤的 `div[@class='main']`）。

```python
import re

# ---------- 定义节点类 ----------
class Node:
    """所有节点的基类"""
    pass

class Element(Node):
    """元素节点：对应HTML标签"""
    def __init__(self, tag, attrs):
        self.tag = tag          # 标签名，如 'div'
        self.attrs = attrs      # 属性字典，如 {'class': 'main'}
        self.children = []      # 子节点列表
        self.parent = None      # 父节点

    def add_child(self, child):
        self.children.append(child)
        child.parent = self

    def __repr__(self):
        return f"<{self.tag} {self.attrs}>"

class Text(Node):
    """文本节点：标签内的文字"""
    def __init__(self, text):
        self.text = text
        self.parent = None

    def __repr__(self):
        return f"Text('{self.text}')"


# ---------- 简易HTML解析器 ----------
def parse_html(html):
    """把HTML字符串解析成DOM树，返回根元素"""
    # 去掉多余空白（简化处理）
    html = html.strip()
    i = 0
    length = len(html)
    stack = []          # 存放当前未闭合的元素
    root = None

    # 用正则匹配标签（简易版，不考虑注释等）
    tag_pattern = re.compile(r'<(\w+)([^>]*)>')  # 匹配开始标签
    end_tag_pattern = re.compile(r'</(\w+)>')    # 匹配结束标签

    while i < length:
        if html[i] == '<':
            # 可能是开始标签或结束标签
            if html[i+1] == '/':
                # 结束标签，例如 </div>
                match = end_tag_pattern.match(html, i)
                if match:
                    tag = match.group(1)
                    # 弹出栈顶，应该是同名标签
                    if stack and stack[-1].tag == tag:
                        stack.pop()
                    i = match.end()
                else:
                    i += 1
            else:
                # 开始标签，例如 <div class="main">
                match = tag_pattern.match(html, i)
                if match:
                    tag = match.group(1)
                    attrs_str = match.group(2).strip()
                    # 解析属性（简易版，只处理 key="value" 形式）
                    attrs = {}
                    if attrs_str:
                        # 用正则找到所有 属性名="值"
                        attr_pattern = re.compile(r'(\w+)=["\']([^"\']*)["\']')
                        for m in attr_pattern.finditer(attrs_str):
                            attrs[m.group(1)] = m.group(2)
                    # 创建元素节点
                    elem = Element(tag, attrs)
                    if not root:
                        root = elem
                    # 如果栈里有父元素，就把自己作为子节点加入
                    if stack:
                        stack[-1].add_child(elem)
                    # 判断是不是自闭合标签（比如 <br/> 或 <img .../>）
                    if not (html[i:match.end()].endswith('/>') or tag in ['br', 'img', 'input']):
                        # 不是自闭合，入栈
                        stack.append(elem)
                    i = match.end()
                else:
                    i += 1
        else:
            # 文本节点，直到下一个 < 为止
            text_end = html.find('<', i)
            if text_end == -1:
                text_end = length
            text_content = html[i:text_end].strip()
            if text_content and stack:
                # 将文本作为文本节点加入当前父元素
                text_node = Text(text_content)
                stack[-1].add_child(text_node)
            i = text_end
    return root


# ---------- XPath查询器 ----------
def find_elements(node, xpath):
    """
    在 node 为根的树中查找满足 xpath 的所有元素。
    xpath 格式：由 '/' 分隔的步骤，例如 'html/body/div[1]/p'
    每个步骤可以是：
        tagname                - 匹配所有该标签名的子元素
        tagname[index]         - 匹配第 index 个子元素（从1开始）
        tagname[@attr='value'] - 匹配属性等于指定值的子元素
    """
    if xpath.startswith('/'):
        xpath = xpath[1:]  # 去掉开头的 '/'

    steps = xpath.split('/')
    current_nodes = [node]  # 当前层待匹配的节点列表

    for step in steps:
        if not step:
            continue
        # 解析步骤：提取标签名和可能的条件
        # 匹配形如 div[1] 或 div[@class='main'] 或 纯div
        match = re.match(r'(\w+)(?:\[(\d+)\])?(?:\[@(\w+)=[\'"]([^\'"]+)[\'"]\])?', step)
        if not match:
            raise ValueError(f"无法解析步骤: {step}")
        tag_name = match.group(1)
        index = match.group(2)   # 如果有 [数字] 就得到数字字符串
        attr_name = match.group(3)
        attr_value = match.group(4)

        next_nodes = []
        for cur in current_nodes:
            # 只考虑元素节点（跳过文本节点）
            if not isinstance(cur, Element):
                continue
            # 收集所有符合标签名的子元素
            candidates = [child for child in cur.children if isinstance(child, Element) and child.tag == tag_name]
            # 如果指定了属性条件，进一步过滤
            if attr_name is not None:
                candidates = [c for c in candidates if c.attrs.get(attr_name) == attr_value]
            # 如果指定了索引，取对应位置（注意XPath索引从1开始）
            if index is not None:
                idx = int(index) - 1
                if 0 <= idx < len(candidates):
                    candidates = [candidates[idx]]
                else:
                    candidates = []
            next_nodes.extend(candidates)
        current_nodes = next_nodes
        if not current_nodes:
            break

    return current_nodes


# ---------- 测试 ----------
if __name__ == "__main__":
    html_doc = """
    <html>
        <body>
            <div class="container">
                <p id="first">你好，世界！</p>
                <p id="second">这是第二段。</p>
            </div>
            <div class="footer">
                <p>版权信息</p>
            </div>
        </body>
    </html>
    """

    root = parse_html(html_doc)

    print("=== 查询示例 ===")
    # 查询所有 p 标签（相对于根）
    ps = find_elements(root, "html/body/div/p")
    print("html/body/div/p 结果:", ps)

    # 查询第二个 div 里的第一个 p
    p2 = find_elements(root, "html/body/div[2]/p")
    print("html/body/div[2]/p 结果:", p2)

    # 查询 class='container' 的 div 里的 p
    container_p = find_elements(root, "html/body/div[@class='container']/p")
    print("html/body/div[@class='container']/p 结果:", container_p)
```

运行这段代码，会输出类似：

```
=== 查询示例 ===
html/body/div/p 结果: [<p {'id': 'first'}>, <p {'id': 'second'}>, <p {}>]
html/body/div[2]/p 结果: [<p {}>]
html/body/div[@class='container']/p 结果: [<p {'id': 'first'}>, <p {'id': 'second'}>]
```

---

### 3. 代码解释

- **节点类**：`Element` 存标签名、属性、孩子和父亲；`Text` 只存文本。
- **解析器**：用简单的状态机遍历 HTML 字符串，遇到 `<` 就解析标签，遇到普通文字就创建文本节点。用栈来维护当前正在处理的元素。
- **XPath 查询器**：  
  1. 把路径按 `/` 拆成一步步。  
  2. 每一步里，用正则提取标签名、索引（如 `[1]`）、属性条件（如 `[@class='main']`）。  
  3. 在当前层的每个元素中，找出符合标签名的子元素，再根据属性和索引过滤。  
  4. 把结果作为下一层的“当前节点”，继续处理下一步。  
  5. 最后返回所有找到的元素。

这样，我们就用简单的代码模拟了 DOM 树的存储和 XPath 查询的基本原理。当然，真正的浏览器和爬虫库（比如 lxml）会复杂得多，但核心思想是一样的。
