ARTICLE DETAIL

资讯详情

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

XPath与Parsel实战:从爬虫HTML中高效提取结构化数据

XPath与Parsel实战:从爬虫HTML中高效提取结构化数据 爬虫拿到HTML之后真正让人头疼的其实是怎么从这一堆标签里把我要的东西抠出来。前面我们试过正则写起来是真的爽但也是真的脆——样式稍微换个空格、加个属性你的findall可能就全军覆没。这一节我们来啃解析环节的第二块硬骨头XPath配合Parsel库。我先把结论放在这里学会XPath之后你会发现之前那些凑正则的时间基本都可以省下来去干点别的。XPath不是一门编程语言而是一门在XML/HTML文档里定位节点的查询语言。它不依赖字符串长什么样而是依赖节点在文档树里的位置和关系所以只要页面结构没有发生根本性调整哪怕样式的文字变了一百遍你的解析代码依然稳如老狗。而Parsel这个库是Scrapy团队从Scrapy框架里抽出来的独立解析模块底层基于lxml速度、稳定性都有保证API设计得也相当顺手。这篇文章我不会只甩几个XPath语法给你背那样学完就忘。我会从为什么需要树状思维开始把XPath的路径、谓词、函数讲透再用Parsel写真实案例最后把我实际踩过的坑挨个摊开说。内容定位是进阶但新手友好——我默认你已经能用requests拿到HTML文本了但没学过也没关系这节的重点全在解析前面的部分能跑通就行。1. 解析这件事为什么越往后越要按树找节点很多新手在学解析时都会有个困惑一开始觉得正则挺简单的re.findall(rtitle(.*?)/title, html)这不就完事了吗干嘛还要搞XPath、CSS选择器这些东西这个想法我太理解了因为我也是从正则走过来的。但真到了复杂页面正则就原形毕露了。1.1 正则的本质是平面匹配撑不起层级关系正则的原理是在字符串里按模式去扫它看到的是从头到尾一长串字符没有这个div套着那个div的概念。举个实际例子你想提取一个列表页里所有商品的名称和价格HTML大概长这样ul classproduct-list li div classname机械键盘/div div classprice299/div /li li div classname无线鼠标/div div classprice89/div /li /ul用正则你会怎么写很可能是两个findall一个抓name一个抓price然后靠zip硬拼。names re.findall(rdiv classname(.*?)/div, html) prices re.findall(rdiv classprice(.*?)/div, html)这么干有两个隐患。第一如果某个商品没有价格标签names和prices的长度就对不上zip会悄悄丢掉数据或者错位。第二如果页面里其他地方也出现了classname比如某个推荐模块你会发现正则把不该抓的东西也抓进来了。为什么会这样因为正则不知道这个name标签属于哪个li它没有作用域的概念。树状解析的思路就完全不同。它先把HTML整个转换成一棵DOM树——ul是根两个li是它的子节点每个li下面又有name和price两个叶子节点。然后你告诉解析器去每个li节点下面找到它的name子节点它就能精确地沿着结构去取永远不会跨列表匹配。你只要记住这个心法正则适合处理平铺的、格式松散的文本比如从一段话里抓手机号、邮箱页面结构提取请优先交给树状解析方案。1.2 在浏览器里看见DOM树一开始就别盲写学习XPath最有效的方式不是对着文档记语法而是先学会用浏览器开发者工具看HTML结构。我教学生时反复强调一句话解析器眼里没有页面只有一棵树。所以你必须先学会在代码之外看见这棵树。你在Chrome里打开任意一个页面按F12切到Elements面板看到的就是浏览器解析出来的DOM树。这棵树和你在代码里用requests拿到的HTML是同一个东西只是浏览器帮你渲染成了可折叠的可视化结构。你点开某个节点左右两侧能看到它的标签名、属性、文本内容。一个非常实用的技巧是**在Elements面板里选中某个元素右键 → Copy → Copy XPath就能得到一条指向该元素的XPath表达式。**比如你在上面那个商品列表里右键点机械键盘复制出来的往往是类似/html/body/div[1]/div[2]/ul/li[1]/div[1]这样的绝对路径。但请注意——**浏览器生成的XPath只能当草稿不要直接粘进代码里用。**原因有两点它是绝对路径从html一路写到底。如果页面在它上面多套了一层div整条路径就失效了。它经常带数字下标比如div[1]而页面里模块的插入顺序一变下标可能就全乱了。我推荐的做法是对着浏览器生成的这条路径把它缩短成一个抓住锚点的相对XPath。比如看到classname这个特征你就可以不用管前面那串/html/body/div[1]/div[2]直接写//div[classname]。又稳又简洁。这就是后面要讲的相对路径思想先在心里埋个种子。2. XPath的路径表达从盘根错节到一格一站理解了树的概念XPath的语法就好学了。它就是一套描述怎么从树的某个位置走到另一个位置的语言。你把HTML想象成一个巨大的文件柜XPath就是写在纸条上的取件路线。先上一张最基础的符号表我保证每一个都会用到表达式含义生活类比/从根节点开始走绝对路径从档案室门口开始走//从任意位置往下找不管在哪层在整栋楼里搜关键词.当前节点我现在站的位置..当前节点的父节点上一级取属性看门牌号*匹配任意节点通配符2.1 优先用//开头的相对路径少用绝对路径很多初学者看到XPath第一眼就被吓到觉得那一串//div[class...]/ul/li/a像天书。把它拆开就简单了它其实就是从任意位置找divdiv的class属性要等于xxx然后找它下面的ul再到li再到a。为什么我强烈建议你以//开头因为绝大多数字段在页面里出现的位置是不固定的深度。比如你要的标题可能有时候嵌在三层div里面有时候又嵌在五层里面如果用绝对路径/html/body/div/div/div[3]/h1结构一变就断。而//h1[classtitle]表达的是不管在哪只要你是h1且class是title我就要你。这就是稳定的来源。# 绝对路径写法——页面结构稍微一动就挂 html.xpath(/html/body/div[2]/div/div[1]/h1/text()) # 相对路径写法——抓住特征属性稳得多 html.xpath(//h1[classtitle]/text())在写爬虫的时候我有个习惯**一个XPath里但凡超过3个层级我就会停下来想有没有可能压缩成两步。**因为每多一个层级就多一个可能被改动的中间节点。别迷信写得多精确要追求写得多稳定。2.2 谓词用[ ]做筛选让节点按条件上岗XPath里最强大的东西我认为是方括号谓词。它相当于给XPath加了if条件。还是拿商品列表举例你可能遇到这么几种需求找第二个li//ul[classproduct-list]/li[2]找class属性等于name的节点//div[classname]找带price类的节点//div[contains(class, price)]找包含键盘文本的节点//div[contains(text(), 键盘)]这里有一个非常容易踩的坑我要单独拎出来说。**同一个标签往往有多个class比如div classproduct-item hot。**如果你写//div[classproduct-item]是匹配不到它的因为属性值严格等于product-item hot。这时候必须用contains(class, product-item)来模糊匹配。另外contains(text(), 关键词)和contains(., 关键词)也有区别。前者只匹配直接的文本节点后者会把当前节点下面所有子节点的文本都算进来。在一段文字被多个内联标签比如span、a拆散的情况下contains(., 关键词)更好用。我一开始不懂这个区别写出来的XPath经常返回空列表后来发现是文本被嵌套标签分割了改用.就解决了。2.3 XPath坐标轴不只是向下找还能找兄弟大部分教程讲到谓词就结束了但我觉得有四个轴概念非常实用新手也应该知道因为它们能让XPath的表达能力上一个档次following-sibling::后面的兄弟节点preceding-sibling::前面的兄弟节点parent::父节点简写是..ancestor::祖先节点举个真实场景。你提取一个文章列表想拿到每篇文章标题下的发布时间。HTML结构是article h2 classnews-title文章标题/h2 span classdate2024-11-01/span /article你已经定位到了//h2[classnews-title]怎么拿对应的date可以这样//h2[classnews-title]/following-sibling::span[classdate]/text()这个表达式读起来很自然标题后面的那个date兄弟的文本。遇到类似同一个模块下多个平行字段的场景用兄弟轴比从根节点重新找一遍要可靠得多因为它是基于你已定位的那个节点出发的天然和它同一组。3. Parsel库上手把XPath变成Python代码的桥XPath语法本身是一回事真正在Python里用它又是另一回事。市面上能解析HTML的库不少BeautifulSoup、lxml、Parsel各有拥趸。我为什么在解析与清洗这一章里专门选Parsel来讲有三个原因。3.1 为什么从BeautifulSoup换到Parsel我不是说BeautifulSoup不好它对于偶尔解析一两个页面的人来说确实很友好API简单直接网上代码也多。但爬虫写多了你会发现几个痛点BeautifulSoup的查找APIfind、find_all在写复杂筛选时很啰嗦一层套一层。它默认的解析器在不同环境下行为有差异有时候需要额外安装lxml作为解析器才能正确处理某些畸形HTML。它是汤式API先find再find的链条式写法不如XPath一句来得直观。Parsel设计的哲学正好反着来**它把用什么方式找节点和找到之后怎么处理分得清清楚楚。**底层用lxml解析速度和容错性都有保障又提供了和Scrapy一脉相承的API——你以后如果接触Scrapy会发现Selector的用法完全一致等于提前学了框架的核心技能。我用一个对比让你感受差异。同样是提取商品列表的所有nameBeautifulSoup写出来大概是soup BeautifulSoup(html, lxml) names [div.text for div in soup.select(ul.product-list li div.name)]而Parsel写出来是sel parsel.Selector(texthtml) names sel.xpath(//ul[classproduct-list]/li/div[classname]/text()).getall()看起来好像差不多但XPath的表达能力在于当条件复杂以后它的伸缩性远好于CSS。最典型的例子是找包含特定文本的节点这种需求CSS几乎办不到XPath用contains(text(), xxx)一行搞定。3.2 从text到Selector对象核心三步走Parsel的基本用法真的就三步我建议你把它当成肌肉记忆来练。第一步导入库并创建Selector对象。import parsel selector parsel.Selector(texthtml)这里的text接受的是一段HTML或XML字符串。如果你已经用requests拿到了response.text直接丢进去就行。还有一个容易忽略的细节——parsel.Selector默认会对输入文本做文本规范化处理包括解码、修复不完整的标签等底层靠的就是lxml的容错能力。所以哪怕你拿到的HTML有标签没闭合它一般也能硬解析出来不会像正则那样直接错乱。第二步调用.xpath()方法进行定位。# 返回的是一个list[Selector]对象列表 result selector.xpath(//ul[classproduct-list]/li)这里返回的不是[机械键盘, 无线鼠标]这样的字符串列表而是一个Selector对象的列表。每个Selector代表一个li节点。这个先拿到节点再从节点里提取的思维非常重要因为它天然支持分组提取。第三步用.get()或.getall()真正拿到数据。# get() 返回第一个匹配的结果没有就是None first_name selector.xpath(//div[classname]/text()).get() # getall() 返回所有匹配结果组成的列表 all_names selector.xpath(//div[classname]/text()).getall()这是Parsel和XPath结合时最核心的API分界。新手最容易翻车的地方就是搞不清这两个方法的区别写getall()却只想要一个结果拿到的列表还得自己取[0]或者写get()但页面里有多个匹配结果永远只拿到第一个却不自知。你要养成一个习惯先问自己这个页面上该字段可能出现几次单值用get()多值用getall()。3.3 节点对象再次xpath时路径开头别乱加//这个坑我见得太多次了必须强调。当你拿到了一个Selector节点对象再在它内部做二次查询时路径的写法是有讲究的。items selector.xpath(//ul[classproduct-list]/li) for item in items: name item.xpath(./div[classname]/text()).get() price item.xpath(./div[classprice]/text()).get()注意这里我写的是./div而不是//div。./div的意思是从当前节点出发找它的子节点中的div。而//div的意思是从当前节点出发在它下面所有层级的节点里找div。如果HTML里这个li下面还有更深的嵌套而你又用了//div[classname]它依然能匹配到正确节点好像没出问题。但一旦页面某个li内部多了一个不属于本商品的推荐商品div//就会把那个干扰项也匹配进来让你的数据多抓或者错抓。还有一点**对同一个Selector变量反复调用xpath得到的是新Selector列表不会污染原对象。**所以你可以放心地在一个循环里拆分组再取子字段这比BeautifulSoup在一棵树上反复find更加安全也更容易调试。4. 综合实战抓一个新闻列表页的标题、链接和时间讲了这么多概念我们直接上一个完整的实战。我挑一个典型的新闻列表页结构来模拟因为新闻列表是爬虫练手最常见的场景——结构清晰、字段固定很适合把XPath和Parsel串起来用。页面HTML结构我先贴出来我简化过保留核心骨架!DOCTYPE html html langzh-CN headmeta charsetutf-8title示例新闻列表/title/head body main classcontainer article classnews-item h2 classnews-titlea href/news/20241101-aPython 3.13发布性能提升明显/a/h2 span classdate2024-11-01/span p classsummary新版本引入了若干优化……/p /article article classnews-item h2 classnews-titlea href/news/20241031-b爬虫技术再引热议数据合规成焦点/a/h2 span classdate2024-10-31/span p classsummary行业正在探索更规范的采集方式……/p /article !-- 更多条目…… -- /main /body /html好消息是新闻列表这种一篇文章一个article块的结构非常规整用XPath做分组提取简直不要太顺手。4.1 先拆需求再定XPath别急着写代码很多人写爬虫的习惯是打开页面右键复制XPath粘贴进代码跑一下看结果。我强烈建议你先花两分钟在纸上把目标字段和页面结构对应起来。以这个列表为例目标字段有三个目标字段页面结构特征定位思路标题h2 classnews-title下的a文本先定位article再找h2里的a的text链接同一个a的href属性先定位article再取a的href发布时间span classdate的文本先定位article再取span的text关键决策点在于到底是以全部字段全局搜索来写XPath还是先分组定位到article再在每组内部取字段我用后一种方案。逻辑也很好理解目标是每篇文章的标题、链接、时间这三者天然属于同一个article节点。如果全局写三个互不相干的XPath一旦某个article缺了时间三个列表的长度就对不齐数据就错位了。先分组再提取等于把打包一条记录这件事交给了解析器天然对齐。对应的XPath就是定位到所有article//article[classnews-item]每组内部取标题./h2[classnews-title]/a/text()每组内部取链接./h2[classnews-title]/a/href每组内部取时间./span[classdate]/text()这种写法非常稳定。如果页面结构变了比如标题外面又多套了一个div你只需要改这一项的路径其他项完全不受影响。4.2 完整代码从HTML字符串到结构化数据下面是完整的Parsel解析代码我把注释写得密一点方便你对着上面HTML看import parsel html open(news_list.html, encodingutf-8).read() selector parsel.Selector(texthtml) # 第一步定位到所有新闻条目节点 items selector.xpath(//article[classnews-item]) # 第二步遍历每个节点提取所需字段 results [] for item in items: # 标题取a标签内的文本 title item.xpath(./h2[classnews-title]/a/text()).get() # 链接取a标签的href属性 link item.xpath(./h2[classnews-title]/a/href).get() # 发布时间取span标签的文本 publish_date item.xpath(./span[classdate]/text()).get() # 拼接URL页面里是相对路径需要补全 if link and not link.startswith(http): link https://example.com link results.append({ title: title.strip() if title else None, link: link, publish_date: publish_date.strip() if publish_date else None, }) # 打印结果 for item in results: print(item)这里有几个清洗细节我要展开讲因为它们直接影响最终数据的质量。**第一个细节strip()处理空白。**从HTML标签里拿出来的text节点经常会带着缩进空格、换行符尤其是用getall()批量提取时一堆\n和空格混在结果里看着头大。我习惯在拿到文本后立刻做strip()。但注意如果你直接用text()提取它返回的是该元素的直接文本节点不会再往下递归。如果想提取某个元素的全部文本包括子标签里的文字要用string(.)或者//*[text()]的组合这个后面单独讲。**第二个细节相对URL的拼接。**新闻站点的链接设计成相对路径很常见。你在浏览器里看起来是完整可点的因为浏览器自动帮你基于当前页面URL补全了。但爬虫拿到的就是光秃秃一个/news/20241101-a你不处理落库就是一条无效链接。最简单的处理方式是准备一个base_url手工startswith(http)判断后拼上去。更健壮的方式是交给urllib.parse.urljoin()它能处理各种奇怪的相对路径写法from urllib.parse import urljoin base_url https://example.com full_link urljoin(base_url, link)urljoin的好处是如果link已经是完整绝对URL它会原样返回如果是//static.example.com/logo.png这种协议相对地址它也能正确处理。爬虫里处理链接我几乎不用字符串拼接全部交给urljoin。**第三个细节字段缺失时的兜底策略。**这里比较关键。item.xpath(...).get()在匹配不到内容时返回的是None而不是报错。上面代码里我用了title.strip() if title else None这种写法就是为了避免None.strip()直接抛AttributeError。如果你是在写批量入库的爬虫建议所有字段都按这个模式处理宁可把缺失字段记为None也不要让一个坏数据打崩整条流水线。如果字段在页面里可能出现多种情况比如有的文章没有摘要但你想把摘要也抓下来这时候有一个更优雅的处理让XPath替你做默认值判断。summary item.xpath(./p[classsummary]/text()).get(default).get()方法支持default参数匹配不到时返回你指定的默认值比先判断再给默认少写好几行。这也是Parsel API里一个很良心的设计。4.3 结果验证打印出来用眼睛核对一遍再入库代码跑完输出大致长这样{title: Python 3.13发布性能提升明显, link: https://example.com/news/20241101-a, publish_date: 2024-11-01} {title: 爬虫技术再引热议数据合规成焦点, link: https://example.com/news/20241031-b, publish_date: 2024-10-31}很多新手到这一步就急着写数据库存储代码了。我的建议是**先别往数据库里灌把解析结果打印出来人肉检查一遍。**我见过太多案例解析逻辑看着对结果打印出来字段错位、时间格式不统一、标题里混入了分类名这种垃圾数据入库之后再来清洗成本翻好几倍。在解析结果输出这一步多花两分钟你会发现后面清洗环节省下来的是大把时间。5. 我踩过的解析坑空列表、动态加载和边界问题到这一节我想把调试XPath和Parsel时真正踩过的坑集中讲一遍。很多坑不是语法问题而是想当然问题。我一个个说。5.1 返回空列表的几种可能先怀疑结构再怀疑语法如果你在写Parsel代码时发现xpath()返回了空列表或者get()返回了None别急着觉得自己XPath写错了。按照我下面这个顺序排查命中率极高第一HTML结构里是不是根本没有你要找的标签有时候你眼睛在浏览器里看到了内容但那是JS动态渲染出来的。requests直接拿到的源码里压根没有这些标签。验证方法很简单在Python里把html内容打印前500个字符或者存成一个文件用编辑器打开搜一下你要找的class名搜不到就说明静态源码里没有XPath必然空。第二你的XPath是不是被空格或引号坑了我犯过最蠢的错误就是复制了浏览器生成的XPath里面带了tbody这种浏览器自动补全的标签但原始HTML里根本没写tbody结果怎么匹配都是空。还有一类常见错误是属性值里有单引号、双引号混用XPath表达式是用引号包属性值的如果你的属性值里有同款引号整条表达式就断了。遇到这种情况可以试试用双方括号配合concat()不过这属于进阶新手可以先把单双引号统一成一种。第三节点是不是在iframe里这是一个极其隐蔽的坑。有些页面会把内容塞进iframe子框架里浏览器里看起来是正常内容但requests拿到的主HTML里只有一个iframe src...标签真正的数据在src指向的另一个页面里。这种情况你需要先拿到iframe的src再发起一次请求去解析子页面。XPath和Parsel处理不了跨文档的内容这是结构限制不是你的代码问题。第四你的text()用对了没有text()匹配的是直接的文本节点。如果你要提取的文本被span、b之类内联标签分割了text()只能拿到一段段碎片get()拿到的是第一段碎片看起来像是丢了内容。这时候建议改用string(.)或者normalize-space(.)来提取当前节点的全部字符串内容。# 提取div下的全部文本包括子标签里的并压缩空白 full_text item.xpath(string(./div[classcontent])).get().strip()注意哈string(.)返回的是一个字符串不是一个节点列表。想拿全部文本又保持段落结构这个方法是比较省力的。5.2 动态加载内容XPath的边界得承认这是新手最容易产生我的代码坏了错觉的地方。很多异步渲染的页面比如商品评论、瀑布流列表、微博时间线requests拿到的HTML里只有一坨初始化代码和空壳容器内容全是JS跑完后才塞进去的。你用XPath去解析静态HTML当然什么都拿不到——不是XPath不好用是货还没上架。遇到这类页面你首先想到的应该是数据在不在接口里。打开浏览器开发者工具的Network面板刷新页面找到XHR请求看看有没有返回JSON或HTML片段的数据接口。如果有直接请求那个接口用requests去拿JSON比用Selenium/Playwright渲染整个页面轻量得多。这是最优雅的方案可惜很多新手不知道。如果确实找不到接口或者页面逻辑太复杂没法模拟那就只能上浏览器渲染方案比如Selenium或Playwright等页面加载完成后再通过page.content()把渲染后的HTML拿出来然后再交给Parsel去解析。要注意的是此时的HTML是浏览器渲染后的快照里面会有很多JS注入的属性XPath匹配时相对更宽松一般用//div[classcomment-item]这种抓特征的写法依然有效。我特意把动态加载这个坑放在这里是因为它和XPath本身的坑性质完全不同——前者是数据源问题后者是定位语法问题。排查问题的第一步永远是定位问题层级先确认数据在不在再考虑解析方式。5.3 解析结果里全是\n和空格用normalize-space做兜底清洗这一节叫解析与清洗解析完之后清洗往往是个被低估的环节。你用text()拿到的字符串常常附带缩进、换行、连续空格。比如上面p classsummary里的文本如果在HTML里换行了提取出来就会变成\n Python 3.13版本在性能上有显著提升……\n。手动strip()只能去掉首尾中间的换行和多余空格它管不了。这里有一个非常推荐的XPath函数normalize-space()。它会做三件事去掉首尾空白、把连续多个空白字符压缩成一个空格、把换行符变成普通空格。# 一个表达式同时完成文本定位和清洗 summary item.xpath(normalize-space(./p[classsummary])).get()normalize-space()接收一个节点作为参数返回清洗后的字符串。这样你从XPath拿到的就是干干净净的句子不需要再回Python里做二次re.sub(r\s, , text)操作。我在写爬虫时凡是提取类似段落文本、摘要、描述这类内容都会优先用normalize-space()它把提取和清洗一步到位。那是不是所有字段都用normalize-space()更好也不是。比如你要提取的关键词列表本身就是用空格分隔的或者你要保留内容的换行段落结构那就不适合用这个函数。清洗方案要跟着业务需求走不是一套逻辑打天下。5.4 调试XPath的习惯学会在浏览器Console里用$x()初筛最后分享一个调试层面的提速技巧。在Chrome的Console里你可以直接输入$x()来测试XPath表达式不需要写任何Python代码。比如你想确认//article[classnews-item]/h2[classnews-title]/a/text()能不能匹配到标题打开Console输入$x(//article[classnews-item]/h2[classnews-title]/a/text())回车之后浏览器会列出所有匹配的节点文本。不行就改改到满意为止。这个方法的优势是零成本迭代——你不用每次改完都跑一遍Python脚本而且浏览器会基于真实的DOM树来判断结果非常可靠。等表达式在Console里验证OK了再粘回Parsel代码里命中率会高很多。还有一个我常用的技巧如果XPath里含有双引号在Console里就别用双引号包整个表达式了省得转义。你可以写成$x(//article[classnews-item]/h2[classnews-title]/a/text())属性值用单引号外层用双引号一眼就能看清不会乱。这个小习惯在写复杂XPath的时候特别管用。实际写解析代码这么多年我最大的体会是**XPath是一个一次学会终身受用的技能但前提是你别把它当成一套死语法去背而是当成一种描述位置的思维方式。**而且Parsel和XPath的组合不只在requests这个小场景里有用你以后写Scrapy爬虫、处理XML配置、甚至解析SVG向量数据这套技能全部都能复用。这一节能把先分组定位再逐字段提取的思维练熟后续接触复杂的嵌套页面时会顺很多。
返回列表