ARTICLE DETAIL

资讯详情

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

DOM解析与XPath提取:从HTML到结构化数据的文本挖掘指南

DOM解析与XPath提取:从HTML到结构化数据的文本挖掘指南 做文本挖掘的人大概率都碰到过这样的场景费了大力气把一个网站的内容抓下来以为拿到的是干净文本结果打开一看满屏div、span、class正文淹没在标签海里。网上一提到网页动辄就说“这是非结构化数据”这话其实只说对了一半。网页从字面上确实是非结构化文本但浏览器能把它画成漂亮的页面爬虫能从中精准抽出标题和正文底层靠的是同一个东西——文档对象模型DOM。这篇内容想和你聊的正是文本挖掘流程里这个最不起眼、却也最绕不开的环节DOM到底怎么把一团乱麻的HTML变成可定位、可提取、可计算的结构以及我们实际做项目时该怎么用好它。1. 为什么文本挖掘绕不开DOMHTML是“带结构的非结构化数据”1.1 网页文本和纯文本的本质区别文本挖掘这门手艺面对的数据源很杂。纯文本文件、邮件、聊天记录、Excel导出的CSV各有各的麻烦。网页HTML在其中非常特殊它表面是字符流但它身上背着标签、属性、嵌套关系。这句话理解到位了后续很多问题都能迎刃而解。拿一篇新闻报道和它的HTML源码比一下。你在页面上看到的标题“某企业发布新一代智能文本处理平台”在源码里长这样h1 classarticle-title某企业发布新一代智能文本处理平台/h1从纯文本角度标题就是那串字符但从结构角度h1标签说明了它是一级标题classarticle-title给出了它的语义角色。换句话说HTML在书写时就自带元信息只是这些信息被藏在标签里不解析就用不上。所以业内把HTML称作“半结构化数据”是有道理的。它既不像数据库表那样有严格字段也不像普通文本那样毫无线索。而文档对象模型就是把HTML这种半结构化文本翻译成一棵有组织、有血缘关系的树。一旦有了树你就能回答这些问题这段文字是不是标题它属于哪个区块旁边那块内容是不是广告这些都是纯文本层面上回答不了的。1.2 正则硬抠和DOM提取的差距刚入门的同学容易想我直接用正则匹配正文行不行简单页面上确实行正则写起来快出结果也快。但只要你处理超过三五十个页面或者页面稍微复杂一点正则方案就会开始崩原因有三个第一HTML标签在真实站点里的变化极其自由class名会变div和p会混用属性顺序也会变。你今天写好的正则下周可能就失效。第二网页里混着脚本、样式、注释、广告位、评论区、推荐列表正则一次只能匹配一种模式很难做全局排除。你要么手工设计很多条规则去过滤要么就得承认漏网之鱼太多。第三也是最重要的一点正则表达不了“这段文字在哪个标签下、和旁边的节点是什么关系”这种结构性条件。而文本挖掘里判断正文和噪音靠的恰恰就是结构正文通常在article里广告通常在aside或带ad类名的容器里版权信息通常在footer里。放弃结构等于放弃最关键的信号。DOM方案的做法完全不同先把整段HTML解析成一棵树之后用选择器或路径表达式定位目标节点再从容地取文本。结构条件直接写进查询表达式里改版时只需要调整路径而不是重写整个匹配逻辑稳定性和可维护性都高出一大截。1.3 DOM不是某个语言专属的浏览器和爬虫共用一套规则DOM全称Document Object Model是W3C制定的平台接口标准它不是Python、Java或JavaScript专属的东西。浏览器的核心工作之一就是把HTML解析成DOM树再在DOM树上做布局和渲染。爬虫里的解析库本质也是在构建同一棵树。这意味着一个很实用的事实你在浏览器开发者工具里看到并定位到的那个节点和你在Python里用XPath或CSS选择器定位到的节点是同一个东西。所以排查提取不到数据的问题时第一件事永远是打开浏览器F12确认目标内容在当前页面的DOM树里长什么样、被哪一层容器包着然后再去写代码。很多新手直接凭肉眼猜测结构写出来的选择器自然全是试错。2. DOM的骨架节点、树与遍历顺序2.1 节点类型决定你取到的是什么DOM树由节点构成每个节点都有类型。实际做提取时你至少得分得清这几种Document节点整棵树的根对应整个文档类型编号是9。Element节点元素节点html、body、div、p、h1这些都是类型编号是1。Text节点真正承载文本内容的是它而不是Element本身类型编号是3。Comment节点HTML注释类型编号是8提取正文时通常需要过滤。Attribute节点属性在DOM接口中有自己的抽象类型编号是2。这个区分不是学术概念它在代码里有直接后果。比如在lxml里你对一个h1元素调用text_content()得到的是它内部所有所有Text节点的拼接但如果你只访问element.text得到的只是第一个直接文本子节点其余文本可能在子标签里。用错API结果差之千里。我见过不少新手以为“div的文本就是div.text”结果拿到None或者残缺内容就是这个原因。2.2 父子、兄弟关系和深度优先遍历DOM树的节点之间有明确关系父子、兄弟、祖先与后代。标准里有一组属性来描述parentNode、childNodes、firstChild、lastChild、previousSibling、nextSibling。在lxml里对应的是getparent()、getnext()、getprevious()以及iter()、iterdescendants()这类遍历方法。这里有个顺序规律需要留意DOM的遍历默认按深度优先前序进行也就是先访问根再访问子树下的每一个节点之后再轮到兄弟节点。浏览器渲染时的解析顺序、XPath返回节点的顺序、还有你用iter()遍历时的顺序都遵循这个规则。写“取第几个节点”这种逻辑时搞不清楚遍历顺序十有八九会取错目标。iter()方法在做全文档扫描时非常好用。比如想找出页面中所有p标签下的文本可以遍历整棵树然后按标签名过滤。这比手工从根节点一层层走下来省事得多。2.3 属性节点与元素节点的区别刚接触DOM时我一直默认“属性是元素的子节点”后来调试一个数据抓少了的bug才彻底改过来。在DOM的标准结构里Attribute并不属于childNodes序列它更像是挂在元素旁边的元数据。这个认知直接关系到代码怎么写元素的子节点只包含Element、Text、Comment等类型Attribute不在其中。想拿属性值得用getAttribute()或者在XPath里显式写href、class。一个元素有多个属性时它们之间没有可依赖的顺序。如果没搞懂这一点写XPath时会犯一类低级错误想取class值却写成//div/text()这类式子或者天真地遍历子节点去寻找属性信息自然什么都拿不到。3. 从DOM到可分析文本解析器选型和三种提取粒度3.1 html.parser、lxml、html5lib怎么选Python生态里把HTML字符串变成DOM树有三条常用路线实际差别很大。html.parser是标准库自带零依赖但解析速度一般遍历能力也弱适合需求极简单的场景。lxml.html底层是C实现的解析速度非常快XPath和CSS选择器支持都很完善是我在爬虫和文本抽取任务里的首选。html5lib严格遵循HTML5规范的解析算法容错能力和现代浏览器几乎一致适合页面标签错误很多的场景但速度偏慢项目里不常用。还有一种常见组合是BeautifulSoup作为API层解析器用lxml。BeautifulSoup的接口对新手友好代码读起来舒服代价是封装层带来的性能损耗。我在小项目和团队协作时总的来说更倾向于直接用lxml.html代码也不复杂速度还快一截。大规模抓取时这个效率差距会被放大得非常明显。3.2 XPath与CSS选择器的分工DOM树提取的核心可以归纳成“先定位、再取值”。定位手段里最常用的是XPath和CSS选择器。XPath是XML/HTML世界里的路径语言表达能力强。//article//h1/text()这类表达式能直接从任意层级找到目标。它最擅长表达“当前节点自身条件”和“祖先/后代间的条件关系”这是CSS选择器做不太好的部分。CSS选择器则是从浏览器前端生态来的比如.article-title、#content p写起来短可读性好在BeautifulSoup的select()和lxml的cssselect()里都能用。但遇到“父节点满足某个条件、子节点还要满足另一个条件”这种需求写起来就比较绕。工程上我的习惯是统一用XPath原因只有一个它表达力最强不需要在两种语法之间来回切换。团队里如果前端出身的人多CSS选择器更好上手可以按实际情况定规矩。3.3 整块文本、规则分块与结构化序列定位到节点之后根据下游任务不同取文本有三种粒度。第一种是整块文本。比如想拿整个article元素里的可见文字直接用text_content()lxml或get_text()BeautifulSoup。这个方式最快但有个隐蔽的坑它会把内部所有文本无缝粘连丢失段落边界。你拿到了内容却不知道哪句话属于哪一段。第二种是规则分块。用XPath逐条取文本比如//div[classcontent]/p/text()这样一段一个元素段落边界清晰。做正文清洗、转训练语料时这种粒度最常用。第三种是结构化序列把每个文本节点连同它的标签、路径、前后兄弟关系一起取出。这等于把DOM树转成半结构化数据适合做特征工程或者信息抽取。实际项目里我通常先定位正文区块再遍历其下的p、li、pre逐块取文本最后再拼接。这样既能保留段落结构清洗起来也方便。4. 实战从新闻页抽取标题、正文、作者和发布时间4.1 准备一个带干扰元素的实验页面理论说了这么多不如直接跑一个例子。假设你遇到一个典型新闻页结构如下html body article classnews-article header h1 classarticle-title某企业发布新一代智能文本处理平台/h1 div classmeta span classauthor王小明/span time datetime2024-11-20T10:30:002024-11-20 10:30/time /div /header div classcontent p第一段该平台基于自然语言处理技术实现了从文本采集到信息抽取的自动化流程。/p p第二段平台的核心能力包括文档解析、实体识别、关系抽取和知识图谱构建。/p p第三段相关负责人表示该平台将在文本挖掘领域持续迭代。/p /div div classad广告点击购买/div section classcomments p评论区占位文本不属于正文。/p /section script var a 这个不应该被提取; /script footer版权声明/footer /article /body /html里面除了真正的正文还有广告、评论区、脚本、版权声明。在真实页面上干扰元素会比这多得多但逻辑是一样的先定位到主容器再提取目标字段而不是把整个页面都当正文。4.2 lxml解析与XPath提取过程用lxml实现这个提取代码非常简洁import lxml.html import json html_doc ...上面的HTML... root lxml.html.fromstring(html_doc) article root.xpath(//article[contains(class,news-article)])[0] title article.xpath(.//h1[classarticle-title]/text())[0].strip() author article.xpath(.//span[classauthor]/text())[0].strip() time_nodes article.xpath(.//time/datetime) publish_time time_nodes[0].strip() if time_nodes else content_paras article.xpath(.//div[contains(class,content)]/p/text()) content \n.join(p.strip() for p in content_paras) result { title: title, author: author, publish_time: publish_time, content: content } print(json.dumps(result, ensure_asciiFalse, indent2))几个细节值得展开说。//article[contains(class,news-article)]这一步把定位范围先限制在新闻主容器内之后所有表达式都写成相对路径点开头。这样做可以防止页头导航、侧边栏、页脚里相似结构干扰结果。如果用全局绝对路径很容易误捕页面其他区域的同名标签。取发布时间时我取的是datetime属性而不是标签内的可见文本。原因是time标签的可见文本是“2024-11-20 10:30”这种展示形式而datetime属性通常是机器可读的ISO 8601格式。做文本挖掘下游任务时机器可读格式更方便排序和过滤。取正文段落时用p/text()逐段取文本再用换行符拼接。这和直接text_content()不一样前者保留了段落边界后者会把所有文字粘成一大坨。下游做情感分析、关键词抽取时段落边界对结果有实际影响。4.3 缺字段、空值、类型异常的兜底真实页面不可能都这么标准。有的页面没有作者有的没有发布时间有的正文可能一棵段落都没有。上面代码里author article.xpath(...)[0]如果列表为空会直接抛IndexError。工程上比较稳的做法是封装一个小函数def first_text(node, xpath_expr, default): results node.xpath(xpath_expr) if not results: return default text results[0].strip() if isinstance(results[0], str) else results[0].text_content().strip() return text or default然后用它去取每个字段。这样遇到缺字段时返回空字符串程序继续往下跑不会因为一个页面的小瑕疵中断整个批次。批处理文本挖掘任务时这种健壮性比代码“看起来漂亮”重要得多。5. 生产环境里最容易翻车的六个细节及对策5.1 JavaScript动态渲染让DOM“没有内容”用requests直接拉页面再解析DOM拿到的是服务端返回的原始HTML。但如果页面内容是JavaScript在浏览器里动态渲染出来的比如用React、Vue写的SPA那么原始HTML的DOM树里可能根本没有正文只有几个空的div挂载点。对策分两步判断。第一步先用浏览器开发者工具看“查看网页源代码”里有没有正文如果没有右键检查里却能看到正文说明内容是动态加载的。第二步按成本从低到高挑方案优先找页面背后有没有返回JSON的数据接口很多站点的文章详情实际有隐藏的API直接请求JSON比渲染浏览器轻量得多找不到接口再考虑用Playwright或Selenium控制真实浏览器渲染让JS执行完再取DOM。实测下来能走JSON接口的尽量走JSON接口浏览器渲染方案资源开销大且容易被风控识别。5.2 页面编码声明与实际编码不一致解析DOM前编码问题必须先解决。requests的response.encoding有时会根据HTTP头推断但HTTP头声明的编码不一定和页面内实际一致。有的服务器头里写ISO-8859-1页面meta里却声明UTF-8导致正文全是乱码。我现在的处理顺序是先看响应头里的Content-Type再看HTML源码里meta charset...的声明最后才用apparent_encoding兜底。注意一点apparent_encoding是通过内容统计猜编码文本较短时猜不准所以别一上来就依赖它。更省事的办法拿到字节流后先用requests的response.content保存原始字节自己按规则解码避免响应对象内部的编码猜测带来二次麻烦。5.3 标签不规范导致解析结果不一致真实网页的HTML经常是不规范的比如标签没闭合、属性值不加引号、p里嵌套了div。不同解析器对这些问题的容忍度和修正方式不一样这就导致同一份HTML在不同解析器里得到的DOM树形状可能不同。html5lib严格按浏览器规范修正结构结果最接近Chrome的渲染结果但慢。lxml.html基于libxml2的HTML解析器也做了容错但在某些特殊结构上可能和浏览器表现不一致。遇到明明是合法的HTML解析后却发现节点层次不对的情况优先怀疑解析器的容错差异。换解析器试试很多“灵异事件”立刻就有了解释。5.4 脚本、样式与注释混进正文用text_content()拿整块文本时script和style里的内容也会被当作文本捞出来。JavaScript代码、CSS规则会被当成正文结果极脏。解法是在取文本之前先遍历目标节点把不需要的标签移除for selector in [.//script, .//style, .//comment()]: for node in article.xpath(selector): parent node.getparent() if parent is not None: parent.remove(node)注意注释节点在XPath里要写成.//comment()它不是元素节点直接按标签名过滤不到。这段操作完之后再走文本提取数据干净得多。5.5 大页面和批量任务的性能问题单个页面解析慢拖的是整个采集任务的后腿。这里有两个优化点非常实用。第一避免在循环里重复编译XPath。lxml每次调用xpath()时内部都会解析表达式批量处理时这个开销很可观。可以提前用etree.XPath编译from lxml import etree article_xpath etree.XPath(//article[contains(class,news-article)]) title_expr etree.XPath(.//h1[classarticle-title]/text()) for html_text in html_list: root lxml.html.fromstring(html_text) article article_xpath(root)[0] title title_expr(article)[0]第二超大页面整体解析占内存。一个几十MB的列表页整棵树建出来很吃资源。这种场景可以考虑能不能只截取目标区域或者用iterparse做流式解析避免一次性加载整棵DOM树。5.6 选择器脆弱改版即崩依赖具体class名的选择器风险很大。很多站点前端框架会自动生成带哈希后缀的类名比如classcss-1a2b3c今天能用明天重构就全变了。更稳的策略是分层第一优先用HTML语义标签article、main、h1、time这类语义标签比div加随机类名稳定得多。第二优先用带业务含义的稳定属性比如itemprop、>
返回列表