Python爬虫零基础入门【第五章:数据保存与入库·第1节】先学最通用:CSV/JSONL 保存(可复现、可分享)!

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

全文目录:

🌟 开篇语

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

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

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

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

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

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

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

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

📌 上期回顾

在上一讲《数据质量:缺失率、重复率、异常值》中,我们学习了如何系统评估采集数据的质量。我们实现了三大质量维度的检测:

  • 完整性检测:识别字段缺失率,定位高缺失字段
  • 唯一性检测:计算重复率,支持多策略去重
  • 准确性检测:发现异常值,验证数据范围和格式

我们还学会了生成专业的质量报告(JSON/Markdown格式),设置质量监控告警,建立质量基线和历史趋势分析。

现在数据已经清洗干净、质量合这些宝贵的数据应该存到哪里?

你可能会想:"直接存数据库不就好了?"这当然是最终目标,但在实际项目中,我强烈建议你先把数据保存为文件。为什么?听我给你讲三个真实场景:

场景1:调试与复现 🔧

你的爬虫突然不工作了,想复现问题,但目标网站已经更新了内容。如果你有原始HTML和解析结果的文件备份,就能轻松定位是选择器失构变化。

场景2:团队协作 👥

你采集的数据需要交给数据分析师处理,但他们不熟悉你的数据库。一直接用Excel打开查看,一个JSONL文件可以快速导入任何分析工具。

场景3:数据迁移 📦

项目初期用SQLite,后期要迁移到PostgreSQL。如果有完整的文件备份,迁移就是"读文件→写新库"这么简单,而不是"导出旧库→格式转一讲,我们就来学习两种最通用、最实用的文件存储格式:CSV和JSONL。它们简单、可靠、跨平台,是每个爬虫工程师都应该掌握的基本技能!💪

🎯 本讲目标

交付物

  • storage/csv_exporter.py - CSV导出器
  • storage/jsonl_exporter.py - JSONL导出器
  • storage/base_exporter.py - 导出器基类(统一接口)
  • storage/meta.py - 元数据记录工具
  • 完整的测试用例和使用文档

验收标准

  1. 能够将采集数据导出为CSV和JSONL格式
  2. 支持追加写入模式(断点续爬场景)
  3. 正确处理编码、换行符等边界情况
  4. 记录元数据(采集时间、数据量、字段schema等)
  5. 导出的文件能在其他工具中正常打开和使用

💡 CSV vs JSONL:如何选择?

格式对比速查表

特性 CSV JSONL
可读性 ⭐⭐⭐⭐⭐ Excel直接打开 ⭐⭐⭐ 需要代码查看
结构化 ⭐⭐⭐ 扁平结构 ⭐⭐⭐⭐⭐ 支持嵌套
灵活性 ⭐⭐ 字段固定 ⭐⭐⭐⭐⭐ 字段可变
文件大小 ⭐⭐⭐⭐ 较小 ⭐⭐⭐ 略大
兼容性 ⭐⭐⭐⭐⭐ 通用性最强 ⭐⭐⭐⭐ 开发友
追加写入 ⭐⭐⭐⭐⭐ 天然支持 ⭐⭐⭐⭐⭐ 天然支持

选择建议

选CSV的场景

  • 数据结构简单(无嵌套列表/字典)
  • 需要给非技术人员查看
  • 需要导入Excel/Google Sheets
  • 追求最小文件体积

选JSONL的场景

  • 数据结构复杂(有嵌套)
  • 字段数量不固定(不同数据项字段可能不同)
  • 需要保留完整数据类型(如列表、布尔值)
  • 后续需要程序化处理

我的推荐 :🌟
两种都保存! CSV用于快速查看,JSONL作为"数据源头"保留完整信息。磁盘很便宜,数据无价。

📖 深入理解JSONL格式(为什么不用JSON?)

JSON vs JSONL的关键区别

python 复制代码
# ❌ 标准JSON格式(不推荐用于爬虫)
{
  "data": [
    {"title": "新闻A", "views": 1000},
    {"title": "新闻B", "views": 2000}
  ]
}

# ✅ JSONL格式(每行一个JSON对象)
{"title": "新闻A", "views": 1000}
{"title": "新闻B", "views": 2000}

JSONL的三大优势

  1. 流式读写:可以一行行处理,不需要一次性加载整个文件到内存
  2. 追加友好:直接在文件末尾新增一行,不破坏已有数据
  3. 容错性强:某行损坏不影响其他行,而JSON文件一个括号错误就全军覆没

让我用真实案例说明为什么标准JSON不适合爬虫:

python 复制代码
# 标准JSON的问题场景
items = []
for page in range(1, 100):
    data = crawl_page(page)
    items.extend(data)

# 写入文件(问题!)
with open('data.json', 'w') as f:
    json.dump({'data': items}, f)  # 如果在第99页崩溃,前面98页数据全丢!
python 复制代码
# JSONL的优势场景
with open('data.jsonl', 'a') as f:  # 追加模式
    for page in range(1, 100):
        data = crawl_page(page)
        for item in data:
            f.write(json.dumps(item, ensure_ascii=False) + '\n')
            f.flush()  # 立即写入磁盘,崩溃也不丢数据

🔧 实现JSONL导出器

设计导出器基类

首先设计一个统一的接口,方便后续扩展其他格式(如XML、Parquet):

python 复制代码
# storage/base_exporter.py
from abc import ABC, abstractmethod
from typing import List, Dict, Any
from pathlib import Path

class BaseExporter(ABC):
    """导出器基类"""
    
    def __init__(self, output_file: str, mode: str = 'w'):
        """
        Args:
            output_file: 输出文件路径
            mode: 写入模式 ('w'=覆盖, 'a'=追加)
        """
        self.output_file = Path(output_file)
        self.mode = mode
        self.item_count = 0
        
        # 确保输出目录存在
        self.output_file.parent.mkdir(parents=True, exist_ok=True)
    
    @abstractmethod
    def open(self):
        """打开文件"""
        pass
    
    @abstractmethod
    def write_item(self, item: Dict[str, Any]):
        """写入单条数据"""
        pass
    
    @abstractmethod
    def write_batch(self, items: List[Dict[str, Any]]):
        """批量写入数据"""
        pass
    
    @abstractmethod
    def close(self):
        """关闭文件"""
        pass
    
    def __enter__(self):
        """支持with语句"""
        self.open()
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        """自动关闭文件"""
        self.close()

实现JSONL导出器

python 复制代码
# storage/jsonl_exporter.py
import json
from typing import List, Dict, Any, Optional
from datetime import datetime
from decimal import Decimal
from .base_exporter import BaseExporter

class JSONLExporter(BaseExporter):
    """JSONL格式导出器"""
    
    def __init__(self, output_file: str, mode: str = 'w', 
                 ensure_ascii: bool = False,
                 auto_flush: bool = True):
        """
        Args:
            output_file: 输出文件路径
            mode: 写入模式
            ensure_ascii: 是否转义非ASCII字符(False=保留中文)
            auto_flush: 每次写入后是否立即刷新到磁盘
        """
        super().__init__(output_file, mode)
        self.ensure_ascii = ensure_ascii
        self.auto_flush = auto_flush
        self.file_handle = None
    
    def open(self):
        """打开文件"""
        self.file_handle = open(
            self.output_file, 
            self.mode, 
            encoding='utf-8'
        )
        print(f"📂 已打开文件: {self.output_file} (模式: {self.mode})")
    
    def write_item(self, item: Dict[str, Any]):
        """
        写入单条数据
        
        Args:
            item: 数据字典
        """
        if not self.file_handle:
            raise RuntimeError("文件未打开,请先调用open()或使用with语句")
        
        # 序列化数据(处理特殊类型)
        serialized = self._serialize(item)
        
        # 写入一行JSON
        line = json.dumps(serialized, ensure_ascii=self.ensure_ascii)
        self.file_handle.write(line + '\n')
        
        # 立即刷新到磁盘(防止崩溃丢数据)
        if self.auto_flush:
            self.file_handle.flush()
        
        self.item_count += 1
    
    def write_batch(self, items: List[Dict[str, Any]]):
        """
        批量写入数据
        
        Args:
            items: 数据列表
        """
        for item in items:
            self.write_item(item)
    
    def close(self):
        """关闭文件"""
        if self.file_handle:
            self.file_handle.close()
            print(f"✅ 文件已关闭: {self.output_file} (共写入 {self.item_count} 条)")
    
    def _serialize(self, obj: Any) -> Any:
        """
        序列化特殊数据类型
        
        处理以下类型:
        - datetime → ISO字符串
        - Decimal → float
        - 自定义对象 → 字典
        """
        if isinstance(obj, dict):
            return {k: self._serialize(v) for k, v in obj.items()}
        elif isinstance(obj, list):
            return [self._serialize(item) for item in obj]
        elif isinstance(obj, datetime):
            return obj.isoformat()
        elif isinstance(obj, Decimal):
            return float(obj)
        elif hasattr(obj, '__dict__'):
            # 自定义对象转字典
            return self._serialize(obj.__dict__)
        else:
            return obj


# 便捷函数:直接导出列表
def export_to_jsonl(items: List[Dict], output_file: str, mode: str = 'w'):
    """
    快速导出数据到JSONL
    
    Args:
        items: 数据列表
        output_file: 输出文
    Examples:
        >>> data = [{"title": "新闻A"}, {"title": "新闻B"}]
        >>> export_to_jsonl(data, "news.jsonl")
    """
    with JSONLExporter(output_file, mode) as exporter:
        exporter.write_batch(items)

代码解析

  1. 编码处理ensure_ascii=False保留中文字符,避免"\u4e2d\u6587"这种不可读形式
  2. 立即刷新auto_flush=True确保每条数据立即写入磁盘,即使程序崩溃也不丢数据(性能损失可接受)
  3. 类型序列化 :自动处理datetimeDecimal等JSON不支持的类型
  4. 上下文管理 :支持with语句自动打开/关闭文件

使用示例

python 复制代码
# 示例1:基本使用
from storage.jsonl_exporter import JSONLExporter

news_data = [
    {"title": "新闻A", "views": 1000, "pub_date": datetime.now()},
    {"title": "新闻B", "views": 2000, "pub_date": datetime.now()},
]

# 方式1:使用with语句(推荐)
with JSONLExporter("news.jsonl", mode='w') as exporter:
    for item in news_data:
        exporter.write_item(item)

# 方式2:便捷函数
from storage.jsonl_exporter import export_to_jsonl
export_to_jsonl(news_data, "news.jsonl")


# 示例2:追加模式(断点续爬)
# 第一次采集
with JSONLExporter("products.jsonl", mode='w') as exporter:
    for product in crawl_page(1):
        exporter.write_item(product)

# 程序重启后继续采集
with JSONLExporter("products.jsonl", mode='a') as exporter:  # 追加模式!
    for product in crawl_page(2):
        exporter.write_item(product)


# 示例3:实时写入(长时间采集)
with JSONLExporter("live_data.jsonl", mode='w', auto_flush=True) as exporter:
    for i in range(10000):
        item = crawl_one_item(i)
        exporter.write_item(item)
        # 即使程序在第5000条崩溃,前4999条已经安全保存

📊 实现CSV导出器

CSV格式的特殊挑战

CSV看似简单,实际有很多坑:

python 复制代码
# 问题1:字段包含逗号
{"title": "新闻标题,很重要"}  # CSV会被错误分割成两列!

# 问题2:字段包含换行
{"content": "第一段\n第二段"}  # CSV会被识别为两行!

# 问题3:字段包含引号
{"author": '张"小明"'}  # 引号需要转义!

# 问题4:嵌套结构
{"tags": ["科技", "AI"]}  # CSV不支持列表!

解决方案 :使用Python内置的csv模块,它已经处理了这些边界情况。

实现CSV导出器

python 复制代码
# storage/csv_exporter.py
import csv
from typing import List, Dict, Any, Optional
from datetime import datetime
from decimal import Decimal
from .base_exporter import BaseExporter

class CSVExporter(BaseExporter):
    """CSV格式导出器"""
    
    def __init__(self, output_file: str, mode: str = 'w',
                 fieldnames: List[str] = None,
                 auto_detect_fields: bool = True,
                 flatten_nested: bool = True):
        """
        Args:
            output_file: 输出文件路径
            mode: 写入模式
            fieldnames: 字段列表(列顺序),None=自动检测
            auto_detect_fields: 是否自动检测字段(从第一条数据推断)
            flatten_nested: 是否展平嵌套结构(列表→字符串)
        """
        super().__init__(output_file, mode)
        self.fieldnames = fieldnames
        self.auto_detect_fields = auto_detect_fields
        self.flatten_nested = flatten_nested
        self.file_handle = None
        self.csv_writer = None
        self._header_written = False
    
    def open(self):
        """打开文件"""
        self.file_handle = open(
            self.output_file,
            self.mode,
            encoding='utf-8',
            newline=''  # 重要!防止Windows下出现空行
        )
        print(f"📂 已打开文件: {self.output_file} (模式: {self.mode})")
    
    def write_item(self, item: Dict[str, Any]):
        """写入单条数据"""
        if not self.file_handle:
            raise RuntimeError("文件未打开")
        
        # 第一次写入时初始化字段
        if not self._header_written:
            self._initialize_writer(item)
        
        # 展平并序列化数据
        flattened = self._flatten(item)
        serialized = self._serialize_row(flattened)
        
        # 写入行
        self.csv_writer.writerow(serialized)
        self.item_count += 1
    
    def write_batch(self, items: List[Dict[str, Any]]):
        """批量写入"""
        for item in items:
            self.write_item(item)
    
    def close(self):
        """关闭文件"""
        if self.file_handle:
            self.file_handle.close()
            print(f"✅ 文件已关闭: {self.output_file} (共写入 {self.item_count} 条)")
    
    def _initialize_writer(self, first_item: Dict):
        """初始化CSV写入器(从第一条数据推断字段)"""
        if self.fieldnames is None and self.auto_detect_fields:
            # 自动检测字段
            self.fieldnames = list(first_item.keys())
        
        if not self.fieldnames:
            raise ValueError("无法确定CSV字段,请指定fieldnames参数")
        
        # 创建CSV写入器
        self.csv_writer = csv.DictWriter(
            self.file_handle,
            fieldnames=self.fieldnames,
            extrasaction='ignore'  # 忽略不在fieldnames中的字段
        )
        
        # 写入表头(仅写入模式且文件为空时)
        if self.mode == 'w' or self.output_file.stat().st_size == 0:
            self.csv_writer.writeheader()
        
        self._header_written = True
    
    def _flatten(self, item: Dict) -> Dict:
        """展平嵌套结构"""
        if not self.flatten_nested:
            return item
        
        flattened = {}
        for key, value in item.items():
            if isinstance(value, (list, dict)):
                # 列表/字典转JSON字符串
                import json
                flattened[key] = json.dumps(value, ensure_ascii=False)
            else:
                flattened[key] = value
        
        return flattened
    
    def _serialize_row(self, row: Dict) -> Dict:
        """序列化单行数据(处理特殊类型)"""
        serialized = {}
        for key, value in row.items():
            if isinstance(value, datetime):
                serialized[key] = value.isoformat()
            elif isinstance(value, Decimal):
                serialized[key] = str(value)
            elif value is None:
                serialized[key] = ''  # 空值转空字符串
            else:
                serialized[key] = str(value)
        
        return serialized


# 便捷函数
def export_to_csv(items: List[Dict], output_file: str, 
                  fieldnames: List[str] = None, mode: str = 'w'):
    """
    快速导出数据到CSV
    
    Args:
        items: 数据列表
        output_file: 输出文件
        fieldnames: 字段顺序(None=自动检测)
        mode: 写入模式
    
    Examples:
        >>> data = [{"title": "新闻A", "views": 1000}]
        >>> export_to_csv(data, "news.csv")
    """
    with CSVExporter(output_file, mode, fieldnames=fieldnames) as exporter:
        exporter.write_batch(items)

代码解析

  1. 字段顺序 :CSV有列顺序概念,通过fieldnames参数控制。如果不指定,从第一条数据推断
  2. 嵌套处理:列表和字典转为JSON字符串存储,虽然不完美但能保留信息
  3. 换行符处理newline=''参数防止Windows系统产生空行
  4. 追加模式:追加时不重复写表头(检查文件是否为空)

使用示例

python 复制代码
# 示例1:基本使用
from storage.csv_exporter import CSVExporter

news_data = [
    {"title": "新闻A", "author": "张三", "views": 1000},
    {"title": "新闻B", "author": "李四", "views": 2000},
]

with CSVExporter("news.csv") as exporter:
    exporter.write_batch(news_data)


# 示例2:指定字段顺序
fieldnames = ['title', 'views', 'author']  # 调整顺序
with CSVExporter("news.csv", fieldnames=fieldnames) as exporter:
    exporter.write_batch(news_data)


# 示例3:处理嵌套数据
complex_data = [
    {
        "title": "新闻A",
        "tags": ["科技", "AI"],  # 列表
        "meta": {"source": "网站A"}  # 字典
    }
]

with CSVExporter("complex.csv", flatten_nested=True) as exporter:
    exporter.write_batch(complex_data)

# complex.csv内容:
# title,tags,meta
# 新闻A,"[""科技"", ""AI""]","{""source"": ""网站A""}"

🔖 元数据记录:让数据可追溯

为什么需要元数据?

想象一个月后你打开news_20240115.jsonl文件,你可能会忘记:

  • 这是从哪个网站采集的?
  • 采集时间是什么时候?
  • 用的是什么版本的爬虫代码?
  • 数据schema是什么(字段含义)?

**元数据(Metadata)**就是"关于数据的数据",它让数据具备可追溯性。

实现元数据记录器

python 复制代码
# storage/meta.py
import json
from datetime import datetime
from typing import Dict, List, Any
from pathlib import Path

class MetadataRecorder:
    """元数据记录器"""
    
    @staticmethod
    def create_metadata(
        data_file: str,
        source: str,
        item_count: int,
        schema: Dict[str, str] = None,
        extra: Dict = None
    ) -> Dict:
        """
        创建元数据
        
        Args:
            data_file: 数据文件路径
            source: 数据来源(URL/网站名)
            item_count: 数据条数
            schema: 字段schema(字段名→描述)
            extra: 额外信息
        
        Returns:
            元数据字典
        """
        metadata = {
            "data_file": str(data_file),
            "created_at": datetime.now().isoformat(),
            "source": source,
            "item_count": item_count,
            "schema": schema or {},
            "file_size_bytes": Path(data_file).stat().st_size if Path(data_file).exists() else 0,
        }
        
        if extra:
            metadata["extra"] = extra
        
        return metadata
    
    @staticmethod
    def save_metadata(metadata: Dict, meta_file: str):
        """保存元数据到文件"""
        with open(meta_file, 'w', encoding='utf-8') as f:
            json.dump(metadata, f, ensure_ascii=False, indent=2)
        print(f"📋 元数据已保存到: {meta_file}")
    
    @staticmethod
    def load_metadata(meta_file: str) -> Dict:
        """加载元数据"""
        with open(meta_file, 'r', encoding='utf-8') as f:
            return json.load(f)
    
    @staticmethod
    def auto_save_metadata(
        data_file: str,
        source: str,
        item_count: int,
        schema: Dict[str, str] = None
    ):
        """
        自动保存元数据(数据文件同目录下)
        
        元数据文件名: <数据文件名>.meta.json
        例如: news.jsonl → news.jsonl.meta.json
        """
        meta_file = str(data_file) + ".meta.json"
        metadata = MetadataRecorder.create_metadata(
            data_file, source, item_count, schema
        )
        MetadataRecorder.save_metadata(metadata, meta_file)

改进导出器:自动记录元数据

python 复制代码
# storage/jsonl_exporter.py (改进版)
class JSONLExporterWithMeta(JSONLExporter):
    """带元数据记录的JSONL导出器"""
    
    def __init__(self, output_file: str, mode: str = 'w',
                 source: str = "", schema: Dict[str, str] = None):
        super().__init__(output_file, mode)
        self.source = source
        self.schema = schema
    
    def close(self):
        """关闭文件并保存元数据"""
        super().close()
        
        # 自动保存元数据
        if self.source:
            from .meta import MetadataRecorder
            MetadataRecorder.auto_save_metadata(
                self.output_file,
                self.source,
                self.item_count,
                self.schema
            )


# 使用示例
schema = {
    "title": "新闻标题",
    "author": "作者名称",
    "pub_date": "发布日期(ISO格式)",
    "views": "浏览量(整数)"
}

with JSONLExporterWithMeta(
    "news.jsonl",
    source="https://news.example.com",
    schema=schema
) as exporter:
    exporter.write_batch(news_data)

# 自动生成 news.jsonl.meta.json:
# {
#   "data_file": "news.jsonl",
#   "created_at": "2024-01-22T10:30:00",
#   "source": "https://news.example.com",
#   "item_count": 100,
#   "schema": {
#     "title": "新闻标题",
#     ...
#   },
#   "file_size_bytes": 52480
# }

🛠️ 实战案例:完整的新闻采集+存储

让我们用一个完整案例串联所有知识点:

python 复制代码
# example_news_crawler_with_storage.py
"""
新闻采集+多格式存储完
from datetime import datetime
from storage.jsonl_exporter import JSONLExporterWithMeta
from storage.csv_exporter import CSVExporter
from storage.meta import MetadataRecorder

def crawl_news_with_storage(base_url: str, pages: int = 5):
    """
    采集新闻并保存为多种格式
    
    Args:
        base_url: 新闻网站URL
        pages: 采集页数
    """
    print(f"🚀 开始采集新闻: {base_url}\n")
    
    # 1. 定义数据schema
    schema = {
        "url": "新闻URL(唯一标识)",
        "title": "新闻标题",
        "author": "作者名称",
        "pub_date": "发布日期(ISO格式)",
        "content": "正文内容",
        "views": "浏览量",
        "tags": "标签列表(JSON数组)"
    }
    
    # 2. 创建导出器
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    jsonl_file = f"data/news_{timestamp}.jsonl"
    csv_file = f"data/news_{timestamp}.csv"
    
    jsonl_exporter = JSONLExporterWithMeta(
        jsonl_file,
        source=base_url,
        schema=schema
    )
    
    csv_exporter = CSVExporter(
        csv_file,
        fieldnames=['url', 'title', 'author', 'pub_date', 'views']  # CSV只保存主要字段
    )
    
    # 3. 采集数据并实时保存
    total_items = 0
    
    with jsonl_exporter, csv_exporter:  # 同时打开两个导出器
        for page in range(1, pages + 1):
            print(f"📄 采集第 {page} 页...")
            
            # 模拟采集(实际项目中替换为真实爬虫逻辑)
            page_url = f"{base_url}/page/{page}"
            items = simulate_crawl_page(page_url)
            
            for item in items:
                # 同时写入JSONL和CSV
                jsonl_exporter.write_item(item)
                csv_exporter.write_item(item)
                total_items += 1
            
            print(f"   ✓ 已保存 {len(items)} 条")
    
    print(f"\n✅ 采集完成!共 {total_items} 条数据")
    print(f"📂 JSONL文件: {jsonl_file}")
    print(f"📂 CSV文件: {csv_file}")
    print(f"📋

def simulate_crawl_page(url: str):
    """模拟采集单页数据"""
    import random
    
    items = []
    for i in range(10):  # 每页10条
        items.append({
            "url": f"{url}/article/{i}",
            "title": f"新闻标题{i}",
            "author": f"作者{random.randint(1,5)}",
            "pub_date": datetime.now().isoformat(),
            "content": f"这是新闻{i}的正文内容...",
            "views": random.randint(100, 10000),
            "tags": ["科技", "AI"] if i % 2 == 0 else ["财经"]
        })
    
    return items


if __name__ == "__main__":
    crawl_news_with_storage("https://news.example.com", pages=3)

运行结果

json 复制代码
🚀 开始采集新闻: https://news.example.com

📄 采集第 1 页...
📂 已打开文件: data/news_20240122_103000.jsonl (模式: w)
📂 已打开文件: data/news_20240122_103000.csv (模式: w)
   ✓ 已保存 10 条
📄 采集第 2 页...
   ✓ 已保存 10 条
📄 采集第 3 页...
   ✓ 已保存 10 条

✅ 文件已关闭: data/news_20240122_103000.jsonl (共写入 30保存到: data/news_20240122_103000.jsonl.meta.json
✅ 文件已关闭: data/news_20240122_103000.csv (共写入 30 条)

✅ 采集完成!共 30 条数据
📂 JSONL文件: data/news_20240122_103000.jsonl
📂 CSV文件: data/news_20240122_103000.csv
📋 元数据: data/news_20240122_103000.jsonl.meta.json

📚 最佳实践与进阶技巧

实践1:大文件分片存储

当数据量很大时(如百万条),单个文件会难以处理。可以按数量或大小分片:

python 复制代码
# storage/sharded_exporter.py
class ShardedJSONLExporter:
    """分片JSONL导出器"""
    
    def __init__(self, output_prefix: str, items_per_shard: int = 10000):
        """
        Args:
            output_prefix: 输出文件前缀(如"news")
            items_per_shard: 每片数据量
        """
        self.output_prefix = output_prefix
        self.items_per_shard = items_per_shard
        self.current_shard = 0
        self.current_exporter = None
        self.total_items = 0
    
    def _get_shard_filename(self, shard_num: int) -> str:
        """生成分片文件名"""
        return f"{self.output_prefix}_shard_{shard_num:04d}.jsonl"
    
    def _rotate_shard(self):
        """切换到新分片"""
        if self.current_exporter:
            self.current_exporter.close()
        
        self.current_shard += 1
        filename = self._get_shard_filename(self.current_shard)
        self.current_exporter = JSONLExporter(filename, mode='w')
        self.current_exporter.open()
        
        print(f"📦 切换到新分片: {filename}")
    
    def write_item(self, item: dict):
        """写入数据(自动分片)"""
        # 检查是否需要新分片
        if self.total_items % self.items_per_shard == 0:
            self._rotate_shard()
        
        self.current_exporter.write_item(item)
        self.total_items += 1
    
    def close(self):
        """关闭当前分片"""
        if self.current_exporter:
            self.current_exporter.close()
        
        print(f"✅ 分片完成: 共 {self.current_shard} 个文件,{self.total_items} 条数据")


# 使用示例
exporter = ShardedJSONLExporter("big_data", items_per_shard=10000)

for i in range(25000):  # 2.5万条数据
    exporter.write_item({"id": i, "data": f"item_{i}"})

exporter.close()

# 输出:
# 📦 切换到新分片: big_data_shard_0001.jsonl
# 📦 切换到新分片: big_data_shard_0002.jsonl
# 📦 切换到新分片: big_data_shard_0003.jsonl
# ✅ 分片完成: 共 3 个文件,25000 条数据

实践2:压缩存储节省空间

对于大规模数据,可以启用gzip压缩:

python 复制代码
# storage/compressed_exporter.py
import gzip
import json

class CompressedJSONLExporter:
    """压缩JSONL导出器"""
    
    def __init__(self, output_file: str, compression_level: int = 6):
        """
        Args:
            output_file: 输出文件(自动添加.gz后缀)
            compression_level: 压缩级别(1-9,9=最高压缩)
        """
        if not output_file.endswith('.gz'):
            output_file += '.gz'
        
        self.output_file = output_file
        self.compression_level = compression_level
        self.file_handle = None
        self.item_count = 0
    
    def open(self):
        """打开压缩文件"""
        self.file_handle = gzip.open(
            self.output_file,
            'wt',  # 文本模式
            encoding='utf-8',
            compresslevel=self.compression_level
        )
        print(f"📦 已打开压缩文件: {self.output_file}")
    
    def write_item(self, item: dict):
        """写入数据"""
        line = json.dumps(item, ensure_ascii=False)
        self.file_handle.write(line + '\n')
        self.item_count += 1
    
    def close(self):
        """关闭文件"""
        if self.file_handle:
            self.file_handle.close()
            
            # 显示压缩效果
            from pathlib import Path
            file_size = Path(self.output_file).stat().st_size
            print(f"✅ 压缩文件已保存: {self.output_file}")
            print(f"   文件大小: {file_size / 1024:.2f} KB ({self.item_count} 条)")
    
    def __enter__(self):
        self.open()
        return self
    
    def __exit__(self, *args):
        self.close()


# 使用示例
with CompressedJSONLExporter("large_data.jsonl.gz") as exporter:
    for i in range(100000):
        exporter.write_item({"id": i, "text": "some long text..." * 10})

# 压缩效果:10MB原始数据 → 1MB压缩文件(压缩比90%)

实践3:增量导出与版本管理

在持续采集场景下,需要区分增量数据:

python 复制代码
# storage/incremental_exporter.py
from datetime import datetime
from pathlib import Path
import json

class IncrementalExporter:
    """增量导出器(支持版本管理)"""
    
    def __init__(self, base_dir: str = "data/incremental"):
        self.base_dir = Path(base_dir)
        self.base_dir.mkdir(parents=True, exist_ok=True)
        self.version_file = self.base_dir / "versions.json"
        self.versions = self._load_versions()
    
    def _load_versions(self) -> dict:
        """加载版本记录"""
        if self.version_file.exists():
            with open(self.version_file, 'r') as f:
                return json.load(f)
        return {}
    
    def _save_versions(self):
        """保存版本记录"""
        with open(self.version_file, 'w') as f:
            json.dump(self.versions, f, indent=2)
    
    def create_version(self, version_name: str = None) -> str:
        """
        创建新版本
        
        Args:
            version_name: 版本名(默认使用时间戳)
        
        Returns:
            版本号
        """
        if version_name is None:
            version_name = datetime.now().strftime("v%Y%m%d_%H%M%S")
        
        version_info = {
            "created_at": datetime.now().isoformat(),
            "file": f"{version_name}.jsonl",
            "item_count": 0
        }
        
        self.versions[version_name] = version_info
        self._save_versions()
        
        return version_name
    
    def get_exporter(self, version_name: str):
        """获取指定版本的导出器"""
        if version_name not in self.versions:
            raise ValueError(f"版本不存在: {version_name}")
        
        file_path = self.base_dir / self.versions[version_name]["file"]
        return JSONLExporter(str(file_path), mode='a')
    
    def list_versions(self):
        """列出所有版本"""
        print("📚 可用版本:")
        for version, info in sorted(self.versions.items()):
            print(f"   {version}: {info['item_count']} 条 ({info['created_at']})")


# 使用示例
manager = IncrementalExporter()

# 第一天采集
v1 = manager.create_version("v20240122")
with manager.get_exporter(v1) as exporter:
    exporter.write_batch([{"id": 1}, {"id": 2}])

# 第二天增量采集
v2 = manager.create_version("v20240123")
with manager.get_exporter(v2) as exporter:
    exporter.write_batch([{"id": 3}, {"id": 4}])

# 查看版本历史
manager.list_versions()
# 输出:
# 📚 可用版本:
#    v20240122: 2 条 (2024-01-22T10:00:00)
#    v20240123: 2 条 (2024-01-23T10:00:00)

🎯 本讲总结与检查清单

核心知识点回顾

CSV vs JSONL选择

  • CSV适合简单扁平数据,Excel友好
  • JSONL适合复杂嵌套数据,程序友好
  • 推荐同时保存两种格式

导出器实现

  • 统一基类接口,便于扩展新格式
  • 支持追加模式(断点续爬)
  • 自动处理编码、换行、特殊字符
  • 实时刷新防止数据丢失

元数据记录

  • 记录数据来源、采集时间、schema
  • 使用.meta.json文件存储元数据
  • 让数据具备可追溯性

进阶技巧

  • 大文件分片存储
  • gzip压缩节省空间
  • 增量导出与版本管理

验收任务

请完成以下任务,检验本讲学习效果:

任务1:实现双格式导出

  • 实现JSONLExporterCSVExporter
  • 支持追加模式写入
  • 正确处理中文、换行、特殊字符
  • 同一份数据同时导出CSV和JSONL

任务2:元数据记录

  • 为每个数据文件生成.meta.json
  • 元数据包含:来源、时间、数量、schema
  • 实现元数据加载和验证功能

任务3:真实数据测试

  • 采集真实网站数据(如新闻/商品)
  • 导出为CSV和JSONL格式
  • 用Excel打开CSV验证可读性
  • 用Python读取JSONL验证完整性

常见问题FAQ

Q1:CSV中的换行符问题如何处理?

python 复制代码
# ✅ 正确做法:使用csv模块自动转义
with open('data.csv', 'w', newline='', encoding='utf-8') as f:
    writer = csv.DictWriter(f, fieldnames=['content'])
    writer.writerow({'content': '第一行\n第二行'})  # 自动处理

# ❌ 错误做法:手动拼接字符串
with open('data.csv', 'w') as f:
    f.write('第一行\n第二行')  # 会被识别为两行!

Q2:如何读取JSONL文件?

python 复制代码
# 逐行读取(内存友好)
def read_jsonl(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        for line in f:
            yield json.loads(line)

# 使用
for item in read_jsonl('data.jsonl'):
    print(item)

Q3:大文件导出时内存溢出怎么办?

python 复制代码
# ✅ 使用流式写入,逐条处理
with JSONLExporter('large.jsonl') as exporter:
    for item in crawl_iterator():  # 迭代器而非列表
        exporter.write_item(item)  # 立即写入,不累积

# ❌ 避免一次性加载全部数据
all_data = list(crawl_iterator())  # 内存爆炸!
exporter.write_batch(all_data)

📖 下期预告

恭喜你掌握了文件存储的核心技能!现在你能够用最通用的格式保存采集数据,并确保数据的可读性、可追溯性和可分享性。

但在实际项目中,你可能还需要一个更强大的存储方案------数据库。特别是当你需要:

  • 高效查询和筛选数据
  • 支持多人并发访问
  • 建立数据之间的关联关系

下一讲《17|SQLite实战:单机最佳,零配置(附防重插入)》,我们将学习:

SQLite基础

  • 为什么推荐SQLite作为入门数据库
  • 创建表结构和索引
  • 基本增删改查操作

爬虫专用技巧

  • 防重复插入机制(UNIQUE约束)
  • 批量插入性能优化
  • 事务管理防止数据损坏

数据迁移

  • 从JSONL文件导入SQLite
  • 导出查询结果为CSV
  • 数据库备份和恢复

在第17讲结束后,你将拥有一个轻量级但功能完整的本地数据库,为后续的数据分析和应用开发打下坚实基础!💪

本讲配套代码已上传至:
https://github.com/your-repo/python-spider-course/tree/main/chapter05/lesson16

包含:

  • 完整的storage工具包
  • CSV/JSONL导出器
  • 元数据记录工具
  • 分片/压缩/增量导出器
  • 真实采集案例

记得给仓库点个⭐,我们下一讲见!🎉

🌟 文末

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

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


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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
子夜江寒2 小时前
OpenCV 学习:图像拼接与答题卡识别的实现
python·opencv·学习·计算机视觉
bjxiaxueliang2 小时前
一文掌握Python Flask:HTTP微服务开发从入门到部署
python·http·flask
SunnyRivers3 小时前
Python 中的 HTTP 客户端:Requests、HTTPX 与 AIOHTTP 对比
python·httpx·requests·aiohttp·区别
u0109272713 小时前
持续集成/持续部署(CI/CD) for Python
jvm·数据库·python
lixin5565563 小时前
基于迁移学习的图像风格增强器
java·人工智能·pytorch·python·深度学习·语言模型
阡陌..3 小时前
浅谈SAR图像处理---形态学滤波
图像处理·人工智能·python
qq_229058014 小时前
python-Dgango项目收集静态文件、构建前端、安装依赖
开发语言·python
测试人社区—66794 小时前
2025区块链分层防御指南:AI驱动的安全测试实战策略
开发语言·驱动开发·python·appium·pytest
喵手4 小时前
Python爬虫零基础入门【第九章:实战项目教学·第10节】下载型资源采集:PDF/附件下载 + 去重校验!
爬虫·python·爬虫实战·python爬虫工程化实战·零基础python爬虫教学·下载型资源采集·pdf下载