Python爬虫零基础入门【第九章:实战项目教学·第14节】表格型页面采集:多列、多行、跨页(通用表格解析)!

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

全文目录:

开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》

订阅后更新会优先推送,按目录学习更高效~

上期回顾

上一讲《动态站点"回到接口":识别接口并用 Requests 重写(更稳)!》我们学会了从动态网站里"抠出"真正的数据接口,用 Requests 替代 Playwright,速度直接起飞。不过接口不是万能的,有些网站(尤其是政府、统计、金融类)压根不走接口,数据就是硬生生用 <table> 标签渲染在页面上的。

今天咱们就来攻克这个场景:表格型页面采集

说实话,表格数据看着规整,但实际采集起来坑不少:列对不齐、合并单元格、跨页拼接、空值满天飞......新手经常采到一半就懵了。

别慌,今天我教你写一个通用表格解析器,学会之后,再复杂的表格都是"一把梭"的事儿

为什么表格采集这么常见?

先说说应用场景,你肯定遇到过这些:

  • 统计局数据:GDP、人口、工业产值,全是表格
  • 证券网站:股票行情、财报数据、公告列表
  • 招投标平台:中标公示、项目清单
  • 学术网站:论文列表、引用统计
  • 电商后台:订单列表、库存表

这些数据的共同点:结构化程度高,但不提供接口。你要么手动复制粘贴(想想就头疼),要么写脚本自动提取。

咱们今天就是要把这个"体力活"彻底自动化。

表格采集的三大难点

难点1:列头与数据对应

有些表格的 <thead><tbody> 分离得很清楚,有些却混在一起,甚至列头在第二行、第三行。你得先搞清楚"哪一行是列头",否则数据全乱套。

难点2:合并单元格

HTML 里用 rowspancolspan 实现合并单元格。比如:

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("❌ 无效选项")

代码实现逻辑说明

整体架构设计

这个终极版表格提取器采用策略模式 + 模板方法模式设计:

  1. 核心类 UltimateTableExtractor: 封装所有功能
  2. 策略选择 : 通过 handle_mergemultipage 参数动态切换策略
  3. 流程模板 : 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

总结与最佳实践

核心思想 : 表格采集的本质是二维数据的结构化提取,关键是搞清楚"行列对应关系"。

操作步骤:

  1. 定位表格: 用 F12 找到精确的选择器
  2. 分析结构: 有没有 thead/tbody? 有没有合并单元格?
  3. 选择策略: 基础提取 vs 合并处理 vs 跨页拼接
  4. 质量检查: 列数一致性、缺失率、异常值
  5. 留存原文: 保存 HTML,便于调试复现

注意事项:

  • 列头识别 : 优先找 <thead>,其次找第一行 <th>
  • 空值处理: 统一填充为空字符串,便于后续清洗
  • 编码问题 : 用 utf-8-sig 导出 CSV,Excel 打开不乱码
  • 礼貌延迟: 跨页采集时加 1-2 秒延迟

什么时候用表格提取器?

  • ✅ 政府统计网站(GDP、人口、财政)
  • ✅ 金融数据网站(股票行情、财报)
  • ✅ 学术网站(论文列表、引用统计)
  • ✅ 企业内部报表系统

什么时候不适合?

  • ❌ 表格是用 div+CSS 模拟的(不是真 <table>)
  • ❌ 数据是 JavaScript 动态渲染的(考虑接口采集)
  • ❌ 表格嵌套层级太深(超过3层,解析复杂度爆炸)

下期预告

下一讲我们要搞定一个"看着简单,实际有坑"的场景:搜索页采集

很多网站都有搜索功能,你输入关键词,它返回匹配结果。咱们要做的是:批量搜索多个关键词,把结果汇总去重,还要应对反爬策略

我会教你:

  • 关键词队列管理
  • 搜索结果分页
  • 去重键设计(同一条数据不同搜索词都能找到)
  • 限速与暂停机制(避免触发反爬)

相信我,学会这招后,市场调研、竞品分析、舆情监控都能自动化

练习作业

  1. 必做: 找一个真实的表格网站(建议:国家统计局、证券交易所),用本讲的代码采集至少100行数据
  2. 必做: 对比手动复制和自动采集的效率差异,生成一个对比报告
  3. 选做: 实现一个"表格对比"功能,输入两个 CSV,输出差异行

验收标准:

  • CSV 导出后 Excel 打开不乱码
  • 列数一致性100%通过
  • 缺失率 < 5%
  • 如有合并单元格,填充正确

小提示 : 如果遇到表格是用 <div> 伪装的(F12看不到 <table> 标签),那就不是真表格,得用普通的元素选择器提取。真表格的标志:必须有 <table>、<tr>、<td> 这三个标签

下一讲见,咱们搞定搜索页采集!

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
喵手2 小时前
Python爬虫零基础入门【第九章:实战项目教学·第15节】搜索页采集:关键词队列 + 结果去重 + 反爬友好策略!
爬虫·python·爬虫实战·python爬虫工程化实战·零基础python爬虫教学·搜索页采集·关键词队列
Suchadar2 小时前
if判断语句——Python
开发语言·python
ʚB҉L҉A҉C҉K҉.҉基҉德҉^҉大2 小时前
自动化机器学习(AutoML)库TPOT使用指南
jvm·数据库·python
0思必得03 小时前
[Web自动化] 爬虫之API请求
前端·爬虫·python·selenium·自动化
莫问前路漫漫3 小时前
WinMerge v2.16.41 中文绿色版深度解析:文件对比与合并的全能工具
java·开发语言·python·jdk·ai编程
木头左4 小时前
Backtrader框架下的指数期权备兑策略资金管理实现与风险控制
python
玄同7654 小时前
LangChain 核心组件全解析:构建大模型应用的 “乐高积木”
人工智能·python·语言模型·langchain·llm·nlp·知识图谱
喵手4 小时前
Python爬虫实战:从零构建 Hacker News 数据采集系统:API vs 爬虫的技术抉择!(附CSV导出 + SQLite 存储)!
爬虫·python·爬虫实战·hacker news·python爬虫工程化实战·零基础python爬虫教学·csv导出
测试老哥4 小时前
软件测试之功能测试详解
自动化测试·软件测试·python·功能测试·测试工具·职场和发展·测试用例