第 9 讲 · 三格式落盘:CSV / Excel / JSON
本讲目标 :把内存里的
list[dict],变成「同事能打开、程序能读、还能接着往下用」的文件。三种格式各管一段:CSV 通用、Excel 交付、JSON 给程序。
配套代码 :
spiders/s09_save_formats.py运行 :
python spiders/s09_save_formats.py本讲产物 :
data/output/下的第09讲_books.csv、第09讲_books.xlsx、第09讲_图书报表.xlsx、第09讲_books_sample.json、第09讲_books.jsonl
---下载地址:git clone https://gitee.com/wuyikui555/python-spider.git
一、CSV:最通用,但有两个必踩的坑
爬完不存 = 白爬。而「存」这件事,很多人是这么写的:
python
# 反面教材
with open("books.csv", "w") as f: # 没有 encoding
f.write("title,price\n")
f.write("极简操作系统,116.0\n")
在 Windows 上用 Excel 打开这个文件,你会看到两件事同时发生:
- 中文列变成一串「烫烫烫」式的乱码;
- 每两行数据之间多出一整行空行。
它们都不报错,只是安静地让你的文件变成废品------这才是最麻烦的。
正确写法(utils/store.py):
python
def save_csv(rows: list[dict], path: Path, fieldnames: list[str] | None = None) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
fields = fieldnames or list(rows[0].keys())
with open(path, "w", newline="", encoding="utf-8-sig") as f: # ← 两个关键参数
writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
return path
实测输出:
写出:第09讲_books.csv 1072 字节
文件头三字节:b'\xef\xbb\xbf' → utf-8-sig(BOM) 已写入 ✔
坑 1:不加 BOM,Excel 打开中文就是乱码。
解法:encoding='utf-8-sig'(多写三个字节,救回一整列中文)
坑 2:open 时不写 newline='',Windows 上每行之间会多一个空行。
原因:csv 模块自己会写 \r\n,文本模式又把 \n 翻译成 \r\n。
两个坑的根因,一张表说清:
| 现象 | 根因 | 解法 |
|---|---|---|
| Excel 打开中文乱码 | Excel 不认「无 BOM 的 UTF-8」,它会按本机 GBK 猜 | encoding="utf-8-sig",写入 3 字节 BOM EF BB BF |
| 每行之间多空行 | csv 模块写 \r\n,文本模式又把 \n 翻译成 \r\n → 变 \r\r\n |
open(..., newline="") 关掉翻译 |
utf-8-sig 这个编码名的意思是「UTF-8 with BOM」:写文件时会自动补上 EF BB BF 三字节,读文件时又会自动吃掉它们。上面那行 文件头三字节:b'\xef\xbb\xbf' 就是脚本自己读回来验证的------BOM 确实写进去了。
读回来时的一个真相:CSV 没有类型
读回来 12 行,第一条:极简操作系统 / ¥116.0
★ 注意:读回来的 price 是字符串 '45.0'。CSV 没有类型,全是文本。
CSV 是一张纯文本表。写进去的 116.0,读出来是字符串 '116.0'(日志里那条 45.0 是脚本里写死的对照示例)。如果你读回来直接拿它做算术,得先 float()。 想要保住类型,就别用 CSV------往下看 JSON 和 SQLite。
二、列表字段:CSV 的单元格装不下列表
我们的数据里有 tags 这种字段,它的值是 ['彩印', '签名版', '畅销']------一个 list。
CSV 的每个格子只能装一个字符串。那列表怎么办?实测给了两个方案:
原始数据 : ['彩印', '签名版', '畅销'] (list)
方案 A 拼字符串 : '彩印|签名版|畅销' → 简单,但取出来要再 split
方案 B 存 JSON : '["彩印", "签名版", "畅销"]' → 保留结构,Excel 里看着丑
★ 本工程用方案 A:'畅销|新书'。原因是交付给同事的表格,
可读性比结构完整性更重要。要还原就 split('|')。
两个方案的本质区别:
| 方案 | 存进去的样子 | 还原方式 | 适合谁 |
|---|---|---|---|
| A 拼接 | `彩印 | 签名版 | 畅销` |
| B JSON | ["彩印", "签名版", "畅销"] |
json.loads(s) |
程序二次处理 |
选哪个没有标准答案,但要提前定死 。最怕的是同一个字段,这批用 |、下批用 ,、再下批没人知道用了什么------文件就废了。
(顺带提醒:如果你选的拼接符是 ,,而它正好又是 CSV 的分隔符,那这个字段会被劈成好几列。用 |、; 或 \x1f 这种不常见字符更稳。)
三、Excel:交付场景的默认选择
要给领导、同事交东西,.xlsx 比 .csv 体面得多。原因就一个:
CSV 一个文件只能装一张表。而一份交付报表,往往既要明细、又要汇总。
本工程的做法是把「图书明细」和「分类统计」塞进同一个文件的两张 sheet:
python
p2 = save_multi_sheet({"图书明细": rows, "分类统计": summary},
out_path("第09讲_图书报表.xlsx"))
实测输出:
单表:第09讲_books.xlsx 5909 字节
多表:第09讲_图书报表.xlsx 6588 字节 (含 图书明细 / 分类统计 两张表)
计算机 3 本 均价 ¥118.5
文学 3 本 均价 ¥51.5
历史 2 本 均价 ¥73.4
经济 2 本 均价 ¥77.2
科普 2 本 均价 ¥76.6
★ 为什么不是 CSV?因为 CSV 一个文件只能装一张表。
交付报表时,Excel + 多 Sheet 是唯一体面的选择。
底层就是 pandas + openpyxl,一个 ExcelWriter 里循环 to_excel 即可(utils/store.py):
python
def save_multi_sheet(sheets: dict[str, list[dict]], path: Path) -> Path:
import pandas as pd
path.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(path, engine="openpyxl") as writer:
for name, rows in sheets.items():
pd.DataFrame(rows).to_excel(writer, index=False,
sheet_name=name[:31] or "Sheet1")
return path
Excel 的两个隐性坑
坑一:sheet 名有 31 字符上限。 上面代码里那个 name[:31] 不是装饰------Excel 硬性规定 sheet 名最多 31 个字符,超了 openpyxl 直接抛异常。截断 + or "Sheet1" 兜底,是为了防止你把一整句话当表名。
坑二:列宽是默认值,中文全被挤成「####」。 openpyxl 不会自动调列宽。交付前如果想好看一点,可以自己设:
python
# 生产写法(本工程不做,因为示例数据短)
from openpyxl.utils import get_column_letter
for i, col in enumerate(df.columns, start=1):
width = max(len(str(col)) * 2, *(len(str(v)) for v in df[col].astype(str)))
ws.column_dimensions[get_column_letter(i)].width = min(width + 4, 60)
一个必须知道的表格安全问题:公式注入
CSV 和 Excel 都有这个问题,而且后果比乱码严重得多。
如果单元格的内容以 =、+、-、@ 开头,Excel / WPS 会把它当公式执行 。而爬来的文本是不可信的------万一某条数据的标题是以 =cmd|' /C calc'!A0 开头(老版本 DDE 攻击),同事双击打开表格的那一刻,公式就跑了。
防御很简单:导出前给这些字符开头的单元格加个前缀,让它变成纯文本。
python
# 生产写法:导出前清洗
def _defuse(v: str) -> str:
s = str(v)
return "'" + s if s[:1] in ("=", "+", "-", "@") else s
rows = [{k: _defuse(v) for k, v in r.items()} for r in rows]
记住:只要数据来源不是你自己,导出成表格前就该走一遍这步。 本工程的靶场数据是安全的,所以示例里没加,但真实项目别省。
四、JSON 与 JSONL:给程序读的格式
给程序读的格式,首选 JSON。它保类型、保结构(列表还是列表),不会像 CSV 那样把 116.0 变成 '116.0'。
python
def save_json(rows, path: Path, indent: int = 2) -> Path:
path.write_text(json.dumps(rows, ensure_ascii=False, indent=indent),
encoding="utf-8")
return path
实测输出(注意 ensure_ascii=False 的效果------中文原样保留,不是 \uXXXX):
写出:第09讲_books_sample.json
内容片段:
[
{
"id": 1,
"title": "极简操作系统",
"author": "郑一鸣",
"category": "计算机",
"price": 116.0,
"rating": 9.6,
"stock": 83,
"tags": [
"彩印",
"签名版",
"畅销"
]
},
这里 price 是 116.0(数字)、tags 是真正的数组------类型和结构全保住了,这是 CSV 给不了的。
但 JSON 有个规模问题:它是一个大数组,必须一次性读完。几万条还行,几百万条就会吃光内存。
于是有了 JSONL(JSON Lines)------每一行是一个独立的 JSON 对象:
JSONL:第09讲_books.jsonl 每行一条,共 12 行
★ JSONL 的好处:进程崩了,已经写出去的几万行还在,不用从头再爬。
python
with open(jsonl, "w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
两种格式怎么选:
| JSON | JSONL | |
|---|---|---|
| 结构 | 一个大数组 [...] |
一行一对象,无外层数组 |
| 读取 | 一次性 json.load,全进内存 |
逐行读,内存恒定 |
| 追加 | 要改整个文件 | 直接 open(..., "a") 追加一行 |
| 崩了之后 | 文件可能不完整,整个作废 | 已写出的行都还在 |
| 适合 | 几千条以内、要给人看结构 | 百万级、要边爬边存 |
一句话记忆:JSON 是「一次性交付」,JSONL 是「边爬边写」。
五、把 base_url 换成真实站点时,多留意这几处
本讲所有案例打的是本地靶场,编码、分页、字段都规规矩矩。换成真实站点,下面几点会立刻找你麻烦:
- 编码不一定是 UTF-8。 国内不少老站还是 GBK/GB2312。存 CSV 时
encoding="utf-8-sig"没问题(你统一转码),但读取响应 时要按resp.encoding走,否则解析阶段就已经乱码了。 - 表头顺序会漂。
save_csv默认fieldnames=list(rows[0].keys())------如果某条数据的 key 顺序不同,或者缺字段,列就对不齐。真实项目里显式传fieldnames,把顺序钉死。 - 字段里可能混着 HTML。 有的接口把简介原样塞进 JSON,带着
<p><br>。落盘前用clean()(utils/parse.py)压一遍空白、剥掉标签。 - 分页字段名五花八门。 靶场是
total_pages,别的地方可能是pages、totalPage、last_page。翻页边界判断不能想当然。 - 导出前先问一句「这份数据给谁看」。 给人看 → Excel;给程序看 → JSON/JSONL;要反复增量爬 → 直接进库(下一讲)。
本讲小结
| 能力 | 一句话要点 |
|---|---|
| CSV 编码 | encoding="utf-8-sig" 加 BOM,否则 Excel 打开中文乱码 |
| CSV 换行 | open(..., newline=""),否则 Windows 上多空行 |
| CSV 类型 | CSV 全是文本,读回来要自己 float();要保类型用 JSON |
| 列表字段 | 提前定死分隔符(` |
| Excel | 交付首选;多 sheet 塞明细 + 统计;注意 sheet 名 ≤ 31 字符、列宽要不要调 |
| 表格安全 | 单元格以 = + - @ 开头会被当公式执行,导出前 _defuse 一遍 |
| JSON / JSONL | JSON 一次性交付;JSONL 边爬边写、可流式、崩了不丢 |
下一讲
文件格式解决的是「怎么交出去 」。但只要你的爬虫会跑第二次,文件格式立刻暴露短板:
用 CSV 追加,跑两遍数据就翻倍;跑一百遍,你有 100 份一样的表,而且没人分得清哪行是最新的。
下一讲上数据库。核心只有一个词------幂等:同一个数据源,抓一次和抓一百次,结果一样。
我们会拆开 SQLite 的三种写法(INSERT / INSERT OR REPLACE / ON CONFLICT DO UPDATE),
看看到底哪个才是最安全的 upsert------其中有一个是「先删后插」的陷阱,用错了会悄悄丢字段。