第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(®ex, pattern, REG_EXTENDED) != 0) {
printf("正则编译失败\n");
return 1;
}
char *cursor = html;
while (regexec(®ex, 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(®ex);
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(®ex, pattern, REG_EXTENDED) != 0) return;
char *cursor = (char *)tag;
while (regexec(®ex, 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(®ex);
}
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(®ex, pattern, REG_EXTENDED) != 0) return;
char *cursor = (char *)html;
int out_pos = 0;
while (regexec(®ex, 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(®ex);
}
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(®ex, pattern, REG_EXTENDED) != 0) return NULL;
if (regexec(®ex, 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(®ex);
return node;
}
regfree(®ex);
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标签解析的四个核心场景。我个人觉得,提取标签名和属性值是最实用的,工作中用得最多。去除标签虽然简单,但别忘了先干掉<script>和<style>。至于DOM解析,记住一句话:小打小闹用正则,真刀真枪上解析库。
公众号:蓝海资料掘金营,微信deep3321