ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬虫实战:邮票纪念币目录采集与SQLite归档

Python爬虫实战:邮票纪念币目录采集与SQLite归档 做收藏的朋友应该都有这种经历手里攒了一堆邮票和纪念币想查某枚票的志号、发行日期或者现在市场什么价翻纸质目录翻到眼花。我以前也靠一本厚厚的目录加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。如果你之前装过Anacondarequests和lxml通常已经在了先开个终端跑一下import requests, lxml确认即可。没有的话pip install很快。2.2 首次请求与响应头伪装很多初学爬虫的人第一反应是直接requests.get(url)然后遇到403、418甚至空白页就懵了。原因很简单服务器能识别出这不是浏览器行为。所以我的习惯是每次请求都带上完整的请求头尤其是User-Agent和Refererimport 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;q0.9, } url https://stamp-catalog.example.com/list?page1 resp requests.get(url, headersHEADERS, timeout10) 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 idcatalog tbody tr classstamp-row td classnoJ.1/td td classname万国邮政联盟成立一百周年/td td classdate1974-05-15/td td classface8分/td td classissue1000万套/td td classdesigner李印清/td td classref_price150元/td /tr ... /tbody /table注意几个特征每一行的类名有规律、字段在td里、整行都在tbody下。这就是XPath的抓手。3.2 逐字段编写XPath表达式先取所有行rows doc.xpath(//table[idcatalog]/tbody/tr[classstamp-row])再对每一行提取字段records [] for row in rows: record { no: row.xpath(./td[classno]/text()), name: row.xpath(./td[classname]/text()), date: row.xpath(./td[classdate]/text()), face: row.xpath(./td[classface]/text()), issue: row.xpath(./td[classissue]/text()), designer: row.xpath(./td[classdesigner]/text()), ref_price: row.xpath(./td[classref_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 classnamea href/stamp/j1万国邮政联盟成立一百周年/a/td此时td/text()只能取到td的直接文本子节点拿不到a标签里的文字。正确写法是row.xpath(string(./td[classname]))或者row.xpath(./td[classname]//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?page1、/list?page2这种最容易处理。下一页链接型每页底部有“下一页”的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(pagecurrent_page) resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 doc etree.HTML(resp.text) rows doc.xpath(//table[idcatalog]/tbody/tr[classstamp-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, pathprogress.json): with open(path, w, encodingutf-8) as f: json.dump({page: page, records: records}, f, ensure_asciiFalse)下次启动时先读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_asciiFalse), )) 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_typestamp ORDER BY no) rows cur.fetchall() with open(stamp_catalog.csv, w, newline, encodingutf-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_retries3): for attempt in range(max_retries): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: return resp print(f第{attempt1}次请求返回{resp.status_code}重试...) except requests.RequestException as e: print(f第{attempt1}次请求异常: {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[idcatalog]): print(页面结构异常可能被限流) return []一旦发现结构变了立刻停止并调整频率而不是傻傻地继续抓。7.2 编码不一致导致中文乱码某次抓完一批数据发现名称字段全乱码。查了半天原来是列表页是UTF-8但详情页是GBK我统一用了resp.encodingutf-8GBK的页面就被错误解码了。解决办法是写一个自适应编码函数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表达式重新适配一下剩下的框架几乎原样复用。等你跑通一两个完整的采集归档项目再看爬虫这个词你可能就不会只想到“抓数据”三个字而是想到一整套从数据获取到数据治理的流程——这大概就是做这类项目最值得的收获。
返回列表