做收藏的朋友应该都有这种经历:手里攒了一堆邮票和纪念币,想查某枚票的志号、发行日期或者现在市场什么价,翻纸质目录翻到眼花。我以前也靠一本厚厚的目录加Excel手工录入,录了不到一百条就放弃——条目太碎了,一个条目要对抄七八个字段,手一抖就抄串行。后来换了思路,既然目录网站上的数据是现成的,干脆用Python爬虫把邮票和纪念币目录一次性采集下来,落成自己的本地档案库。这篇就把整个工程拆开讲:怎么定字段、怎么选目标站点、requests加XPath怎么把网页表格变成结构化数据、翻页全量抓取怎么做、最后怎么归档成SQLite库并导出Excel。项目不算难,跑通这条链路后,你会发现不只是邮票,任何表格型目录都能用同一套方法收拾得明明白白。
1. 从一本纸质目录到本地数据库:先想清楚你要采什么
很多人写爬虫上来就写requests.get,这是最容易翻车的地方。采集类工程和普通接口调用不一样,你得先回答一个问题:归档这件事,到底需要哪些字段?字段没想清楚,后面解析、清洗、去重都是白做。
1.1 集邮与纪念币归档的核心字段设计
拿邮票目录来说,一枚票在集邮者眼里不是“一张好看的图”,而是一组严格的结构化信息。我最终落库的字段列表是这样的:
| 字段 | 示例 | 说明 |
|---|---|---|
| 志号 | J.1 | 邮局全张/套票的唯一编号,天然主键 |
| 名称 | 万国邮政联盟成立一百周年 | 套票或单枚票名称 |
| 发行日期 | 1974-05-15 | 统一成ISO格式 |
| 全套枚数 | 3 | 同一志号下的枚数 |
| 面值 | 8分×3 | 单枚面值,保留原文本再加数值列 |
| 发行量 | 1000万套 | 部分票品公布,注意单位 |
| 设计者 | 李印清 | 邮票设计署名 |
| 版别 | 影写版 | 印刷工艺 |
| 齿孔 | P11×11.5 | 齿孔度数 |
| 参考价 | 150元 | 目录价、市场价、成交价可能不同 |
纪念币的字段类似,但主键不能照搬志号概念,一般用“发行年份+主题+面额”组合,比如2023-京剧艺术(生)-5元。此外纪念币还多几个属性:材质(黄铜合金、双色铜合金)、直径、成色、普制/精制。我的建议是建表时留一个item_type字段,stamp或coin,两套数据放同一张表也能查,主键再加个类型前缀就行。
1.2 选源思路:什么样的页面适合做爬虫对象
字段定了,再看目标站点。不是所有目录网站都适合采集,我的筛选标准有三条:
- 公开可见,不需要登录就能浏览全部列表页和详情页。
- 结构规整,列表页是表格或重复的卡片结构,每一行/块的字段位置一致。
- 有明确的分页,或者有“上一页/下一页”链接,方便程序逐页遍历。
在我的采集工程里,目标页面结构是表格型:一个<table>,每一行是一套票,志号、名称、日期、面值这些分列展示。这种页面是XPath最舒服的猎物。顺带说一句,如果你选中的站点是动态渲染的(数据藏在JavaScript里,requests拿到的是空壳),可以先找找有没有对应的接口,或者考虑Selenium/Playwright,那条路复杂度会上一个台阶,第一篇工程尽量避开。
2. 开发环境装配:requests配合xpath的最小可用骨架
这一节写给刚摸Python的读者,老手可以跳过依赖部分直接看请求头伪装。整个采集链路用到的库其实非常少,凡是能用标准库和两个第三方库解决的,就别引入重型框架。
2.1 Python环境与依赖安装
我用的Python 3.10+,实测3.8以上都没问题。需要装的就两个库:
pip install requests lxmlrequests负责发HTTP请求、拿HTML文本。lxml负责用XPath解析HTML。
如果你之前装过Anaconda,requests和lxml通常已经在了,先开个终端跑一下import requests, lxml确认即可。没有的话pip install很快。
2.2 首次请求与响应头伪装
很多初学爬虫的人第一反应是直接requests.get(url),然后遇到403、418甚至空白页就懵了。原因很简单:服务器能识别出这不是浏览器行为。所以我的习惯是每次请求都带上完整的请求头,尤其是User-Agent和Referer:
import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://stamp-catalog.example.com/", "Accept-Language": "zh-CN,zh;q=0.9", } url = "https://stamp-catalog.example.com/list?page=1" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" html = resp.text print(resp.status_code, len(html))这里的resp.encoding值得多说一句:有些老牌目录站还是GBK编码,不显式指定的话requests可能用错误的方式解码,导致中文乱码。稳妥做法是先看响应头里的charset,没有就试resp.apparent_encoding,我一般直接显式指定并打印验证。
请求头的意义在于告诉对方“我是普通浏览器用户”,而不是伪装攻击。这一行伪装的背后逻辑是身份可信度,后续遇到防采集,你先检查UA和请求频率,十有八九能解决一大半问题。
2.3 解析器初始化
拿到HTML文本后,用lxml解析成可查询的节点树:
from lxml import etree doc = etree.HTML(html)之后所有字段提取都围绕doc.xpath(...)展开。这一步等于给HTML文本装上了“选区工具”,XPath表达式就是选区规则。第一次接触XPath的人别怕,它和文件路径很像,//表示全文档找,/表示子节点,@表示取属性,就这么点核心概念。
3. XPath提取字段:把HTML表格变成结构化记录
到了最核心的部分。这一步干的事是:对着浏览器开发者工具,找到每个字段的准确位置,写XPath表达式把它抠出来。我通常的做法是先看一个页面,把这个页面上所有需要的字段都解析正确,再写循环——单页验证是提速的关键。
3.1 观察页面结构
按F12打开开发者工具,选中表格里第一行,你会看到类似这样的结构(不同站点结构不同,这里只是一个通用示意):
<table id="catalog"> <tbody> <tr class="stamp-row"> <td class="no">J.1</td> <td class="name">万国邮政联盟成立一百周年</td> <td class="date">1974-05-15</td> <td class="face">8分</td> <td class="issue">1000万套</td> <td class="designer">李印清</td> <td class="ref_price">150元</td> </tr> ... </tbody> </table>注意几个特征:每一行的类名有规律、字段在<td>里、整行都在<tbody>下。这就是XPath的抓手。
3.2 逐字段编写XPath表达式
先取所有行:
rows = doc.xpath('//table[@id="catalog"]/tbody/tr[@class="stamp-row"]')再对每一行提取字段:
records = [] for row in rows: record = { "no": row.xpath('./td[@class="no"]/text()'), "name": row.xpath('./td[@class="name"]/text()'), "date": row.xpath('./td[@class="date"]/text()'), "face": row.xpath('./td[@class="face"]/text()'), "issue": row.xpath('./td[@class="issue"]/text()'), "designer": row.xpath('./td[@class="designer"]/text()'), "ref_price": row.xpath('./td[@class="ref_price"]/text()'), } records.append(record)row.xpath(...)返回的是列表,哪怕只有一个值也是列表。不要直接把它当字符串用,习惯性取第一个或处理空列表,后续步骤会省很多事。我一般写一个小函数:
def clean_text(items): if not items: return "" text = items[0].strip() return text然后把每个字段套上clean_text(...)。
这里有一个很容易翻车的点:一个<td>里除了纯文本,可能还嵌套了<span>、<a>。比如名称字段可能长这样:
<td class="name"><a href="/stamp/j1">万国邮政联盟成立一百周年</a></td>此时td/text()只能取到td的直接文本子节点,拿不到a标签里的文字。正确写法是:
row.xpath('string(./td[@class="name"])')或者:
row.xpath('./td[@class="name"]//text()')后者能抓到所有后代文本节点,再用"".join(...)拼接。我的经验是:能抓就直接抓,抓不到就用string()兜底,不要死磕单一路径。XPath的string()函数会把当前节点内所有文本拼在一起,虽然遇上多段落会丢空格,但对付表格绰绰有余。
3.3 校验与清洗原始文本
解析完一个页面后,别急着开循环。先打印前三条记录检查:
for r in records[:3]: print(r)这个阶段必须看两个东西:一是字段有没有错位,二是文本里有没有杂质。杂质最常见的几种:
- 中文括号、英文括号混用,例如
(3-1)和(3-1); - 面值字段带单位
分/元/角,同一字段里有的写8分有的写1.20元; - 价格字段带“约”“参考”字样,比如
约150元; - 空字段返回
['\n '],全是空白字符。
处理这些杂质的思路是:先保存原始文本,同时再生成一个清洗后的字段。不要想着把原始文本改得面目全非——归档讲究可追溯,原始值丢了以后出了问题没法核对。清洗函数示例:
def clean_face(raw): return raw.replace(" ", "").replace("(", "(").replace(")", ")")清洗规则因站点而异,但套路一致:去空白、统一括号、去掉多余前缀、保留数值和单位。
4. 翻页循环与全量抓取:从单页到整库的工程化处理
单页解析跑通后,整个工程的难点就变成两个:怎么翻页和怎么让程序在遇到意外时不至于白跑一遍。
4.1 分页机制确认
先确认目标站点的分页是哪种类型。常见两种:
- URL参数型:
/list?page=1、/list?page=2,这种最容易处理。 - 下一页链接型:每页底部有“下一页”的
<a>,需要每次从页面里提取href。
第二类有个隐藏坑:有的站点最后一页没有“下一页”按钮;有的站点“下一页”永远是href="#",要靠JavaScript跳转。我的做法是先看href内容再决定策略。如果只有参数型,直接循环页码即可,配合一个终止条件。
4.2 while循环抓取与断点续采
我推荐的抓取框架是while循环加最大页数保护,而不是写死的for i in range(1, 101)。因为目录站的数量可能临时调整,写死页码容易多抓空页或者漏抓新增页:
import time import random PAGE_URL = "https://stamp-catalog.example.com/list?page={page}" MAX_PAGE = 500 START_PAGE = 1 all_records = [] current_page = START_PAGE while current_page <= MAX_PAGE: url = PAGE_URL.format(page=current_page) resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" doc = etree.HTML(resp.text) rows = doc.xpath('//table[@id="catalog"]/tbody/tr[@class="stamp-row"]') if not rows: print(f"第{current_page}页无数据,停止") break for row in rows: record = extract_record(row) # 复用第3节的逐字段提取逻辑 all_records.append(record) print(f"第{current_page}页完成,累计{len(all_records)}条") current_page += 1 time.sleep(random.uniform(1.5, 3.5))注意time.sleep不是可有可无的。它的作用是把请求频率控制在对目标站点友好的范围,既保护自己IP不被封,也避免给对方服务器造成压力。很多反爬封禁根本不是因为你做错了什么,单纯就是请求太密集。随机间隔比固定间隔更自然,所以我用random.uniform。
断点续采是另一个“看似简单但决定成败”的细节。如果程序跑到第87页突然网络抖动崩了,从头再来要浪费好几十分钟。我的方案是每抓完一页,把current_page和已抓的all_records增量写入本地文件:
import json def save_progress(page, records, path="progress.json"): with open(path, "w", encoding="utf-8") as f: json.dump({"page": page, "records": records}, f, ensure_ascii=False)下次启动时先读progress.json,从断点页继续。数据量不大时直接存JSON,几百页也就几万条,完全够用;数据量再大再换数据库写入不迟。
4.3 日志记录
采集程序跑起来少则几分钟多则半小时,如果你不打印进度,中途出问题只能干瞪眼。我习惯在每次请求后输出一行摘要:
print(f"[{time.strftime('%H:%M:%S')}] page={current_page}, rows={len(rows)}, total={len(all_records)}")别小看这几行日志,它能在出问题时让你快速定位是第几页、当时请求了什么URL。后续排查时这张“时间线”价值极大。
5. 归档与查询:SQLite本地库、JSON导出和Excel报表
采集完的原始记录还只是“数据”,归档意味着要让它能查、能用、能长期保存。我的选型很朴素:SQLite做存储,JSON做原始备份,Excel做阅读和分享。
5.1 SQLite建表
SQLite是Python自带的标准库,不需要额外安装。建表时我会刻意把主键设成志号或组合主键,这样重复抓取时可以直接跳过。表结构示例:
import sqlite3 conn = sqlite3.connect("collection.db") cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS catalog ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_type TEXT NOT NULL, no TEXT UNIQUE, name TEXT, issue_date TEXT, face_value TEXT, face_value_num REAL, issue_quantity TEXT, designer TEXT, print_method TEXT, perforation TEXT, ref_price TEXT, raw_json TEXT, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) conn.commit()字段里故意留了raw_json,存解析完的原始记录序列化结果。好处是以后想补字段,可以直接从原始数据里再解析,不用重新跑网络。这是归档工程的一个小经验:网络数据来之不易,原始样本要留底。
5.2 数据写入
写入时用INSERT OR IGNORE配合唯一键,重复跑不会产生脏数据:
import json def insert_records(records): for r in records: try: cur.execute(""" INSERT OR IGNORE INTO catalog (item_type, no, name, issue_date, face_value, face_value_num, issue_quantity, designer, print_method, perforation, ref_price, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( r.get("item_type", "stamp"), r.get("no"), r.get("name"), r.get("date"), r.get("face"), parse_face_to_num(r.get("face")), r.get("issue"), r.get("designer"), r.get("print_method"), r.get("perforation"), r.get("ref_price"), json.dumps(r, ensure_ascii=False), )) except Exception as e: print("写入失败:", r.get("no"), e) conn.commit()parse_face_to_num负责把“8分”转成0.08,“1.20元”转成1.2,方便以后做价格统计。解析逻辑不复杂,按“分/元/角”切分单位再换算即可。这一步顺带实现了数据从“人看的文本”到“机器算的数”的转换。
5.3 导出Excel
用Excel看目录这种需求,用pandas最顺手,但如果你不喜欢装重量级库,标准库的csv写一个Excel也能打开的CSV也行:
import csv def export_csv(): cur.execute("SELECT item_type, no, name, issue_date, face_value, issue_quantity, ref_price FROM catalog WHERE item_type='stamp' ORDER BY no") rows = cur.fetchall() with open("stamp_catalog.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["类型", "志号", "名称", "发行日期", "面值", "发行量", "参考价"]) writer.writerows(rows)utf-8-sig这个编码是给Excel看的——加了BOM头,Excel双击打开CSV不会乱码。这是很多新手踩过坑的地方,直接写utf-8会在Excel里显示一堆乱码,搞半天还以为是数据坏了。
我归档时同时保留三个副本:SQLite库(程序查询用)、JSON(原始备份)、CSV/Excel(日常翻阅用)。三者各司其职,单点坏了不心疼。
6. 防采集与合规边界:优雅爬虫的自我修养
做完一个完整的采集工程,你能接触到的不仅是爬虫技术,还有“反爬”这回事。踩过几次坑之后,我把自己的原则总结成四句话:频率控制是礼貌,随机UA是伪装,异常重试是稳健,合规边界是底线。
6.1 频率控制
前面已经出现过的time.sleep(random.uniform(1.5, 3.5))就是频率控制的核心。你可能觉得慢,但目录归档不是实时行情接口,一天抓完和十分钟抓完没有任何区别。宁可慢一点,不要让自己的IP被对方拉黑。有些站点对同一IP的单秒请求数有硬限制,超了直接让你看到一个验证页面,到时候除了等冷却什么都做不了。
6.2 随机User-Agent
每次请求都用同一个UA也能跑,但更容易被识别为脚本。维护一个UA池,随机取一个:
import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/119.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) Firefox/119.0", ] HEADERS = { "User-Agent": random.choice(UA_POOL), "Referer": "https://stamp-catalog.example.com/", }UA池不需要多大,三五条就够。换UA的深层意义是让请求特征不那么“恒定”——恒定的UA加上恒定的间隔,反而显得规律性太强,容易被识别为程序。
6.3 异常重试
网络请求没有100%成功。超时、连接重置、偶尔的5xx,都是家常便饭。我的习惯是写一个带重试的小函数:
def fetch_with_retry(url, headers, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp print(f"第{attempt+1}次请求返回{resp.status_code},重试...") except requests.RequestException as e: print(f"第{attempt+1}次请求异常: {e}") time.sleep(2 * (attempt + 1)) return None注意重试间隔是递增的(2秒、4秒、6秒),这叫退避策略,避免在服务器还没缓过来时连续轰炸。重试三次还不行就跳过这一页,记录到一个失败列表里,等主流程跑完单独补采。
6.4 合规边界
这部分我想认真说,因为网上很多教程只教技术、不教边界,新手容易踩线。我的底线很简单:
- 只采集公开可访问的页面,不做绕过登录和验证码的尝试。
- 采集前看目标站点的
robots.txt和用户协议,对方明确禁止的就不要硬采。 - 采集行为以个人学习、归档整理为界限,不用于商业牟利。
- 控制请求频率,不影响目标站点正常服务。
有人可能会说,这样是不是太保守了?我的看法是:爬虫本身就是游走在开放数据与网站版权之间的技术,技术本身中立,但使用者的选择决定后果。对于邮票和纪念币目录这类公开信息,采集来做个人收藏整理是正当用途,但如果你把它包装成商业数据产品去卖,那性质就完全不同了。做技术和做人一样,都要留点余地。尤其是中国法律环境对数据保护越来越严格的情况下,“有依据的采集”比“能跑就行的爬虫”更能走得远。
如果你发现目标站点有明确的防采集措施,与其钻研绕过方案,不如换个思路:寻找官方发布的公开文件、正规出版物,或者联系站方谈数据合作。对于个人归档来说,缺少一部分冷门票的细节远不如“有一个干净可持续的数据源”重要。
7. 实测中踩过的坑与Debug思路
这一节写的是我在这个项目里真实遇到过的问题。比起照步骤做,知道“出问题时怎么想”更重要。
7.1 HTTP 200不等于抓到了正确内容
我的程序跑着跑着突然“抓不到”字段了,日志里status_code一直是200。后来打印HTML才发现,页面返回的是一段提示文案,大意是“访问频率过高,请稍后再试”。也就是说,服务器用200代替了429,它不想暴露自己的反爬策略。这给我的教训是:不能只判断状态码,还要判断页面里有没有预期的关键节点。现在我的解析函数第一行就检查:
if not doc.xpath('//table[@id="catalog"]'): print("页面结构异常,可能被限流") return []一旦发现结构变了,立刻停止并调整频率,而不是傻傻地继续抓。
7.2 编码不一致导致中文乱码
某次抓完一批数据,发现名称字段全乱码。查了半天,原来是列表页是UTF-8,但详情页是GBK,我统一用了resp.encoding="utf-8",GBK的页面就被错误解码了。解决办法是写一个自适应编码函数:
def decode_response(resp): resp.encoding = resp.apparent_encoding or "utf-8" return resp.textapparent_encoding会根据内容自动判断实际编码,虽然不是100%准,但对付中英文混合页面足够了。
7.3 日期和价格数据最脏
日期字段的原始值千奇百怪:1974.5.15、1974-05-15、1974年5月15日、1974/5/15。统一格式必须在清洗阶段做,否则Excel排序和SQLite查询都会出问题。我写了个简单的日期清洗函数:
import re def normalize_date(raw): if not raw: return "" raw = raw.strip() match = re.search(r"(\d{4})[年./-](\d{1,2})[月./-](\d{1,2})", raw) if match: return f"{match.group(1)}-{int(match.group(2)):02d}-{int(match.group(3)):02d}" return raw价格字段更麻烦,150元、约150元、150、¥150、150-200元都有。这种字段不要试图清洗成统一格式,保留原文本,同时额外提取一个数值范围列,比如[150.0, 200.0],以后做价格分析时再按范围处理。
项目做到这里,我的感觉是:爬虫入门不难,难的是把采集当作一个完整工程来对待。字段设计、选源判断、请求伪装、解析校验、断点续采、存储归档、异常排查,每一环都值得单独用一次实际操作去验证。我个人的习惯是先拿你最熟的一套邮票做单页测试,确认字段全对、主键唯一、日期干净,再放出去跑全量。这样即使后面有意外,出了问题也多半能一眼看出在哪一步。
这套思路不只是邮票纪念币能用。你换一个表格型的图书目录、老照片档案、资料集,把字段对应关系改一改,XPath表达式重新适配一下,剩下的框架几乎原样复用。等你跑通一两个完整的采集归档项目,再看爬虫这个词,你可能就不会只想到“抓数据”三个字,而是想到一整套从数据获取到数据治理的流程——这大概就是做这类项目最值得的收获。