🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:
-
- 开篇语
- 上期回顾
- 为什么表格采集这么常见?
- 表格采集的三大难点
- 实战思路:四步通用解法
- 代码实战:通用表格解析器
- 完整工程化Demo------终极版
- 代码实现逻辑说明
- 实战案例演示
-
- [案例1: 统计局GDP数据表](#案例1: 统计局GDP数据表)
- [案例2: 证券交易所公告列表(跨页)](#案例2: 证券交易所公告列表(跨页))
- 常见问题与解决方案
-
- [Q1: 表格选择器怎么写?](#Q1: 表格选择器怎么写?)
- [Q2: 如何处理表格里的超链接?](#Q2: 如何处理表格里的超链接?)
- [Q3: 表格数据有千位分隔符怎么办?](#Q3: 表格数据有千位分隔符怎么办?)
- [Q4: 分页URL规则不规范怎么办?](#Q4: 分页URL规则不规范怎么办?)
- [Q5: 表格太大,内存爆了怎么办?](#Q5: 表格太大,内存爆了怎么办?)
- 性能优化建议
-
- [1. 并发采集(小心别被封)](#1. 并发采集(小心别被封))
- [2. 缓存HTML(避免重复请求)](#2. 缓存HTML(避免重复请求))
- [3. 增量采集(只抓新增部分)](#3. 增量采集(只抓新增部分))
- 总结与最佳实践
- 下期预告
- 练习作业
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
上期回顾
上一讲《动态站点"回到接口":识别接口并用 Requests 重写(更稳)!》我们学会了从动态网站里"抠出"真正的数据接口,用 Requests 替代 Playwright,速度直接起飞。不过接口不是万能的,有些网站(尤其是政府、统计、金融类)压根不走接口,数据就是硬生生用 <table> 标签渲染在页面上的。
今天咱们就来攻克这个场景:表格型页面采集。
说实话,表格数据看着规整,但实际采集起来坑不少:列对不齐、合并单元格、跨页拼接、空值满天飞......新手经常采到一半就懵了。
别慌,今天我教你写一个通用表格解析器,学会之后,再复杂的表格都是"一把梭"的事儿
为什么表格采集这么常见?
先说说应用场景,你肯定遇到过这些:
- 统计局数据:GDP、人口、工业产值,全是表格
- 证券网站:股票行情、财报数据、公告列表
- 招投标平台:中标公示、项目清单
- 学术网站:论文列表、引用统计
- 电商后台:订单列表、库存表
这些数据的共同点:结构化程度高,但不提供接口。你要么手动复制粘贴(想想就头疼),要么写脚本自动提取。
咱们今天就是要把这个"体力活"彻底自动化。
表格采集的三大难点
难点1:列头与数据对应
有些表格的 <thead> 和 <tbody> 分离得很清楚,有些却混在一起,甚至列头在第二行、第三行。你得先搞清楚"哪一行是列头",否则数据全乱套。
难点2:合并单元格
HTML 里用 rowspan 和 colspan 实现合并单元格。比如:
html
<td rowspan="3">2024年</td>
这个单元格占了 3 行,解析时你得"填充"下面的空缺,否则导出的 CSV 就对不齐。
难点3示,你得翻页采集,然后把多页数据合并成一个完整的表格,还得确保列名一致。
实战思路:四步通用解法
第一步:定位表格元素
用浏览器 F12 检查表格,找到对应的选择器。通常是:
python
table = soup.find("table", class_="data-table")
或者用 CSS 选择器:
python
table = soup.select_one("table.data-table")
小技巧:如果页面有多个表格,加更精确的定位,比如:
python
table = soup.select_one("#main-content > table:nth-of-type(2)")
第二步:提取列头
找到 <thead> 或者第一行 <tr>,提取所有 <th>:
python
headers = [th.get_text(strip=True) for th in table.find("thead").find_all("th")]
如果没有 <thead>,就从第一行 <tr> 里找:
python
first_row = table.find("tr")
headers = [td.get_text(strip=True) for td in first_row.find_all(["th", "td"])]
第三步:提取数据行
遍历 <tbody> 里的所有 <tr>,每行提取所有 <td>:
python
rows = []
for tr in table.find("tbody").find_all("tr"):
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
rows.append(cells)
第四步:处理合并单元格(可选)
如果表格有合并单元格,需要"填充"逻辑。思路是:
- 遇到
rowspan > 1的单元格,记录它的值 - 在接下来的 N-1 行里"补充"这个值
这部分比较复杂,咱们下面用代码详细讲。
代码实战:通用表格解析器
先上一个基础版本,不处理合并单元格,适合 90% 的简单表格。
版本A:基础表格提取器
python
# table_extractor_basic.py
import requests
from bs4 import BeautifulSoup
import csv
from typing import List, Dict
class BasicTableExtractor:
"""基础表格提取器(不处理合并单元格)"""
def __init__(self, url: str, table_selector: str = "table"):
self.url = url
self.table_selector = table_selector
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
def fetch_html(self) -> str:
"""获取页面HTML"""
try:
resp = requests.get(self.url, headers=self.headers, timeout=10)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding # 自动检测编码
return resp.text
except Exception as e:
print(f"❌ 请求失败: {e}")
return ""
def extract_table(self, html: str) -> List[List[str]]:
"""提取表格数据"""
soup = BeautifulSoup(html, "lxml")
table = soup.select_one(self.table_selector)
if not table:
print(f"⚠️ 未找到表格(选择器: {self.table_selector})")
return []
# 提取列头
headers = self._extract_headers(table)
# 提取数据行
rows = self._extract_rows(table)
# 合并列头和数据
result = [headers] + rows if headers else rows
return result
def _extract_headers(self, table) -> List[str]:
"""提取列头"""
thead = table.find("thead")
if thead:
header_row = thead.find("tr")
if header_row:
return [th.get_text(strip=True) for th in header_row.find_all(["th", "td"])]
# 如果没有thead,尝试从第一行提取
first_row = table.find("tr")
if first_row:
# 判断第一行是否全是th
ths = first_row.find_all("
return [th.get_text(strip=True) for th in ths]
return []
def _extract_rows(self, table) -> List[List[str]]:
"""提取数据行"""
rows = []
tbody = table.find("tbody")
tr_list = tbody.find_all("tr") if tbody else table.find_all("tr")[1:] # 跳过列头行
for tr in tr_list:
cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
if cells: # 过滤空行
rows.append(cells)
return rows
def save_to_csv(self, data: List[List[str]], filename: str = "table_data.csv"):
"""保存为CSV"""
if not data:
print("⚠️ 无数据可保存")
return
with open(filename, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerows(data)
print(f"✅ 已保存 {len(data)-1} 行数据到 {filename}")
def run(self, output_file: str = "table_data.csv"):
"""运行完整流程"""
print(f"📡 正在采集: {self.url}")
html = self.fetch_html()
if not html:
return
print("🔍 正在解析表格...")
data = self.extract_table(html)
if data:
self.save_to_csv(data, output_file)
self._print_preview(data)
else:
print("❌ 未提取到数据")
def _print_preview(self, data: List[List[str]], rows: int = 5):
"""打印预览"""
print("\n" + "="*60)
print("📊 数据预览(前5行)")
print("="*60)
for i, row in enumerate(data[:rows+1]): # 包含列头
print(" | ".join(row))
print("="*60 + "\n")
# 使用示例
if __name__ == "__main__":
# 示例:采集某统计网站的表格
extractor = BasicTableExtractor(
url="https://example.com/statistics/table",
table_selector="table.data-table"
)
extractor.run(output_file="statistics.csv")
版本B:进阶版------处理合并单元格
合并单元格是个硬骨头,但也不是不能啃。核心思路:用一个二维数组记录每个单元格的"真实位置"。
python
# table_extractor_advanced.py
import requests
from bs4 import BeautifulSoup
import csv
from typing import List, Optional
class AdvancedTableExtractor:
"""进阶表格提取器(支持合并单元格)"""
def __init__(self, url: str, table_selector: str = "table"):
self.url = url
self.table_selector = table_selector
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
def fetch_html(self) -> str:
"""获取页面HTML"""
try:
resp = requests.get(self.url, headers=self.headers, timeout=10)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding
return resp.text
except Exception as e:
print(f"❌ 请求失败: {e}")
return ""
def extract_table_with_merge(self, html: str) -> List[List[str]]:
"""提取表格(处理合并单元格)"""
soup = BeautifulSoup(html, "lxml")
table = soup.select_one(self.table_selector)
if not table:
print(f"⚠️ 未找到表格")
return []
# 先获取表格的行数和列数
all_rows = table.find_all("tr")
if not all_rows:
return []
# 初始化一个足够大的二维数组
max_cols = self._estimate_columns(all_rows)
grid = [[None for _ in range(max_cols)] for _ in range(len(all_rows))]
# 填充grid
for row_idx, tr in enumerate(all_rows):
col_idx = 0
for cell in tr.find_all(["td", "th"]):
# 跳过已被占用的位置(被之前的rowspan占用)
while col_idx < max_cols and grid[row_idx][col_idx] is not None:
col_idx += 1
if col_idx >= max_cols:
break
# 获取单元格的值
cell_text = cell.get_text(strip=True)
# 获取rowspan和colspan
rowspan = int(cell.get("rowspan", 1))
colspan = int(cell.get("colspan", 1))
# 填充合并单元格占用的所有位置
for r in range(rowspan):
for c in range(colspan):
if row_idx + r < len(grid) and col_idx + c < max_cols:
grid[row_idx + r][col_idx + c] = cell_text
col_idx += colspan
# 清理None值并转为列表
result = []
for row in grid:
cleaned_row = [cell if cell is not None else "" for cell in row]
result.append(cleaned_row)
return result
def _estimate_columns(self, rows) -> int:
"""估算表格的最大列数"""
max_cols = 0
for tr in rows:
cols = sum(int(cell.get("colspan", 1)) for cell in tr.find_all(["td", "th"]))
max_cols = max(max_cols, cols)
return max_cols
def save_to_csv(self, data: List[List[str]], filename: str = "table_data.csv"):
"""保存为CSV"""
if not data:
print("⚠️ 无数据可保存")
return
with open(filename, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerows(data)
print(f"✅ 已保存 {len(data)} 行数据到 {filename}")
def run(self, output_file: str = "table_advanced.csv"):
"""运行完整流程"""
print(f"📡 正在采集: {self.url}")
html = self.fetch_html()
if not html:
return
print("🔍 正在解析表格(支持合并单元格)...")
data = self.extract_table_with_merge(html)
if data:
self.save_to_csv(data, output_file)
self._print_preview(data)
else:
print("❌ 未提取到数据")
def _print_preview(self, data: List[List[str]], rows: int = 5):
"""打印预览"""
print("\n" + "="*70)
print("📊 数据预览(前5行)")
print("="*70)
for i, row in enumerate(data[:rows]):
print(" | ".join(row))
print("="*70 + "\n")
if __name__ == "__main__":
extractor = AdvancedTableExtractor(
url="https://example.com/merged-table",
table_selector="table#annual-report"
)
extractor.run(output_file="merged_table.csv")
版本C:跨页拼接------完整工程化方案
很多表格会分页,咱们需要翻页采集并合并结果。
python
# table_extractor_multipage.py
import requests
from bs4 import BeautifulSoup
import csv
import time
from typing import List, Generator
from urllib.parse import urljoin, urlparse, parse_qs, urlencode
class MultiPageTableExtractor:
"""跨页表格提取器"""
def __init__(self, base_url: str, table_selector: str = "table", max_pages: int = 10):
self.base_url = base_url
self.table_selector = table_selector
self.max_pages = max_pages
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
def generate_page_urls(self) -> Generator[str, None, None]:
"""生成分页URL"""
parsed = urlparse(self.base_url)
params = parse_qs(parsed.query)
for page_num in range(1, self.max_pages + 1):
# 常见分页参数: page, p, pageNo, pageNum
params['page'] = [str(page_num)] # 根据实际情况调整参数名
new_query = urlencode(params, doseq=True)
url = parsed._replace(query=new_query).geturl()
yield url
def fetch_page_table(self, url: str) -> List[List[str]]:
"""采集单页表格"""
try:
resp = self.session.get(url, timeout=10)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding
soup = BeautifulSoup(resp.text, "lxml")
table = soup.select_one(self.table_selector)
if not table:
return []
# 简化版:不处理合并单元格
rows = []
for tr in table.find_all("tr"):
cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
if cells:
rows.append(cells)
return rows
except Exception as e:
print(f"⚠️ 采集失败({url}): {e}")
return []
def scrape_all_pages(self, delay: float = 1.0) -> List[List[str]]:
"""采集所有分页"""
all_data = []
headers = None
for page_num, url in enumerate(self.generate_page_urls(), start=1):
print(f"📄 正在采集第 {page_num}/{self.max_pages} 页...")
page_data = self.fetch_page_table(url)
if not page_data:
print(f"⚠️ 第 {page_num} 页无数据,停止采集")
break
# 第一页提取列头
if page_num == 1:
headers = page_data[0]
all_data.append(headers)
all_data.extend(page_data[1:]) # 跳过列头
else:
# 后续页跳过列头(假设列头一致)
all_data.extend(page_data[1:] if len(page_data) > 1 else page_data)
print(f"✅ 第 {page_num} 页采集 {len(page_data)-1} 行")
time.sleep(delay)
return all_data
def validate_consistency(self, data: List[List[str]]) -> bool:
"""校验列数一致性"""
if not data:
return False
header_len = len(data[0])
inconsistent_rows = []
for i, row in enumerate(data[1:], start=2):
if len(row) != header_len:
inconsistent_rows.append((i, len(row)))
if inconsistent_rows:
print(f"⚠️ 发现 {len(inconsistent_rows)} 行列数不一致:")
for row_num, col_count in inconsistent_rows[:5]: # 只显示前5个
print(f" 第 {row_num} 行: {col_count} 列(预期 {header_len} 列)")
return False
return True
def save_to_csv(self, data: List[List[str]], filename: str = "multipage_table.csv"):
"""保存为CSV"""
if not data:
print("⚠️ 无数据可保存")
return
with open(filename, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerows(data)
print(f"💾 已保存 {len(data)-1} 行数据到 {filename}")
def run(self, output_file: str = "multipage_table.csv"):
"""运行完整流程"""
print(f"🚀 开始跨页采集...")
data = self.scrape_all_pages(delay=1.0)
if not data:
print("❌ 未采集到任何数据")
return
print(f"\n📊 共采集 {len(data)-1} 行数据")
if self.validate_consistency(data):
print("✅ 列数一致性校验通过")
else:
print("⚠️ 存在列数不一致的行,请检查")
self.save_to_csv(data, output_file)
self._print_sample(data)
def _print_sample(self, data: List[List[str]], sample_size: int = 3):
"""打印抽样数据"""
print("\n" + "="*70)
print("📋 抽样预览(首尾各3行)")
print("="*70)
# 列头
print("【列头】")
print(" | ".join(data[0]))
print("-" * 70)
# 前3行
print("【前3行】")
for row in data[1:min(4, len(data))]:
print(" | ".join(row))
if len(data) > 7:
print("...")
# 后3行
print("【后3行】")
for row in data[-3:]:
print(" | ".join(row))
print("="*70 + "\n")
if __name__ == "__main__":
extractor = MultiPageTableExtractor(
base_url="https://example.com/data?page=1",
table_selector="table.result-table",
max_pages=10
)
extractor.run(output_file="complete_data.csv")
完整工程化Demo------终极版
整合上面三个版本的优点,给出一个生产级别的通用表格提取器:
python
# ultimate_table_extractor.py
import requests
from bs4 import BeautifulSoup
import csv
import time
import json
from typing import List, Dict, Optional
from pathlib import Path
from datetime import datetime
class UltimateTableExtractor:
"""终极版表格提取器"""
def __init__(
self,
url: str,
table_selector: str = "table",
output_dir: str = "./output",
handle_merge: bool = False,
multipage: bool = False,
max_pages: int = 10
):
self.url = url
self.table_selector = table_selector
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.handle_merge = handle_merge
self.multipage = multipage
self.max_pages = max_pages
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
self.stats = {
"total_rows": 0,
"total_pages": 0,
"missing_cells": 0,
"start_time": None,
"end_time": None
}
def fetch_html(self, url: str) -> Optional[str]:
"""获取页面HTML"""
try:
resp = self.session.get(url, timeout=15)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding
return resp.text
except Exception as e:
print(f"❌ 请求失败({url}): {e}")
return None
def extract_table(self, html: str) -> List[List[str]]:
"""提取表格(自动选择策略)"""
if self.handle_merge:
return self._extract_with_merge(html)
else:
return self._extract_basic(html)
def _extract_basic(self, html: str) -> List[List[str]]:
"""基础提取(不处理合并单元格)"""
soup = BeautifulSoup(html, "lxml")
table = soup.select_one(self.table_selector)
if not table:
return []
rows = []
for tr in table.find_all("tr"):
cells = []
for cell in tr.find_all(["td", "th"]):
text = cell.get_text(strip=True)
cells.append(text if text else "") # 空值填充为空字符串
if cells:
rows.append(cells)
return rows
def _extract_with_merge(self, html: str) -> List[List[str]]:
"""处理合并单元格"""
soup = BeautifulSoup(html, "lxml")
table = soup.select_one(self.table_selector)
if not table:
return []
all_rows = table.find_all("tr")
max_cols = self._estimate_columns(all_rows)
grid = [[None for _ in range(max_cols)] for _ in range(len(all_rows))]
for row_idx, tr in enumerate(all_rows):
col_idx = 0
for cell in tr.find_all(["td", "th"]):
while col_idx < max_cols and grid[row_idx][col_idx] is not None:
col_idx += 1
if col_idx >= max_cols:
break
cell_text = cell.get_text(strip=True)
rowspan = int(cell.get("rowspan", 1))
colspan = int(cell.get("colspan", 1))
for r in range(rowspan):
for c in range(colspan):
if row_idx + r < len(grid) and col_idx + c < max_cols:
grid[row_idx + r][col_idx + c] = cell_text
col_idx += colspan
result = [[cell if cell is not None else "" for cell in row] for row in grid]
return result
def _estimate_columns(self, rows) -> int:
"""估算最大列数"""
max_cols = 0
for tr in rows:
cols = sum(int(cell.get("colspan", 1)) for cell in tr.find_all(["td", "th"]))
max_cols = max(max_cols, cols)
return max_cols
def scrape(self) -> List[List[str]]:
"""主采集逻辑"""
self.stats["start_time"] = datetime.now()
if self.multipage:
data = self._scrape_multipage()
else:
html = self.fetch_html(self.url)
data = self.extract_table(html) if html else []
self.stats["end_time"] = datetime.now()
self.stats["total_rows"] = len(data) - 1 if data else 0 # 减去列头
return data
def _scrape_multipage(self) -> List[List[str]]:
"""跨页采集"""
all_data = []
headers = None
for page_num in range(1, self.max_pages + 1):
# 这里简化处理,实际应根据网站分页规则调整
page_url = f"{self.url}&page={page_num}" if "?" in self.url else f"{self.url}?page={page_num}"
print(f"📄 正在采集第 {page_num}/{self.max_pages} 页...")
html = self.fetch_html(page_url)
if not html:
break
page_data = self.extract_table(html)
if not page_data:
print(f"⚠️ 第 {page_num} 页无数据,停止采集")
break
if page_num == 1:
headers = page_data[0]
all_data.append(headers)
all_data.extend(page_data[1:])
else:
all_data.extend(page_data[1:] if len(page_data) > 1 else page_data)
self.stats["total_pages"] += 1
print(f"✅ 第 {page_num} 页采集 {len(page_data)-1} 行")
time.sleep(1.0)
return all_data
def quality_check(self, data: List[List[str]]) -> Dict:
"""数据质量检查"""
if not data or len(data) < 2:
return {"status": "empty"}
header_len = len(data[0])
inconsistent_rows = []
empty_cells = 0
for i, row in enumerate(data[1:], start=2):
if len(row) != header_len:
inconsistent_rows.append(i)
empty_cells += sum(1 for cell in row if not cell)
total_cells = (len(data) - 1) * header_len
missing_rate = (empty_cells / total_cells * 100) if total_cells > 0 else 0
self.stats["missing_cells"] = empty_cells
return {
"status": "ok" if not inconsistent_rows else "warning",
"inconsistent_rows": inconsistent_rows[:10], # 最多显示10个
"missing_rate": f"{missing_rate:.2f}%",
"total_cells": total_cells
}
def save[List[str]], filename: str = "table_output.csv"):
"""保存结果"""
if not data:
print("⚠️ 无数据可保存")
return
output_path = self.output_dir / filename
with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerows(data)
print(f"💾 已保存到 {output_path}")
def save_raw_html(self, html: str, filename: str = "raw_table.html"):
"""保存原始HTML(便于调试)"""
output", encoding="utf-8") as f:
f.write(html)
print(f"📄 原始HTML已保存到 {output_path}")
def generate_report(self, quality_info: Dict):
"""生成质量报告"""
elapsed = (self.stats["end_time"] - self.stats["start_time"]).total_seconds()
report = f"""
{'='*70}
📊 表格采集质量报告
{'='*70}
采集时间: {self.stats['start_time'].strftime('%Y-%m-%d %H:%M:%S')}
总耗时: {elapsed:.2f} 秒
总页数: {self.stats['total_pages'] if self.multipage else 1}
总行数: {self.stats['total_rows']}
{'='*70}
数据质量:
- 状态: {quality_info.get('status', 'unknown').upper()}
- 缺失率: {quality_info.get('missing_rate', 'N/A')}
- 列数不一致行数: {len(quality_info.get('inconsistent_rows', []))}
{'='*70}
"""
print(report)
# 保存报告
report_path = self.output_dir / "quality_report.txt"
with open(report_path, "w", encoding="utf-8") as f:
f.write(report)
def run(self, output_csv: str = "table_data.csv", save_html: bool = True):
"""完整运行流程"""
print(f"🚀 开始采集表格: {self.url}")
print(f" - 合并单元格处理: {'是' if self.handle_merge else '否'}")
print(f" - 跨页采集: {'是' if self.multipage else '否'}")
# 采集数据
data = self.scrape()
if not data:
print("❌ 未采集到数据")
return
# 保存原始HTML(仅单页模式)
if save_html and not self.multipage:
html = self.fetch_html(self.url)
if html:
self.save_raw_html(html)
# 质量检查
quality_info = self.quality_check(data)
# 保存结果
self.save_results(data, output_csv)
# 生成报告
self.generate_report(quality_info)
# 打印预览
self._print_preview(data)
def _print_preview(self, data: List[List[str]], rows: int = 5):
"""打印数据预览"""
print("\n" + "="*70)
print("📋 数据预览(前5_str = " | ".join(str(cell)[:20] for cell in row) # 每个单元格最多显示20字符
print(f"{i}: {row_str}")
if len(data) > rows + 1:
print(f"... (还有 {len(data) - rows - 1} 行)")
print("="*70 + "\n")
# ============ 使用示例 ============
def example_basic():
"""示例1: 基础单页表格"""
extractor = UltimateTableExtractor(
url="https://example.com/simple-table",
table_selector="table.data-list",
handle_merge=False,
multipage=False
)
extractor.run(output_csv="basic_table.csv")
def example_merged():
"""示例2: 带合并单元格的表格"""
extractor = UltimateTableExtractor(
url="https://example.com/merged-table",
table_selector="table#annual-data",
handle_merge=True, # 开启合并单元格处理
multipage=False
)
extractor.run(output_csv="merged_table.csv")
def example_multipage():
"""示例3: 跨页表格"""
extractor = UltimateTableExtractor(
url="https://example.com/data?category=finance",
table_selector="table.result-table",
handle_merge=False,
multipage=True, # 开启跨页采集
max_pages=20
)
extractor.run(output_csv="multipage_finance.csv")
def example_full_featured():
"""示例4: 全功能------合并单元格+跨页"""
extractor = UltimateTableExtractor(
url="https://example.com/complex-table?year=2024",
table_selector="table.complex-data",
handle_merge=True,
multipage=True,
max_pages=15,
output_dir="./complex_output"
)
extractor.run(output_csv="complex_table.csv", save_html=False)
if __name__ == "__main__":
# 根据实际需求选择示例运行
print("请选择运行模式:")
print("1 - 基础单页表格")
print("2 - 合并单元格表格")
print("3 - 跨页表格")
print("4 - 全功能(合并+跨页)")
choice = input("输入选项(1-4): ").strip()
if choice == "1":
example_basic()
elif choice == "2":
example_merged()
elif choice == "3":
example_multipage()
elif choice == "4":
example_full_featured()
else:
print("❌ 无效选项")
代码实现逻辑说明
整体架构设计
这个终极版表格提取器采用策略模式 + 模板方法模式设计:
- 核心类
UltimateTableExtractor: 封装所有功能 - 策略选择 : 通过
handle_merge和multipage参数动态切换策略 - 流程模板 :
run()方法定义了标准流程(采集→检查→保存→报告)
关键模块说明
1. 合并单元格处理 (_extract_with_merge)
核心思想 : 用二维数组 grid 记录每个单元格的"真实占位"
python
# 遇到 rowspan=2, colspan=3 的单元格时:
for r in range(rowspan): # 占2行
for c in range(colspan): # 占3列
grid[row_idx + r][col_idx + c] = cell_text # 填充所有位置
为什么这样设计?
- HTML 的合并单元格只在第一个位置写值,后续位置"隐式占用"
- 我们的 grid 把这些隐式占用"显式化",导出 CSV 时就对齐了
2. 跨页拼接 (_scrape_multipage)
关键点:
- 第一页提取列头,后续页复用
- 每页跳过自己的列头行(避免重复)
- 列数一致性校验,防止网站中途改表结构
python
if page_num == 1:
headers = page_data[0] # 记住列头
all_data.append(headers)
all_data.extend(page_data[1:]) # 跳过列头
else:
all_data.extend(page_data[1:]) # 直接追加数据
3. 质量检查 (quality_check)
检查三个指标:
- 列数一致性: 每行列数是否等于列头列数
- 缺失率: 空单元格占比
- 异常行: 记录不一致的行号,便于人工复查
4. 原始HTML留存
python
self.save_raw_html(html)
为什么要保存?
- 调试时可以对比"我的解析"和"原始HTML"
- 万一网站改版,还能复现问题
- 算是一种"数据可追溯"的工程化实践
实战案例演示
案例1: 统计局GDP数据表
假设我们要采集某统计局的GDP表格:
python
extractor = UltimateTableExtractor(
url="https://stats.gov.cn/data/gdp?year=2024",
table_selector="table.main-table",
handle_merge=True, # 表格有年份合并单元格
multipage=False,
output_dir="./stats_output"
)
extractor.run(output_csv="gdp_2024.csv")
输出:
json
🚀 开始采集表格: https://stats.gov.cn/data/gdp?year=2024
- 合并单元格处理: 是
- 跨页采集: 否
📄 正在采集...
✅ 采集完成
💾 已保存到 ./stats_output/gdp_2024.csv
📄 原始HTML已保存到 ./stats_output/raw_table.html
======================================================================
📊 表格采集质量报告
======================================================================
采集时间: 2026-01-25 14:30:15
总耗时: 3.21 秒
总页数: 1
总行数: 34
======================================================================
数据质量:
- 状态: OK
- 缺失率: 2.15%
- 列数不一致行数: 0
======================================================================
======================================================================
📋 数据预览(前5行)
======================================================================
0: 年份 | 季度 | GDP总值(亿元) | 同比增长(%)
1: 2024 | Q1 | 296299 | 5.3
2: 2024 | Q2 | 312455 | 4.7
3: 2024 | Q3 | 321451 | 4.6
4: 2024 | Q4 | 335478 | 5.4
... (还有 30 行)
======================================================================
案例2: 证券交易所公告列表(跨页)
python
extractor = UltimateTableExtractor(
url="https://www.sse.com.cn/disclosure/listedinfo/announcement/?page=1",
table_selector="table.table-notice",
handle_merge=False,
multipage=True,
max_pages=30,
output_dir="./sse_announcements"
)
extractor.run(output_csv="announcements_jan2026.csv")
输出:
json
📄 正在采集第 1/30 页...
✅ 第 1 页采集 20 行
📄 正在采集第 2/30 页...
✅ 第 2 页采集 20 行
...
📄 正在采集第 28/30 页...
✅ 第 28 页采集 20 行
📄 正在采集第 29/30 页...
⚠️ 第 29 页无数据,停止采集
💾 已保存到 ./sse_announcements/announcements_jan2026.csv
📊 表格采集质量报告
总页数: 28
总行数: 560
缺失率: 0.35%
常见问题与解决方案
Q1: 表格选择器怎么写?
A: 三种常用方法:
python
# 方法1: 按class选择
table_selector = "table.data-table"
# 方法2: 按id选择
table_selector = "table#main-table"
# 方法3: 组合选择(推荐,更精确)
table_selector = "#content > div.main > table:nth-of-type(2)"
小技巧: 浏览器F12右键元素 → Copy → Copy selector,直接拿到选择器字符串
Q2: 如何处理表格里的超链接?
A : 修改 _extract_basic 方法:
python
for cell in tr.find_all(["td", "th"]):
# 优先提取链接文本
link = cell.find("a")
if link:
text = link.get_text(strip=True)
href = link.get("href", "")
cells.append(f"{text}|{href}") # 用分隔符保存文本和链接
else:
text = cell.get_text(strip=True)
cells.append(text if text else "")
Q3: 表格数据有千位分隔符怎么办?
A: 添加清洗函数:
python
def clean_number(text: str) -> str:
"""清洗数值(去除千位分隔符、货币符号)"""
return text.replace(",", "").replace("¥", "").replace("$", "").strip()
# 在提取时应用
cells.append(clean_number(text))
Q4: 分页URL规则不规范怎么办?
A: 有些网站分页用 POST 请求或者加密参数,这时候:
**
python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
page = p.chromium.launch().new_page()
page.goto(url)
for i in range(max_pages):
# 提取当前页表格
html = page.content()
data = extract_table(html)
# 点击"下一页"
page.click("a.next-page")
page.wait_for_load_state("networkidle")
方案2: 抓包找接口(参考上一讲9-13)
Q5: 表格太大,内存爆了怎么办?
A : 改用流式写入:
python
def scrape_stream(self):
"""流式采集,边采边写"""
with open("output.csv", "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
for page_num in range(1, self.max_pages + 1):
page_data = self.fetch_page_table(page_num)
if page_num == 1:
writer.writerow(page_data[0]) # 写列头
writer.writerows(page_data[1:]) # 逐行写入,不占内存
性能优化建议
1. 并发采集(小心别被封)
python
from concurrent.futures import ThreadPoolExecutor
def scrape_concurrent(self, max_workers=3):
"""并发采集多页(控制并发数)"""
with ThreadPoolExecutor(max_workers=max_workers) as executor:
urls = [f"{self.url}?page={i}" for i in range(1, self.max_pages+1)]
results = executor.map(self.fetch_page_table, urls)
all_data = []
for data in results:
all_data.extend(data)
return all_data
2. 缓存HTML(避免重复请求)
python
import hashlib
def fetch_html_cached(self, url: str) -> str:
"""带缓存的HTML获取"""
cache_key = hashlib.md5(url.encode()).hexdigest()
cache_file = self.output_dir / f"cache_{cache_key}.html"
if cache_file.exists():
print(f"💾 使用缓存: {url}")
return cache_file.read_text(encoding="utf-8")
html = self.fetch_html(url)
if html:
cache_file.write_text(html, encoding="utf-8")
return html
3. 增量采集(只抓新增部分)
python
def scrape_incremental(self, last_id: str):
"""增量采集(从某个ID开始)"""
all_data = []
for page_num in range(1, self.max_pages + 1):
page_data = self.fetch_page_table(page_num)
for row in page_data:
if row[0] == last_id: # 假设第一列是ID
print(f"✅ 遇到上次终点,停止采集")
return all_data
all_data.append(row)
return all_data
总结与最佳实践
核心思想 : 表格采集的本质是二维数据的结构化提取,关键是搞清楚"行列对应关系"。
操作步骤:
- 定位表格: 用 F12 找到精确的选择器
- 分析结构: 有没有 thead/tbody? 有没有合并单元格?
- 选择策略: 基础提取 vs 合并处理 vs 跨页拼接
- 质量检查: 列数一致性、缺失率、异常值
- 留存原文: 保存 HTML,便于调试复现
注意事项:
- 列头识别 : 优先找
<thead>,其次找第一行<th> - 空值处理: 统一填充为空字符串,便于后续清洗
- 编码问题 : 用
utf-8-sig导出 CSV,Excel 打开不乱码 - 礼貌延迟: 跨页采集时加 1-2 秒延迟
什么时候用表格提取器?
- ✅ 政府统计网站(GDP、人口、财政)
- ✅ 金融数据网站(股票行情、财报)
- ✅ 学术网站(论文列表、引用统计)
- ✅ 企业内部报表系统
什么时候不适合?
- ❌ 表格是用 div+CSS 模拟的(不是真
<table>) - ❌ 数据是 JavaScript 动态渲染的(考虑接口采集)
- ❌ 表格嵌套层级太深(超过3层,解析复杂度爆炸)
下期预告
下一讲我们要搞定一个"看着简单,实际有坑"的场景:搜索页采集。
很多网站都有搜索功能,你输入关键词,它返回匹配结果。咱们要做的是:批量搜索多个关键词,把结果汇总去重,还要应对反爬策略。
我会教你:
- 关键词队列管理
- 搜索结果分页
- 去重键设计(同一条数据不同搜索词都能找到)
- 限速与暂停机制(避免触发反爬)
相信我,学会这招后,市场调研、竞品分析、舆情监控都能自动化
练习作业
- 必做: 找一个真实的表格网站(建议:国家统计局、证券交易所),用本讲的代码采集至少100行数据
- 必做: 对比手动复制和自动采集的效率差异,生成一个对比报告
- 选做: 实现一个"表格对比"功能,输入两个 CSV,输出差异行
验收标准:
- CSV 导出后 Excel 打开不乱码
- 列数一致性100%通过
- 缺失率 < 5%
- 如有合并单元格,填充正确
小提示 : 如果遇到表格是用 <div> 伪装的(F12看不到 <table> 标签),那就不是真表格,得用普通的元素选择器提取。真表格的标志:必须有 <table>、<tr>、<td> 这三个标签
下一讲见,咱们搞定搜索页采集!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。