如何使用Python结合lxml抓取Wikipedia任意表格(含合并单元格)并转换为指定格式?
我最近在折腾一个需求——用Python从维基百科抓取表格数据,还要把对机器不友好的HTML表格转成自定义的结构化格式(比如元组列表,后续可以轻松转成JSON)。最开始我针对某个固定结构的表格写了专用代码,但遇到合并单元格的复杂表格就卡壳了,试过pandas但实在不合心意,最后还是靠优化lxml的代码解决了问题,今天就把整个过程分享给大家。
一、最初的专用抓取代码(针对固定结构表格)
最开始我以维基的「Unicode区块」页面为例,写了一段针对该表格的lxml代码,能精准提取十六进制范围、区块名称、已分配字符数等信息,完全符合我的需求:
import re import requests from lxml import html res = requests.get('https://en.wikipedia.org/wiki/Unicode_block').content tree = html.fromstring(res) UNICODE_BLOCKS = [] for block in tree.xpath(".//table[contains(@class, 'wikitable')]/tbody/tr/td/span[@class='monospaced']"): codes = block.text start, end = (int(i[2:], 16) for i in codes.split('..')) row = block.xpath('./ancestor::tr')[0] block_name = re.sub('\n|\[\w+\]', '', row.find('./td[3]/a').text) assigned = int(row.find('./td[5]').text.replace(',', '')) scripts = row.find('./td[6]').text_content() if ',' in scripts: systems = [] for script in scripts.split(', '): i = script.index('(') name = script[:i-1] count = int(script[i+1:].split(" ")[0].replace(',', '')) systems.append((name, count)) else: systems = [(scripts.strip(), assigned)] UNICODE_BLOCKS.append((start, end, block_name, assigned, systems))
这段代码确实能拿到我要的数据,但问题也很明显:只能处理这个结构固定的表格,遇到合并单元格的表格(比如该页面的第二个表格、Lindsey Stirling的单曲表)就彻底失效了。
二、为什么我放弃了pandas方案?
我之前看过不少人推荐用pandas.read_html来抓维基表格,为了做对比我特意装了pandas(还被迫装了html5lib和beautifulsoup4),但试了之后发现问题一堆:
- 报错且不灵活:我写的测试代码直接抛出
ValueError: No tables found,就算能跑,也没法精准控制提取哪部分表格; - 数据不纯净:会保留无用的表头行、末尾的非数据行,还带各种引用标记,需要额外清理;
- 速度太慢:这是最不能忍的!我做了基准测试:
- pandas处理Unicode表格的时间:
47.6 ms ± 577 μs per loop - 我最初的lxml代码时间:
32.4 ms ± 472 μs per loop
而且pandas生成的DataFrame还要转成我要的格式,又多了一层耗时,完全没必要。
- pandas处理Unicode表格的时间:
当时的测试代码和报错如下:
import pandas as pd import requests from lxml import etree, html res = requests.get('https://en.wikipedia.org/wiki/Unicode_block').content tree = html.fromstring(res) pd.read_html(etree.tostring(tree.xpath(".//table[contains(@class, 'wikitable')]/tbody")[0]))[0]
报错:
ValueError: No tables found
三、优化后的lxml方案(更快更精准)
既然pandas不合心意,我就对自己的lxml代码做了优化,减少了不必要的ancestor查询,直接定位有效数据行,速度又提升了一截:
import re import requests from lxml import html res = requests.get('https://en.wikipedia.org/wiki/Unicode_block').content tree = html.fromstring(res) clean = re.compile(r'\n|\[\w+\]') UNICODE_BLOCKS = [] rows = tree.xpath(".//table[contains(@class, 'wikitable')][1]/tbody/tr") for row in rows[2:-1]: # 跳过无用的表头和末尾行 start, end = (int(i[2:], 16) for i in row.find('./td[2]/span').text.split('..')) block_name = clean.sub('', row.find('./td[3]/a').text) assigned = int(row.find('./td[5]').text.replace(',', '')) scripts = row.find('./td[6]').text_content() if ',' in scripts: systems = [] for script in scripts.split(', '): i = script.index('(') name = script[:i-1] count = int(script[i+1:].split(" ")[0].replace(',', '')) systems.append((name, count)) else: systems = [(scripts.strip(), assigned)] UNICODE_BLOCKS.append((start, end, block_name, assigned, systems))
优化后的代码耗时:26.4 ms ± 550 μs per loop,比pandas快了近一半!
四、处理合并单元格的通用lxml方案
最头疼的合并单元格问题,其实核心是利用lxml的节点定位能力和处理rowspan属性:合并的单元格会带rowspan属性,代表跨多少行,我们需要把这个单元格的内容填充到下面的空行里。
以Lindsey Stirling的单曲表为例,目标是提取成结构化的元组列表,对应的lxml处理代码如下:
import re import requests from lxml import html res = requests.get('https://en.wikipedia.org/wiki/Lindsey_Stirling_discography').content tree = html.fromstring(res) # 精准定位单曲表(通过表头的"Title"字段) singles_table = tree.xpath(".//table[contains(@class, 'wikitable') and .//th[text()='Title']]/tbody")[0] rows = singles_table.xpath('./tr') # 处理合并单元格的辅助变量:记录跨行列的内容和剩余行数 prev_span_data = {} singles_list = [] # 清理文本的正则:去掉引用标记、换行和多余空格 clean_re = re.compile(r'\[\d+\]|\n|\s{2,}') for row in rows[1:]: # 跳过表头行 cells = row.xpath('./td') current_row = prev_span_data.copy() # 继承上一行未结束的跨行列内容 for cell_idx, cell in enumerate(cells): # 获取单元格的跨行数,默认1 rowspan = int(cell.get('rowspan', 1)) # 清理单元格文本 cell_text = clean_re.sub('', cell.text_content().strip()) # 对应字段:0=单曲名,1=年份,2=所属专辑 if cell_idx == 0: current_row['title'] = cell_text elif cell_idx == 1: current_row['year'] = cell_text elif cell_idx == 2: current_row['album'] = cell_text # 如果跨行数>1,保存到辅助变量,后续行继续使用 if rowspan > 1: prev_span_data[cell_idx] = (cell_text, rowspan - 1) # 如果该位置之前有跨行列记录,且当前行已覆盖,就删除 elif cell_idx in prev_span_data: del prev_span_data[cell_idx] # 把当前行转成目标元组,加入结果列表 singles_list.append( (current_row['title'], current_row['year'], current_row['album']) ) # 更新辅助变量:剩余跨行数减1,用完的就删掉 to_remove = [] for idx, (text, remaining) in prev_span_data.items(): if remaining == 1: to_remove.append(idx) else: prev_span_data[idx] = (text, remaining - 1) for idx in to_remove: del prev_span_data[idx] # 输出结果就是我们要的结构化格式 print(singles_list)
五、通用抓取步骤总结
用lxml处理维基表格(包括复杂的合并单元格表格),通用步骤是:
- 获取并解析页面:用requests(或aiohttp)拉取页面,用
lxml.html.fromstring转成DOM树; - 精准定位表格:通过
class='wikitable'结合表头文本写xpath,避免抓错表格; - 处理合并单元格:用辅助变量记录跨行列的内容和剩余行数,遍历行时继承上一行的跨行列数据;
- 清理与提取:用正则去掉无用标记,提取目标字段,转成需要的结构化格式;
- 性能优化:尽量用精准的xpath减少节点遍历,避免重复的
ancestor/descendant查询,提升速度。
备注:内容来源于stack exchange,提问作者Ξένη Γήινος

