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

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📌 上期回顾](#📌 上期回顾)
- [🎯 本讲目标](#🎯 本讲目标)
- [💡 CSV vs JSONL:如何选择?](#💡 CSV vs JSONL:如何选择?)
- [📖 深入理解JSONL格式(为什么不用JSON?)](#📖 深入理解JSONL格式(为什么不用JSON?))
-
- [JSON vs JSONL的关键区别](#JSON vs JSONL的关键区别)
- [🔧 实现JSONL导出器](#🔧 实现JSONL导出器)
- [📊 实现CSV导出器](#📊 实现CSV导出器)
- [🔖 元数据记录:让数据可追溯](#🔖 元数据记录:让数据可追溯)
- [🛠️ 实战案例:完整的新闻采集+存储](#🛠️ 实战案例:完整的新闻采集+存储)
- [📚 最佳实践与进阶技巧](#📚 最佳实践与进阶技巧)
- [🎯 本讲总结与检查清单](#🎯 本讲总结与检查清单)
- [📖 下期预告](#📖 下期预告)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: 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- 元数据记录工具- 完整的测试用例和使用文档
验收标准:
- 能够将采集数据导出为CSV和JSONL格式
- 支持追加写入模式(断点续爬场景)
- 正确处理编码、换行符等边界情况
- 记录元数据(采集时间、数据量、字段schema等)
- 导出的文件能在其他工具中正常打开和使用
💡 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的三大优势:
- 流式读写:可以一行行处理,不需要一次性加载整个文件到内存
- 追加友好:直接在文件末尾新增一行,不破坏已有数据
- 容错性强:某行损坏不影响其他行,而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)
代码解析:
- 编码处理 :
ensure_ascii=False保留中文字符,避免"\u4e2d\u6587"这种不可读形式 - 立即刷新 :
auto_flush=True确保每条数据立即写入磁盘,即使程序崩溃也不丢数据(性能损失可接受) - 类型序列化 :自动处理
datetime和Decimal等JSON不支持的类型 - 上下文管理 :支持
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)
代码解析:
- 字段顺序 :CSV有列顺序概念,通过
fieldnames参数控制。如果不指定,从第一条数据推断 - 嵌套处理:列表和字典转为JSON字符串存储,虽然不完美但能保留信息
- 换行符处理 :
newline=''参数防止Windows系统产生空行 - 追加模式:追加时不重复写表头(检查文件是否为空)
使用示例
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:实现双格式导出
- 实现
JSONLExporter和CSVExporter - 支持追加模式写入
- 正确处理中文、换行、特殊字符
- 同一份数据同时导出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 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。