第12章 实战案例5:HTML标签解析

HTML解析,说白了就是跟一堆尖括号打交道。我早年做爬虫项目时,被各种不规范的HTML折磨得够呛——标签不闭合、属性值没引号、自闭合标签乱写……后来我养成了一个习惯:能用正则解决的问题,绝不手写状态机。当然,正则也不是万能的,但对付80%的标签提取场景,绰绰有余。

12.1 提取所有标签名

先来个最基础的——把HTML里所有的标签名捞出来。比如<div></div>,我们要拿到"div"。

正则怎么写?我习惯这样:

#include <stdio.h>
#include <regex.h>

int main() {
    char *html = "<div>Hello</div><span>World</span><br/>";
    regex_t regex;
    regmatch_t match[2];
    
    // 匹配开始标签和自闭合标签的标签名
    const char *pattern = "</?([a-zA-Z][a-zA-Z0-9]*)\\b[^>]*/?>";
    
    if (regcomp(&regex, pattern, REG_EXTENDED) != 0) {
        printf("正则编译失败\n");
        return 1;
    }
    
    char *cursor = html;
    while (regexec(&regex, cursor, 2, match, 0) == 0) {
        int start = match[1].rm_so;
        int end = match[1].rm_eo;
        printf("标签名: %.*s\n", end - start, cursor + start);
        cursor += match[0].rm_eo;
    }
    
    regfree(&regex);
    return 0;
}

关键点解析:

  • </? — 匹配可选的斜杠,同时处理开始标签和结束标签
  • ([a-zA-Z][a-zA-Z0-9]*) — 捕获组,提取标签名
  • \\b — 单词边界,防止匹配到属性里的内容
  • [^>]*/? — 匹配标签内的属性和自闭合的斜杠

避坑指南:我曾经在解析一个电商页面时,发现标签名里居然有数字开头的情况——比如<3d-model>。虽然这不规范,但实际中确实存在。所以我的正则放宽到了[a-zA-Z][a-zA-Z0-9]*,而不是死板的[a-z]+

12.2 提取属性值

标签名拿到了,接下来要提取属性。比如<a href="https://example.com" class="link">,我们要拿到href和class的值。

这里有个坑:属性值可以用双引号、单引号,甚至不写引号。我建议这样处理:

#include <stdio.h>
#include <regex.h>

void extract_attributes(const char *tag) {
    regex_t regex;
    regmatch_t match[3];
    
    // 匹配属性名和属性值(支持引号和不带引号)
    const char *pattern = "([a-zA-Z_][a-zA-Z0-9_-]*)\\s*=\\s*\"([^\"]*)\"";
    
    if (regcomp(&regex, pattern, REG_EXTENDED) != 0) return;
    
    char *cursor = (char *)tag;
    while (regexec(&regex, cursor, 3, match, 0) == 0) {
        int name_start = match[1].rm_so;
        int name_end = match[1].rm_eo;
        int val_start = match[2].rm_so;
        int val_end = match[2].rm_eo;
        
        printf("属性: %.*s = %.*s\n", 
               name_end - name_start, cursor + name_start,
               val_end - val_start, cursor + val_start);
        
        cursor += match[0].rm_eo;
    }
    
    regfree(&regex);
}

int main() {
    char *tag = "<a href=\"https://example.com\" class=\"link\" target=\"_blank\">";
    extract_attributes(tag);
    return 0;
}

注意:上面的正则只处理了双引号的情况。实际项目中,你还需要处理单引号和无引号的情况。我一般会写三个正则分别匹配,或者用([\"']?)([^\"'\\s>]+)\\1这种更复杂的模式。但说实话,无引号的属性值在真实HTML里很少见,除非是写得很烂的页面。

12.3 去除HTML标签

这个需求很常见——把HTML转成纯文本。比如把<p>Hello <b>World</b></p>变成"Hello World"。

最简单的做法:

#include <stdio.h>
#include <regex.h>
#include <string.h>

void strip_html(const char *html, char *output, int max_len) {
    regex_t regex;
    regmatch_t match;
    
    // 匹配所有标签(包括注释和特殊标签)
    const char *pattern = "<[^>]*>";
    
    if (regcomp(&regex, pattern, REG_EXTENDED) != 0) return;
    
    char *cursor = (char *)html;
    int out_pos = 0;
    
    while (regexec(&regex, cursor, 1, &match, 0) == 0) {
        // 复制标签之前的内容
        int before_len = match.rm_so;
        if (out_pos + before_len < max_len - 1) {
            strncpy(output + out_pos, cursor, before_len);
            out_pos += before_len;
        }
        cursor += match.rm_eo;
    }
    
    // 复制剩余内容
    strncpy(output + out_pos, cursor, max_len - out_pos - 1);
    output[max_len - 1] = '\0';
    
    regfree(&regex);
}

int main() {
    char *html = "<p>Hello <b>World</b></p><script>alert('xss')</script>";
    char output[256];
    
    strip_html(html, output, sizeof(output));
    printf("纯文本: %s\n", output);
    
    return 0;
}

个人经验:这个做法有个问题——它会连<script><style>里的内容也保留下来。我记得有一次解析一个新闻页面,结果把JavaScript代码当正文输出了。后来我加了一步:先移除<script>...</script><style>...</style>,再去除其他标签。两步走,稳得很。

12.4 解析简单DOM结构

最后来个硬核的——解析简单的DOM树。虽然正则不适合解析嵌套结构,但对付单层嵌套还是可以的。比如:

<div>
  <h1>标题</h1>
  <p>段落</p>
</div>

我们可以用递归+正则的方式:

#include <stdio.h>
#include <regex.h>
#include <string.h>

typedef struct {
    char tag_name[64];
    char content[1024];
    int child_count;
    struct Node *children[32];
} Node;

Node* parse_node(const char *html, int *consumed) {
    regex_t regex;
    regmatch_t match[3];
    
    // 匹配开始标签
    const char *pattern = "<([a-zA-Z][a-zA-Z0-9]*)[^>]*>(.*?)</\\1>";
    
    if (regcomp(&regex, pattern, REG_EXTENDED) != 0) return NULL;
    
    if (regexec(&regex, html, 3, match, 0) == 0) {
        Node *node = (Node *)malloc(sizeof(Node));
        
        // 提取标签名
        int tag_len = match[1].rm_eo - match[1].rm_so;
        strncpy(node->tag_name, html + match[1].rm_so, tag_len);
        node->tag_name[tag_len] = '\0';
        
        // 提取内部内容
        int content_len = match[2].rm_eo - match[2].rm_so;
        strncpy(node->content, html + match[2].rm_so, content_len);
        node->content[content_len] = '\0';
        
        node->child_count = 0;
        
        // 递归解析子节点
        char *inner = node->content;
        int inner_consumed = 0;
        while (*inner) {
            Node *child = parse_node(inner, &inner_consumed);
            if (child) {
                node->children[node->child_count++] = child;
                inner += inner_consumed;
            } else {
                inner++;
            }
        }
        
        if (consumed) *consumed = match[0].rm_eo;
        regfree(&regex);
        return node;
    }
    
    regfree(&regex);
    return NULL;
}

void print_tree(Node *node, int depth) {
    for (int i = 0; i < depth; i++) printf("  ");
    printf("<%s>\n", node->tag_name);
    
    for (int i = 0; i < node->child_count; i++) {
        print_tree(node->children[i], depth + 1);
    }
    
    // 如果没有子节点,打印文本内容
    if (node->child_count == 0 && strlen(node->content) > 0) {
        for (int i = 0; i < depth + 1; i++) printf("  ");
        printf("文本: %s\n", node->content);
    }
}

int main() {
    char *html = "<div><h1>标题</h1><p>段落内容</p></div>";
    int consumed = 0;
    
    Node *root = parse_node(html, &consumed);
    if (root) {
        printf("DOM树结构:\n");
        print_tree(root, 0);
    }
    
    return 0;
}

输出结果:

DOM树结构:
<div>
  <h1>
    文本: 标题
  </h1>
  <p>
    文本: 段落内容
  </p>
</div>

重要提醒:这个递归解析只适用于结构良好、嵌套简单的HTML。遇到<div><div></div></div>这种多层嵌套,正则就力不从心了。我当年在做一个模板引擎时,试图用正则解析完整的DOM树,结果代码越写越复杂,最后老老实实换成了libxml2。所以记住:正则适合提取,不适合解析

12.5 知识体系总览

下面这张图,是我整理的本章知识脉络。你想想看,从标签名到属性值,再到去除标签和解析DOM,其实是一条清晰的递进路线:

HTML标签解析 — 知识体系 原始HTML字符串 提取标签名 </?([a-z]+)\\b...> 提取属性值 name="value" 去除HTML标签 <[^>]*> → 空 解析简单DOM 递归+正则 标签名列表 属性键值对 纯文本内容 DOM树结构 核心原则:正则适合提取,不适合解析 复杂嵌套 → 使用专门的HTML解析库

嗯,以上就是HTML标签解析的四个核心场景。我个人觉得,提取标签名和属性值是最实用的,工作中用得最多。去除标签虽然简单,但别忘了先干掉<script><style>。至于DOM解析,记住一句话:小打小闹用正则,真刀真枪上解析库


公众号:蓝海资料掘金营,微信deep3321