拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BeautifulSoup实战:Python HTML解析与数据提取指南

BeautifulSoup实战:Python HTML解析与数据提取指南 做爬虫、做数据分析、做后端的时候免不了跟 HTML、XML 打交道。用户看到的是排版整齐的网页程序看到的却是一堆嵌套的尖括号标签。早期我用正则从页面里抠数据还能应付一阵子直到遇到一个深嵌套十几层div的页面才彻底意识到自己需要的是一个能把标签堆变成文档树的解析工具。BeautifulSoup 这个 Python 的 HTML/XML 解析库就是专治这类问题的。它不要求页面格式规范能用非常宽松的姿态把标签整理成一棵树让你像浏览文件夹一样去读节点、取属性、抽文本。这篇文章就把我从入门到折腾多年积累的用法和踩坑经验整理出来适合刚接触爬虫的初学者也适合被 HTML 清洗和 XML 结构转换困扰的开发者。1. 为什么我还会选BeautifulSoup正则、XPath和它的取舍1.1 正则适合的场景从小段文本说起先说个容易惹争议的观点正则表达式不是不能用只是它的主场不在 HTML 解析。正则真正擅长的场景是结构固定、文本短小的内容。比如从一行日志里抓 IP、从接口返回的 JSON 字符串里抽某个字段、从一段纯文本里匹配所有手机号。这些目标格式高度可预测一行正则写出来干净利落。但 HTML 恰恰相反。同一个div标签在页面里可能带各种属性属性顺序会变单双引号会变标签大小写会变甚至某些标签就是死活不闭合。你为了匹配一个div classproduct里面的商品名称正则要处理换行、空格、可选属性、嵌套层级写着写着就变成了一堆不可维护的魔法符号。更麻烦的是正则匹配出来的永远是平面字符串它不理解这个a标签到底属于哪个div一旦页面结构稍微调整你的正则大概率当场失效。1.2 XPath 很强但用起来像是在跟语法较劲XPath 配合 lxml 库是另一种常用方案它的执行效率确实高浏览器开发者工具里也能直接 copy 出元素的 XPath。但实际写起来我的体验是心智负担重。比如找某个表格后面第二个 div 里的所有链接这类相对复杂一点的定位XPath 表达式会写得又长又绕而且一旦解析器对不规范 HTML 的理解和浏览器不一致copy 来的 XPath 很可能对不上。相比之下BeautifulSoup 的 API 更接近人说话的方式一个元素下面找所有a标签就是div.find_all(a)语感非常直白。1.3 选型建议不是谁替代谁而是各干各的活结合这些年做采集和数据处理的经验我自己的选型规则是日常爬站、清洗 HTML 片段、写一次性数据处理脚本首选 BeautifulSoupAPI 直观容错好。页面量特别大、结构又规整、目标路径稳定换 lxml 的 XPath性能上限更高。从日志、源码、接口返回里去抠固定格式文本正则依然是最合适的工具。这里多说一句BeautifulSoup 和 lxml 不是竞争关系。BeautifulSoup 底层可以挂 lxml 解析器等于你用着人性化的 API吃着高效的引擎这是比较理想的组合。2. 环境准备别小看解析器这个引擎2.1 安装看似简单但有一个很多人忽略的问题用 pip 装两个库就够了pip install beautifulsoup4 pip install lxml装完之后Python 代码里 import 的名字是bs4不是BeautifulSoup这个细节经常让新手卡住。需要注意即使不装 lxmlBeautifulSoup 也能工作因为它自带 Python 标准库的html.parser。但如果直接用默认解析器去解析大型页面速度会明显变慢容错性也稍逊。所以我建议把 lxml 一起装上让 lxml 当引擎BeautifulSoup 当操作界面。2.2 三种解析器的差异先用一张表说清楚解析器解析速度容错能力依赖适合场景html.parser一般一般无临时脚本、简单片段lxml快好需要安装 lxml日常采集、生产环境html5lib慢最好需要安装 html5lib严格按 HTML5 规范处理新标签直观一点说html.parser是轻量级的处理简单文档够用但遇到明显不规范的页面结构很容易被解析得七扭八歪lxml是综合性价比之王速度快、容错好绝大多数页面用它都没问题html5lib的容错最强但速度感人只有在页面用到大量 HTML5 新标签并且结构特别怪异时才值得牺牲性能。2.3 一个必须掌握的参数from_encoding中文网页里最常踩的坑就是编码问题。很多网页用的是 GBK 或 GB2312 编码如果直接把文本字符串丢给 BeautifulSoup它默认按 UTF-8 处理中文必然会乱码。正确的做法是读取 HTML 的二进制流再通过from_encoding指定源编码from bs4 import BeautifulSoup with open(page.html, rb) as f: data f.read() soup BeautifulSoup(data, lxml, from_encodinggb18030)这里我推荐用gb18030而不是gbk因为 gb18030 是 GBK 的超集兼容性更好。如果你不确定页面原始编码可以先用UnicodeDammit自动检测一下from bs4 import UnicodeDammit dammit UnicodeDammit(data) print(dammit.original_encoding)拿到检测结果后再决定from_encoding传什么这是处理老旧中文站点时最稳妥的方案。3. 把 HTML 当作一棵树核心对象与常用寻址方式3.1 先认清三个基础对象BeautifulSoup 的结构核心是三个对象类型BeautifulSoup代表整个文档对象也就是那棵完整的树。Tag代表一个标签节点比如一个div、一个a。NavigableString代表标签里的纯文本内容比如p你好/p里的你好。这儿有个特别容易理解错的点NavigableString并不是字符串本身它只是表现成字符串。你在操作时会常看到它的内容但它依然是一个对象。当你需要真正可编辑的字符串时通常用str()转换一下。3.2 find 与 find_all定位节点的基本功find_all()是最常用的方法它会递归搜索整棵文档树返回所有满足条件的节点列表soup.find_all(a) # 找所有链接 soup.find_all(a, hrefTrue) # 找带 href 属性的链接 soup.find_all(a, hrefre.compile(ritemid\d)) # 按正则匹配 href soup.find_all(div, class_product) # 找 class 为 product 的 div soup.find_all(idcontent) # 找 id 为 content 的节点find()则是只取第一个的版本适合确定页面上只会出现一次的元素。这里有几个加分细节第一class_后面有下划线。因为class是 Python 的关键字所以构造函数和find_all()里要用class_。但在 CSS 选择器里就直接写.classname。第二limit参数可以限制返回数量等于让解析器找到足够结果后提前停止很适合只取前几条数据的场景soup.find_all(a, limit5)第三find_all()默认是递归的。如果你只想找当前节点的直接子元素把recursiveFalse传进去soup.find(div, class_list).find_all(p, recursiveFalse)3.3 用 CSS 选择器select 与 select_one老手通常两种写法混着用。select()支持 CSS 选择器语法写起来非常简洁soup.select(div#main p.title) soup.select(.rank-list li:first-child)select_one()和find()一样只返回第一个匹配结果。我个人的习惯是能确定唯一路径时用select_one逻辑清晰需要遍历多个节点时用find_all返回列表好处理。3.4 提取文本string、strings 与 get_text 的区别这是新手最容易栽跟头的地方。Tag.string只有在节点内只有一个子节点且该子节点是文本时才返回内容否则会返回Nonesoup BeautifulSoup(phello bworld/b/p, lxml) p soup.p print(p.string) # None因为 p 下面有两个子节点文本节点和 b所以当你要拿标签里的全部文字时别依赖.string直接使用get_text()print(p.get_text()) # hello world print(p.get_text(stripTrue)) # hello world去掉首尾和多余空白get_text(stripTrue)会把换行、缩进一并去掉虽然简单粗暴但在绝大多数表格清洗、内容抽取场景里已经够用。3.5 树上的走动contents、parents、siblings除了向下查找BeautifulSoup 也支持在树上横向和纵向走动。contents返回当前节点的直接子节点列表children则是生成器descendants会递归返回所有后代节点。向上走可以用find_parent()找相邻节点用find_next_sibling()和find_previous_sibling()。处理复杂页面时这套邻居亲戚关系比一层一层find_all高效得多。比如你在解析评论列表每条评论是一个li评论的作者在标题行里、回复数在下一行这种情况下直接用兄弟节点导航会比反复定位从根开始重新找方便很多。4. 一个能直接抄的实战HTML 表格转 Markdown4.1 场景背景很多资料站、帮助文档页面会把数据做成表格想把它转成 Markdown 或 CSV格式却杂乱不堪。网上也有一些在线转换工具但批量处理、走管道自动化的时候还是自己写脚本最方便。这个例子的原型来自我一次整理公司内部文档库的经历当时有几百张 HTML 表格要转成 Markdown人力复制粘贴根本不可行。4.2 实现思路思路其实很简单读取 HTML 文本并交给 BeautifulSoup 解析。用find_all(table)定位所有表格。遍历每个表格里的tr行。每行里取td和th作为单元格。每个单元格用get_text(stripTrue)提取文本。把数据按 Markdown 表格语法拼成字符串。这里要处理两个细节单元格文本里可能有多个空白、换行需要折叠成单行某些行单元格数量可能比其他行少要补空字符串保证 Markdown 表格对齐。4.3 完整代码import re from bs4 import BeautifulSoup def table_to_markdown(html_str, table_index0): soup BeautifulSoup(html_str, lxml) tables soup.find_all(table) if not tables: return table tables[table_index] rows [] for tr in table.find_all(tr): cells tr.find_all([td, th]) if not cells: continue row [ .join(cell.get_text(stripTrue).split()) for cell in cells ] rows.append(row) if not rows: return # 第一行作为表头 header rows[0] md [] md.append(| | .join(header) |) md.append(| ---| * len(header)) # 其余行长度不够就补空字符串 for row in rows[1:]: if len(row) len(header): row row [] * (len(header) - len(row)) md.append(| | .join(row) |) return \n.join(md)上面代码里有几处值得注意get_text(stripTrue)之后再用join(row.split())是因为strip只能去掉首尾空白无法折叠字符串中间的换行和缩进。类似商品名\n\n已售10件这种文本经过处理后才会变成一行里的两个短语。table.find_all(tr)是递归查找如果表格里嵌套了其他表格这里也会一并取到。常规页面没有这种嵌套问题真遇到了就需要额外加判断。4.4 测试效果假设输入这样一段 HTMLtable trth姓名/thth城市/th/tr trtd张三/tdtd上海/td/tr trtd李四/tdtd北京/td/tr /table脚本会输出| 姓名 | 城市 | |--- |--- | | 张三 | 上海 | | 李四 | 北京 |这段代码稍微改几行就能把输出换成 CSVimport csv import io def table_to_csv(html_str, table_index0): soup BeautifulSoup(html_str, lxml) table soup.find_all(table)[table_index] output io.StringIO() writer csv.writer(output) for tr in table.find_all(tr): cells tr.find_all([td, th]) writer.writerow([ .join(c.get_text(stripTrue).split()) for c in cells ]) return output.getvalue().strip()这就是 BeautifulSoup 实用性的体现同样一套数据抽取逻辑换一种输出格式只是最后几行的问题解析部分完全不用动。5. 真实项目里踩过的坑编码、动态内容与性能5.1 编码问题是最隐蔽的元凶前面刚提过编码这里再展开讲因为这是我在实际项目里出现最频繁的问题没有之一。一个典型过程是这样的requests请求回来一个 HTML你直接赋值给 BeautifulSoup结果中文全变成乱码字符。排查半天才发现是网页本身用 GBK 编码而requests.text在未正确识别编码时可能给出错误结果BeautifulSoup 拿到的已经是坏文本。正确的链路应该是用response.content拿到原始字节用UnicodeDammit判断编码再传入 BeautifulSoup。不要把requests.text当唯一选项除非你已经确认响应头里的charset是可靠的。5.2 class 属性为什么特别容易出错class是 Python 的保留字所以很多刚接触的人会写tag.class直接报错。正确写法是tag[class]或tag.get(class)。还有一点很多人不知道HTML 的 class 属性可以包含多个值例如classtitle red bold所以 BeautifulSoup 返回的是列表tag soup.find(h1) print(tag.get(class)) # [title, red, bold]如果只是想判断某个元素有没有某个 class用select_one(.title)或tag.has_attr(class)会更方便。5.3 动态内容BeautifulSoup 不执行 JavaScript这是为什么拿到源码但抓不到数据的根本原因。BeautifulSoup 只是解析静态 HTML它不会执行页面里的 JavaScript。现在很多页面是前端渲染的商品价格、评论列表都是 JS 跑完后才出现在 DOM 里。你从requests得到的 HTML 里根本没有这些节点BeautifulSoup 当然也找不到。碰到这种情况我一般按优先级尝试抓页面背后的接口直接请求 JSON 数据省时省力。用 Playwright 或 Selenium 渲染页面等 JS 执行完再把page_source或current_page_source交给 BeautifulSoup。如果页面是通过定时器异步加载的用工具等几秒再取源码。这里要提醒一句不要为了让某个渲染方案跑通就无脑上浏览器自动化。先花十分钟看接口往往是最优解。5.4 性能问题解析器选型与 API 使用习惯处理单个页面时性能差异几乎感受不到。但当你批量跑几万个页面或者处理一个几十兆的超大 HTML 时性能问题就会显现出来。第一解析器选 lxml比默认 html.parser 快很多。第二能用select_one、find拿单个结果就别用find_all、select拿全部结果再取第一个。第三limit参数能限制返回数量时要用。第四避免在同一段 HTML 上重复创建 BeautifulSoup 对象应该复用同一个 soup。还有一点容易被忽略BeautifulSoup 解析大型 HTML 时单次find_all就会遍历整棵节点树。如果你在 for 循环里对同一个 soup 反复执行find_all(div)时间复杂度会成倍上升。更好的做法是一遍遍历中同时提取多个字段或者先把需要的节点取出来缓存再做二次处理。5.5 XML 解析BeautifulSoup 同样胜任标题里写了 HTML/XML 解析库自然要聊 XML。BeautifulSoup 在lxml依赖存在的情况下解析 XML 文档也很方便xml_soup BeautifulSoup(xml_content, lxml-xml)注意这里解析器名是lxml-xml它会严格按照 XML 规则处理节点大小写和属性不会像解析 HTML 那样自动容错。日常工作中遇到 MyBatis 的 mapper XML、SOAP 接口返回、各种配置文件需要筛选节点时这招很管用。因为 XML 对结构的要求比 HTML 严格很多所以用 BeautifulSoup 解析 XML 时尽量确保文档本身合法。如果有 DTD 或 XSD 校验需求那还是用lxml.etree更专业BeautifulSoup 适合快速抽取、文档又比较规整的轻量场景。6. 大型 XML/HTML 解析场景下的优化思路与习惯6.1 反复创建对象是隐形杀手很多人在循环里写for html in html_list: soup BeautifulSoup(html, lxml) # 处理逻辑如果每个 html 都不大问题不大。但如果每个页面几十 KB循环几万次创建对象的开销就会非常可观。合理做法是仅当页面结构差异很大时才用单个 BeautifulSoup 对象处理假如多个页面来自同一套模板可以先粗略找出重复结构再考虑用SoupStrainer只解析需要的部分。BeautifulSoup 提供的SoupStrainer是个好东西from bs4 import BeautifulSoup, SoupStrainer only_tags SoupStrainer([div, table, a]) soup BeautifulSoup(html, lxml, parse_onlyonly_tags)这样只会构建你关心的节点树能大幅减少解析内存和时间。对动辄几 MB 的下载页或日志页来说不是小优化。6.2 我日常处理页面的一个小习惯这里分享一条个人经验拿到一个陌生页面的 HTML 后先别急着写选择器。先做一步soup BeautifulSoup(content, lxml) print(soup.prettify()[:3000])虽然prettify()会多消耗一点性能但它能让你一眼看清页面结构、标签层级和子节点关系。我在排查为什么 find_all 找不到元素的时候几乎都会先打印这段预览确认自己到底面对的是什么树。多数疑难问题光看这 3000 字就能定位。还有一个小习惯是凡是可能多次复用且格式固定的对象比如某个导航块、页脚、侧边栏我都习惯先取出来单独成变量sidebar soup.find(aside, class_sidebar)后续所有针对侧边栏的解析都基于这个变量既能减少重复查找也让代码逻辑更清楚。6.3 不要做一次性脚本狂魔BeautifulSoup 上手特别快带来的副作用是很多人的脚本写得非常粗糙。比如为了抓一个页面写了 200 行一次性代码下次遇到相似需求又从头写。我建议把常用功能沉淀成函数像表格转 Markdown随便一个容器内提取文本按 CSS 路径精准取值这类逻辑值得积累成一个自己的解析工具模块。时间长了你会发现真正麻烦的从来不是 BeautifulSoup 不会用而是对页面结构的分析方法和异常情况的兜底策略。把这些经验沉淀成固定工具才是这个解析库带给你最大的杠杆。最后再分享一个实用性小经验很多看起来是解析库不够强的问题其实是数据源本身就带着脏格式比如标签交错、非法字符、未闭合的。处理这类页面时先用html.unescape()清洗一下文本或者给 BeautifulSoup 传入html5lib解析器往往能解决一大半莫名解析不出来的问题。能从源头上把数据洗得干净一点解析库写起来会省心得多。
返回列表