整理鸟类标本馆藏目录时我接到的第一个任务就让我意识到很多自然史数据本质上是一张巨大的编号表——馆代码、采集批次号、个体序号拼在一起就能唯一定位一件标本。可是馆藏系统一次只给你五十条要凑齐几万条记录就得一页一页翻。这种活计交给手工复制粘贴纯属折磨交给爬虫才正常。这篇文章就是来拆解这件事的Python爬取鸟类标本馆藏目录的分页数据把每一件标本的编号型自然史数据落进SQLite再把索引库建好让后面的查询、去重、导出都不卡壳。文章适合两类人。一类是刚把requests、BeautifulSoup、sqlite3这几个库的基本用法学完、想找个真实项目练手的人另一类是博物馆、高校课题组里需要批量整理标本数字化资料的科研助理——你们被Excel表格折磨得够久了。我不打算写一个只能跑在当前样例上的一次性脚本而是会从头到尾理清楚站点分页规则怎么摸、抓下来的脏数据怎么洗、表结构怎么设计才能支撑按科、按采集人、按年份的检索以及最后怎么把SQLite的索引效果实实在在地体现出来。全文用到的工具很简单Python 3、requests、BeautifulSoup、sqlite3标准库。没有Scrapy、没有Selenium、没有分布式。小体量的标本目录爬虫追求的是快速落地、不折腾环境把SQL标准库吃透就已经能解决绝大部分需求。1. 先搞懂标本馆藏目录的分页逻辑再动手写爬虫很多人一上来就写for i in range(1, 1000)去拼接URL结果要么被站点拒访要么爬到一半发现页码根本对不上。我的习惯是先用浏览器把站点的分页行为摸清楚至少回答三个问题参数是页码还是偏移量、每页上限是多少、翻页时需不需要带额外的状态参数。1.1 抓包前的站点结构预判鸟类标本馆藏目录这类系统多半是博物馆自己部署的内容管理系统或者基于某个开源标本数据库二次开发的前端。常见的分页URL有两类形态一眼就能分辨形态一页码式: https://specimen.example.org/catalog?page3per_page50 形态二偏移量式: https://specimen.example.org/catalog?offset100limit50这两种形态在写爬虫时的处理方式完全不同页码式直接把循环变量放进page参数就行偏移量式的offset指的是跳过的记录数第二页其实就是offset50第三页是offset100。如果开始前没搞清楚第二页和第三页抓重复了都不一定察觉。判断方法也简单。打开浏览器开发者工具的网络面板手动点下一页看地址栏或请求参数的变化。如果变化的是offset就按偏移量计算如果变化的是page就按页码循环。这里我强烈建议你关注请求负载而不是只看地址栏因为有些系统的分页参数藏在POST请求的表单里。1.2 从请求到响应分页参数与翻页机制的实际形态我以一个典型的标本检索页面为例说明。它的请求长这样GET /catalog/search ?taxon_groupAves page2 per_page50 sortcatalog_no几点值得注意taxon_groupAves表示限定鸟类类群这是缩小数据范围的关键参数别漏掉。sortcatalog_no表示按编号排序。对爬虫来说按编号排序比按默认相关度排序可靠得多因为编号不会在翻页过程中突然改变顺序。per_page往往可以调大许多系统允许200甚至500但先别贪心。太大的单页响应容易被服务端限流而且一旦解析出错一整页五十条还是两百条都是丢没区别。我先在浏览器里请求一次把返回的HTML或JSON存成文件用编辑器查看结构。这一步非常值得花时间——它决定了后面解析代码的写法也决定了我能提取出哪些字段。如果目标是JSON接口事情就更简单了直接response.json()就能拿数据。但很多标本馆藏系统前端渲染的是HTML表格必须走解析这一步。我先按HTML表格处理后面会提到JSON接口该怎么适配。2. 爬虫主体用requests把每一页标本记录稳定抓下来把分页机制摸清之后就可以写第一版爬虫了。这一版不求功能全只求把数据从页面里完整、稳定地抠出来。稳定是关键词——标本数据动辄几千页跑到一半断掉是常态所以请求头的伪装、超时设置、重试机制都不能省。2.1 Session、请求频率与容错重试我习惯用requests.Session()来管理HTTP连接而不是直接requests.get()。Session会自动复用底层的TCP连接遇到连续翻页这种场景吞吐量的提升肉眼可见。同时在同一个Session实例上统一设置headers比每请求一次写一遍headers清爽得多。import requests import time from random import uniform session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }) def fetch_page(session, page, per_page50, max_retries5): params { taxon_group: Aves, page: page, per_page: per_page, sort: catalog_no, } for attempt in range(max_retries): try: resp session.get(https://specimen.example.org/catalog/search, paramsparams, timeout30) resp.raise_for_status() return resp.text except (requests.RequestException, ConnectionError) as exc: wait 2 ** attempt uniform(0, 1) print(f[retry] page{page} attempt{attempt 1} wait{wait:.1f}s ({exc})) time.sleep(wait) print(f[failed] page{page} after {max_retries} retries) return None重试等待时间用了指数退避加随机抖动第一次失败等2秒左右第二次等4秒左右第三次等8秒左右。这个设计的目的不是装模作样——真的碰到服务端瞬时抽风连续快速重试往往只会加重被限流的概率隔一会儿再试才是有效的。抓取节奏同样重要。我见过有人不加任何限制地疯狂请求结果把博物馆的检索服务打到响应超时最后连正常访问都受影响。科学标本数据是公共资源爬的时候要有节制。我在翻页循环里加一个随机延时让它保持在每秒0.5到1.5个请求之间for page in range(1, 121): html fetch_page(session, page) if html is None: break parse_and_save(html) time.sleep(uniform(0.5, 1.5))这个速率对绝大多数中小型站点来说都不会造成压力。2.2 解析页面从HTML表格到结构化字段拿到HTML之后如果页面结构是表格用BeautifulSoup加上lxml解析器几行代码就能搞定。难点其实不在解析本身而在字段名的映射。鸟类的馆藏记录常见字段有馆藏编号catalog number、科family、属genus、种加词species、采集人collector、采集日期collect date、采集地点locality。不同系统的表格列名叫法不一样有的叫Catalog #有的叫Specimen ID需要现场看HTML确认。from bs4 import BeautifulSoup def parse_catalog_html(html): soup BeautifulSoup(html, lxml) table soup.select_one(table.catalog-table) if table is None: return [] rows [] for tr in table.select(tbody tr): cells [td.get_text(stripTrue) for td in tr.find_all(td)] if len(cells) 7: continue rows.append({ catalog_no: cells[0], family: cells[1], genus: cells[2], species: cells[3], collector: cells[4], collect_date: cells[5], locality: cells[6], }) return rows之所以用stripTrue是因为HTML表格里经常混着大量的空白字符、换行符不清理干净会直接污染SQLite里的内容。顺手还要处理一种常见情况某件标本缺采集人单元格里只有一个nbsp;解析出来是空字符串。这种情况保留空字符串即可入库时再做判断。还有一点值得提有些系统会在表格里放查看详情的链接而目标字段只有点进详情页才有。如果一开始就发现列表页字段不全那么爬取策略要从爬列表升级成爬列表详情页。这种方案请求量会放大一个数量级务必先抓一个页面评估总耗时再决定要不要这么做。我自己做归档整理时列表页字段够用就绝不多请求详情页。2.3 编号型自然史数据的特征编号本身才是核心编号型自然史数据听起来高大上其实就是这类数据的核心是以编号为主键。一个典型编号长这样UMMZ 234567 AMNH-456789 NHMUK 1898.7.1.23它看起来只是字符串但内部是有结构的前缀是收藏机构代码中间可能是批次号或年份后面是流水顺序号。这直接决定了后面建表时的字段取舍——编号不仅不能拆开存还需要保留它完整的字面形式同时额外提取出前缀和序号两部分用于前缀检索和编号范围查询。我在解析完之后多做了两步清洗import re def normalize_catalog_no(raw): return re.sub(r\s, , raw.strip()) def split_catalog_no(raw): # 匹配最前面的机构代码字母/连字符剩余部分作为序号 m re.match(r^([A-Za-z\-\.\s]?)\s*([0-9].*)$, raw) if m: return m.group(1).strip(), m.group(2).strip() return , raw.strip()这段代码解决的是真实数据里AMNH 456789和AMNH456789两种写法并存的问题。清洗后的prefix字段可以用于统计各机构的馆藏量序号字段则用来判断是否出现跳号。对自然史数据来说跳号本身就说明可能存在漏爬的记录这是后文数据校验的重要依据。3. 编号型数据的SQLite建模为什么不能只存一张大表数据抓下来之后很多人会直接往一张宽表里塞然后发现按采集人查询慢得离谱、按年份统计每次都要全表扫描、想统计各科的数量还得写一大段临时脚本。SQLite本身很轻量但不代表你可以无视表结构设计。索引库的关键在于怎么存决定了怎么查。3.1 先把重复字段拆出去还是留在一张主表对于几万条标本数据我不建议一开始就搞复杂的第三范式拆分。拆成三张表标本主表、采集人表、科表看起来规范但实际查询时要反复JOIN爬虫场景下写入逻辑也变复杂。我采用的方案是一张主表承载全部核心字段把高频查询的字段设为索引列同时把年份单独提取成一个字段。这样做的理由是标本数据的特征决定的每条记录最重要的标识是catalog number它天然唯一采集人、采集年份、科名这些字段重复率很高但它们不是维护的对象——你不太可能修改某个采集人的姓名来影响一批标本记录。没有更新异常问题就不需要费劲拆表。3.2 建表语句与字段取舍我实际使用的建表语句长这样CREATE TABLE IF NOT EXISTS specimens ( id INTEGER PRIMARY KEY AUTOINCREMENT, catalog_no TEXT NOT NULL UNIQUE, catalog_prefix TEXT, catalog_seq TEXT, family TEXT, genus TEXT, species TEXT, collector TEXT, collect_date TEXT, collected_year INTEGER, locality TEXT, created_at TEXT DEFAULT (datetime(now)) );几个字段的设计意图说明一下catalog_no上的UNIQUE约束是整个去重机制的基石。爬虫中断后重新跑重复的记录会因为这个约束被自动挡在外面。catalog_prefix和catalog_seq是清洗编号后拆出的两个子字段专门支撑查某个机构的所有标本和找缺失编号这两类需求。collected_year从采集日期里提取出来虽然冗余但能大幅度提升按年代统计的速度。这是典型的空间换时间思路。索引语句也一并建好CREATE INDEX IF NOT EXISTS idx_specimens_family ON specimens(family); CREATE INDEX IF NOT EXISTS idx_specimens_collector ON specimens(collector); CREATE INDEX IF NOT EXISTS idx_specimens_year ON specimens(collected_year); CREATE INDEX IF NOT EXISTS idx_specimens_prefix ON specimens(catalog_prefix);为什么不是所有字段都建索引因为索引本身要占磁盘空间而且写入时每次都要同步更新索引建得越多入库越慢。建索引只挑高频查询条件和区分度高的字段。family、collector、collected_year、catalog_prefix这四列要么是查询入口要么是聚合统计维度建了稳赚不赔。3.3 数据入库批量写入代替逐条insert爬虫跑起来每分钟会产生几十条甚至上百条解析结果。如果一条一条执行INSERTSQLite的事务提交开销会拖慢整体速度。我选择用executemany批量写入并且把几千条记录包在同一个事务里。import sqlite3 def save_to_sqlite(rows, db_pathbirds.db): conn sqlite3.connect(db_path) conn.execute(BEGIN) sql INSERT OR IGNORE INTO specimens (catalog_no, catalog_prefix, catalog_seq, family, genus, species, collector, collect_date, collected_year, locality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) data [] for r in rows: prefix, seq split_catalog_no(r[catalog_no]) year None date_text r[collect_date] if date_text: m re.search(r(19|20)\d{2}, date_text) if m: year int(m.group(0)) data.append(( r[catalog_no], prefix, seq, r[family], r[genus], r[species], r[collector], r[collect_date], year, r[locality] )) conn.executemany(sql, data) conn.commit() conn.close()这里用INSERT OR IGNORE而不是INSERT配合catalog_no的唯一约束天然实现去重。哪怕上一次爬虫跑到了第80页这次从头开始跑前80页的记录都会被自动忽略只有新的页码才会真正写库。这在断点续爬场景下是救命功能后面详细说。事务的细节也提一嘴先执行BEGIN再批量执行写入最后一次性commit。SQLite默认每个INSERT都是独立事务频繁提交会带来大量磁盘fsync操作慢得受不了。按这种方式实测下来几千条记录入库通常在一秒内完成。4. SQLite索引库实战建立索引之后的查询优化与验证索引建好了怎么证明它真有作用我见过太多人建完索引然后用肉眼感受好像变快了。SQLite给了我们一把尺子叫EXPLAIN QUERY PLAN可以精确看到每次查询是用了索引还是扫了全表。这是索引库实战里最值得掌握的一招。4.1 用EXPLAIN QUERY PLAN验证索引是否生效假设我和前面一样在没有任何索引的情况下建了一张表塞了两万条鸟类标本数据。执行EXPLAIN QUERY PLAN SELECT catalog_no, collector, locality FROM specimens WHERE family Accipitridae;返回结果一般是SCAN specimensSCAN意味着SQLite老老实实地把整张表从头到尾翻了一遍。两万条数据翻起来不痛但如果数据量到了二十万、两百万这个SCAN就是灾难。而建立了idx_specimens_family之后再执行同样的查询返回结果变成SEARCH specimens USING INDEX idx_specimens_family (family?)看到SEARCH ... USING INDEX就对了说明查询走了索引树而不是全表扫描。这个验证方法比任何耗时感觉都可靠强烈建议每次建完索引后都跑一遍确认。4.2 索引效果实测同一查询的前后对比光看执行计划还不够直观我习惯再用实际耗时做个对比。用Python跑同一句聚合查询import sqlite3, time conn sqlite3.connect(birds.db) sql SELECT family, COUNT(*) FROM specimens GROUP BY family ORDER BY COUNT(*) DESC; start time.perf_counter() conn.execute(sql).fetchall() print(fgroup by 查询耗时: {time.perf_counter() - start:.4f}s)在没建索引前两万条记录做GROUP BY family大约需要几毫秒到几十毫秒体感上也是秒开。但一旦查询加上WHERE collected_year BETWEEN 1950 AND 1990再配合family分组性能差异就会放大。更重要的是SQLite在数据量大到一定程度后全表扫描的耗时是指数上升的索引带来的收益也是指数上升的。所以我的原则是就算现在数据量不大也要把索引建好。这不是纸上谈兵——归档项目的数据量往往在不知不觉中涨起来等慢到肉眼可见时再补索引你得重新经历一遍全表扫描的折磨。4.3 常用检索SQL按编号前缀、按分类、按采集年代索引库建好之后前面做的数据准备开始回馈日常查询。我总结了几条最常用的检索SQL都是实际用过的按机构前缀统计馆藏量SELECT catalog_prefix, COUNT(*) FROM specimens GROUP BY catalog_prefix ORDER BY COUNT(*) DESC;查某一科包含的所有属SELECT DISTINCT genus FROM specimens WHERE family Accipitridae ORDER BY genus;查特定时间段采集的标本SELECT catalog_no, collector, locality FROM specimens WHERE collected_year BETWEEN 1960 AND 1970 ORDER BY catalog_no LIMIT 100;查可能断号的区域这里会用到catalog_seqSELECT catalog_prefix, MIN(CAST(catalog_seq AS INTEGER)), MAX(CAST(catalog_seq AS INTEGER)) FROM specimens WHERE catalog_prefix USNM AND catalog_seq GLOB [0-9]* GROUP BY catalog_prefix;最后这条是自然史数据特有的需求。标本馆的记录按照编号连续分布但实际馆藏中可能缺了几件可能丢失、借展、或者当初就没编入电子系统。通过最大最小值一比对就能知道哪些编号区间需要人工核实。这种查询在一张扁平宽表里几乎不可能高效完成正是因为拆出了catalog_prefix和catalog_seq才让索引库有了用武之地。5. 爬到一半断了怎么办断点续爬与数据校验爬虫全流程跑通之后真正的考验才刚刚开始。长任务跑到一半要么网络波动要么服务端突然返回了错误页面要么笔记本合盖休眠导致脚本中断。做好断点续爬和数据校验才能保证最终落库的不是一堆残缺数据。5.1 断点记录表让爬虫知道上次停在哪最简单的断点策略是记录已成功解析的最大页码下次启动时从这个页码继续。我在SQLite里建了一张专门的meta表来存这类信息CREATE TABLE IF NOT EXISTS crawl_meta ( key TEXT PRIMARY KEY, value TEXT );爬虫每成功处理完一页就更新一次def mark_page_done(conn, page): conn.execute( INSERT OR REPLACE INTO crawl_meta (key, value) VALUES (last_page, ?), (str(page),) ) conn.commit()启动时先读这个值再从后面一页继续def get_last_page(conn): row conn.execute( SELECT value FROM crawl_meta WHERE key last_page ).fetchone() return int(row[0]) if row else 0 last_page get_last_page(conn) for page in range(last_page 1, total_pages 1): html fetch_page(session, page) if html is None: time.sleep(5) continue rows parse_catalog_html(html) save_to_sqlite(rows) mark_page_done(conn, page)这里有个细节即使当前页面解析失败也不要退出循环而是sleep几秒后重试。只有连续多次失败才考虑人工介入。实践证明很多失败只是瞬时网络抖动过几秒就好了。页级断点和记录级去重配合使用效果最好页级断点防止大量重复请求浪费流量记录级去重防止同一纪录二次入库。两者各自解决不同层面的问题缺一不可。5.2 编号重复与编号缺失的交叉检查前面提到catalog_no的UNIQUE约束会自动挡掉重复记录但那只是数据库层面的兜底。数据质量审核还有两道关卡第一道编号格式校验。鸟类标本编号通常应该符合机构代码序号的模式如果出现空号、纯数字、或者明显截断的编号就需要标记出来人工检查SELECT catalog_no FROM specimens WHERE catalog_no NOT GLOB *[0-9]*;第二道编号连续性抽查。选一个馆藏量最大的机构前缀把该前缀下的catalog_seq转成数字后排序再和相邻编号做差找出缺失区间import sqlite3 conn sqlite3.connect(birds.db) rows conn.execute( SELECT catalog_seq FROM specimens WHERE catalog_prefix USNM AND catalog_seq GLOB [0-9]* ORDER BY CAST(catalog_seq AS INTEGER) ).fetchall() nums [int(r[0]) for r in rows] missing [] for i in range(1, len(nums)): if nums[i] - nums[i-1] 1: missing.append((nums[i-1] 1, nums[i] - 1)) for lo, hi in missing[:20]: print(fmissing range: {lo} - {hi})这个检查跑完之后我通常会列出缺失区间再回去手动抽样验证。有些跳号本身可能是正常的比如某些编号被预留但从未使用但绝大多数情况下跳号就是漏爬的信号。5.3 随机抽样核对验证解析过程没有错位数据校验不能只依赖自动化最终还是要靠人眼抽样。我的习惯是从库里随机抽二三十条记录返回到线上页面逐条比对字段是否错位。最常见的问题是表格列顺序调整导致字段映射错位比如把采集人存到了科名的位置。随机抽样的SQLSELECT catalog_no, family, genus, species, collector, collect_date, locality FROM specimens ORDER BY RANDOM() LIMIT 20;把抽样结果和线上页面一比所有字段都能对上基本可以放心大批量使用了。如果发现错位第一时间要检查的是解析函数里的cells索引是不是还和页面列顺序一致——很多系统的表格在后端升级后会悄悄增加或调整列解析代码不会自动适应。6. 让索引库更好用FTS5全文检索与CSV导出主体流程跑完SQLite里的标本数据已经可以支撑常规查询了。这个阶段再补两个实用功能一个是模糊搜索能力一个是导出清洗后的数据方便分发给不写代码的同事。6.1 FTS5虚拟表让编号、采集人、地点都支持快速模糊搜索SQLite自带的FTS5扩展模块提供了全文检索能力对标本数据尤其好用的场景是只记得采集地的一个地名片段想找出所有在云南省采集的标本或者只记得编号的后几位想反查对应记录。普通LIKE %xxx%查询在数据量上来后会变得很慢而FTS5的倒排索引机制就是为这种场景设计的。建表语句CREATE VIRTUAL TABLE IF NOT EXISTS specimens_fts USING fts5( catalog_no, family, genus, species, collector, locality, contentspecimens, content_rowidid );content参数指定它同步自specimens主表content_rowid指向主表的id列。这样建虚拟表后插入主表的数据不会自动进入FTS索引需要通过触发器或者在入库时手动同步。我选择了触发器方案这样后续无论用什么方式更新主表FTS索引都能保持一致CREATE TRIGGER IF NOT EXISTS specimens_ai AFTER INSERT ON specimens BEGIN INSERT INTO specimens_fts(rowid, catalog_no, family, genus, species, collector, locality) VALUES (new.id, new.catalog_no, new.family, new.genus, new.species, new.collector, new.locality); END;搜索时用MATCH语法比如找编号前缀里包含USNM且有云南采集地记录SELECT catalog_no, family, collector, locality FROM specimens_fts WHERE specimens_fts MATCH USNM AND 云南 LIMIT 50;FTS5的匹配默认按英文分词如果做中文搜索需要额外引入分词器或采用三字词组合处理这里不展开。但光是按编号前缀和采集人做模糊匹配就已经能解决日常绝大多数检索需求了。6.2 导出CSV把索引库数据交给不会写SQL的人最后一步把数据库导出成CSV发给研究组里不碰SQL的同事。这一步看似简单但要注意编码问题——用Excel打开的时候UTF-8的BOM头是不可或缺的。Python的csv模块配合utf-8-sig编码就能解决import csv conn sqlite3.connect(birds.db) rows conn.execute( SELECT catalog_no, family, genus, species, collector, collect_date, locality FROM specimens ORDER BY catalog_no ).fetchall() with open(birds_catalog.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([catalog_no, family, genus, species, collector, collect_date, locality]) writer.writerows(rows)如果你后续打算把数据导入地理信息系统做物种分布可视化CSV格式也同样适用只需要补上经纬度字段即可。标本馆藏数据经常包含精确的采集地点这类数据接进地图工具后价值会上升一个档次。关于FTS5还有一点补充如果数据量已经爬完了也可以用INSERT INTO specimens_fts(specimens_fts) VALUES(rebuild)命令直接重建全部索引效果一样而且比逐条插入快。爬完这批数据后我又做了几轮迭代把采集地和采集日期完全解析到独立字段增加了一跳去重的校验逻辑最终SQLite库里存了数万条鸟类标本记录。整个过程最深的感触是爬虫代码本身只占一半工作量另一半是对数据的理解。编号型自然史数据的核心在于编号背后的一整套逻辑——机构标识、序列规则、潜在跳号、与采集事件的关系这些都需要在表结构设计阶段就想清楚。SQLite作为工具虽然轻量但配合上合理的索引和FTS5检索支撑一个中型标本馆的日常查询绰绰有余。