去年帮朋友整理一批旧地方志扫描件的卷册目录,第一个让我头疼的不是 OCR 识别文字,而是那些藏在网页侧边栏里的目录树。点开一个卷册,下面还有章、节、目,少的三四层,多的五六层,一层套一层。手动一个个复制粘贴显然不现实,于是我用 Python 爬虫把这类“卷册目录页”完整抓了下来,再整理成树结构,最终落进 SQLite 数据库。今天这篇就把整个过程拆开讲一遍,从页面分析、请求解析、树结构建模,到最后写入 SQLite,全程有代码、有踩坑记录,适合刚入门 Python 爬虫、也适合想系统梳理“树形数据如何落库”的朋友参考。
1. 项目分析与整体设计
1.1 目录页到底长什么样
地方志目录页和普通新闻列表不太一样。新闻列表大多是扁平结构,一个 <ul> 下面全是 <li>,抓下来排个序就完事。而地方志的卷册目录是典型的嵌套结构,HTML 里经常能看到这种写法:
html复制<div class="catalog" id="bookCatalog">
<ul>
<li data-level="1">
<span class="item">卷一 疆域志</span>
<ul>
<li data-level="2"><span class="item">沿革</span></li>
<li data-level="2">
<span class="item">四至</span>
<ul>
<li data-level="3"><span class="item">东至</span></li>
<li data-level="3"><span class="item">西至</span></li>
</ul>
</li>
</ul>
</li>
<li data-level="1">
<span class="item">卷二 建置志</span>
<ul>
<li data-level="2"><span class="item">城池</span></li>
<li data-level="2"><span class="item">廨署</span></li>
</ul>
</li>
</ul>
</div>
有些站点的结构可能不是 span.item,而是 <a>、<p>、<div>,但核心规律是一致的:出现 <ul> 就往下一层,出现 <li> 就是同层的节点。你只要把这条规律抓住,不管标签换成什么都好办。
我在动手写代码之前,习惯先开一个文本编辑器把 HTML 缩进捋一遍,或者直接在浏览器开发者工具里的 Elements 面板看结构。这一步不是浪费时间,而是避免把“目录名”和“子目录容器”搞混,尤其遇到那些嵌套层级深、又混着链接和图标的情况,先看结构再写解析,能少踩一半的坑。
1.2 为什么是“树结构 + SQLite”
目录天然就是一棵树。根节点是整本书,往下是卷,再往下是章、节、目。如果不把它当树处理,而是当作普通表格一行行拍平,后续很难复原“谁的爸爸是谁”。所以我一开始就决定在 Python 里维护一份树结构,每个节点记录自己的 parent_id,这样既能按层级展示,也能随时生成完整的路径字符串,比如 “卷一 疆域志 / 四至 / 东至”。
选 SQLite 的原因也很简单:这是个单人维护的小工具型项目,没必要搭 MySQL 或者 PostgreSQL。SQLite 本身就是单文件数据库,一个 .db 文件拷走就能用,配合 Python 标准库里的 sqlite3,零额外安装成本。对于这种“一天抓一次目录,查出来做成清单”的场景,SQLite 的读写性能完全够用,而且不用担心数据库进程没启动的问题。
有人可能会问,为什么不直接把 JSON 存文件?我在项目里不是没用过 JSON,但 JSON 文件一旦大了,查某个节点要看半天,更新某一层还要整份重写。SQLite 可以做到按 ID 更新、按父子关系查询、按书名过滤,后续要对接网页展示或者 Excel 导出都很方便。这就是标题里强调“SQLite 落库”的价值,落地到一个能检索的存储,而不是把数据停在内存里。
1.3 技术栈怎么选
这个项目的技术选型我做过一次小对比,最终核心组合是:
| 环节 | 可选方案 | 我的选择 | 理由 |
|---|---|---|---|
| HTTP 请求 | requests / httpx / scrapy | requests | 上手快,同步写起来直观,适合中小规模采集 |
| HTML 解析 | BeautifulSoup + lxml / pyquery | BeautifulSoup + lxml | 容错好,选择器写起来方便 |
| 数据存储 | sqlite3 / 文件 JSON / MySQL | sqlite3 | 标准库自带,树查询用递归 CTE 也能撑住 |
| 环境 | venv + pip | venv + pip | 隔离依赖,避免污染系统 Python |
如果你后续想把采集规模做大,可以考虑换 Scrapy,它有调度器、下载中间件和 Item Pipeline,非常成熟。但就“抓一个地方志目录页”这个体量来说,杀鸡用牛刀,requests + BeautifulSoup + sqlite3 已经足够。而且这套组合每一步都能单独调试,出错时定位问题特别快。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 请求与页面解析的完整套路
2.1 先判断数据是静态 HTML 还是异步接口
很多新手上来就写 requests.get(url),拿回来一解析发现目录是空的,就开始怀疑人生。其实先要做一件小事:在浏览器打开目标页面,按 F12 切到 Network 面板,刷新页面后找到第一个文档请求,看它的 Response 里到底有没有目录数据。
如果 Response 里直接能看到“卷一 疆域志”这样的字眼,说明目录是服务端渲染的静态 HTML,直接请求解析就行。如果 Response 只有一段空壳 <div id="app">,那数据大概率是页面加载后通过异步接口拿到的。这时候再去 Network 面板里过滤 XHR/Fetch,找到返回目录数据的接口,直接请求这个接口,往往比等页面渲染更高效。
我这个项目里遇到的是静态 HTML,所以下面的代码都以静态解析为例。异步接口的情况其实也不复杂,本质是把解析对象从 HTML 改成 JSON,树结构反而更清晰,后面讲递归思路时完全可以复用。
2.2 请求阶段最容易翻车的三个点
请求阶段最容易翻车的不是代码逻辑,而是细节。第一是响应编码,地方志网站很多还是老系统,响应的 charset 标得不清楚,默认解码可能乱码。我的处理方式是拿到 response 后强制指定编码:
python复制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",
"Accept-Language": "zh-CN,zh;q=0.9",
}
resp = requests.get("https://example.com/catalog.html", headers=headers, timeout=15)
resp.encoding = resp.apparent_encoding
html = resp.text
apparent_encoding 是根据响应内容里的字节特征推断出来的编码,比简单地看响应头更可靠。不过它需要读取一部分内容做检测,所以会有微小的性能损耗,但这个场景完全可以接受。
第二是超时设置。目录页如果是老服务器,偶尔会卡住,不加 timeout 的话程序可能挂在那等很久。设置 15 秒超时,配合后文的“重试机制”,能避免单页卡死导致整个采集流程中断。
第三是请求头。有的站会校验 User-Agent,默认的 python-requests 很容易被识别。带上一个正常的浏览器 User-Agent,再补一个 Accept-Language,大多数老站点都不会再刁难你。真正有风控的站点另说,那不是这个项目讨论的范围。
2.3 解析节点:xpath 还是 css selector
拿到 HTML 之后,我习惯先用 BeautifulSoup 转成对象,再用 CSS Selector 定位根节点。为什么不用 xpath?当然也可以用 lxml 直接 xpath(),但 BeautifulSoup 对不规范的 HTML 容错更好,而且 .select() 写起来更短,读代码的人一眼能懂。
python复制from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
root_ul = soup.select_one("div.catalog ul")
if root_ul is None:
raise ValueError("未找到目录根节点,请检查页面结构")
这里强烈建议在解析之前先做一次“空值保护”。因为我踩过太多次坑:页面上线后某个 class 改名,select_one 返回 None,结果 .find_all 直接报错,整个程序崩在一堆日志里。先判断 root_ul 是否存在,不存在就明确抛错,定位问题会快很多。
解析单个节点时,优先取标题标签里的文本,并把首尾空白、换行符都清理掉:
python复制name_node = li.select_one("span.item") or li
name = name_node.get_text(strip=True)
这里用 or li 做兜底,万一某个节点不是标准的 span.item 结构,至少能拿到该 <li> 里的文字,不会因为字段缺失把整条目录丢掉。
3. 树结构解析:递归、栈和路径还原
3.1 递归函数是核心
树结构最直观的处理方式就是递归。因为树的定义本身就是“根节点 + 子树”,用递归函数来处理几乎是天作之合。我当时的实现思路是:给定一个 <li> 节点,先把它自己存进结果列表,再找它内部的 <ul>,如果有,就进入这些子 <li> 继续处理。
下面这段是我在项目里用的简化版递归逻辑:
python复制def parse_tree(parent_li, parent_id, parent_path, temp_id_counter, level):
for order, li in enumerate(parent_li.find_all("li", recursive=False), start=1):
name_node = li.select_one("span.item") or li
name = name_node.get_text(strip=True)
if not name:
continue
node_id = next(temp_id_counter)
full_path = f"{parent_path}/{name}" if parent_path else name
nodes.append({
"temp_id": node_id,
"parent_id": parent_id,
"name": name,
"sort_order": order,
"level": level,
"full_path": full_path,
})
child_ul = li.select_one(":scope > ul")
if child_ul is not None:
parse_tree(
child_ul,
parent_id=node_id,
parent_path=full_path,
temp_id_counter=temp_id_counter,
level=level + 1,
)
这里有几个细节值得展开讲。
第一,find_all("li", recursive=False) 的作用是只找当前容器下的直接子 <li>,不能 recursive 到处搜索,否则会出现把孙子节点也当成兄弟节点的错误。第二,sort_order 用 enumerate(..., start=1) 生成,它表示当前节点的兄弟顺序,这个字段在后续数据库排序里非常重要。第三,level 表示当前节点处在第几层,排查层级异常时很有用。第四,temp_id_counter 用一个可变的计数器对象传进递归,避免整棵树共用同一个 ID 导致冲突。
递归里最需要留意的就是“终止条件”。在这段代码里,终止条件本质上就是“没有子 <ul> 就自然结束”,不会无限循环。但如果页面结构里存在异常的互相引用,或者你的逻辑误把自己作为子节点,递归就会爆炸。所以,我建议如果真的要把这段代码放到生产环境,可以在函数入口加一个 if level > MAX_DEPTH: return 的保护,例如 MAX_DEPTH = 15。地方志目录一般不会超过 10 层,15 已经足够宽松。
3.2 用 full_path 弥补树的不便
树结构适合展示,但有些场景用起来不方便,比如你要做全文搜索、导出 Excel、给某个节点快速定位。这时候我建议在解析时顺手生成一个 full_path 字段,把所有祖先节点的名字用斜杠拼起来。
例如,根节点“卷一 疆域志”的 full_path 是 卷一 疆域志;它的子节点“四至”的 full_path 是 卷一 疆域志/四至;再下一层“东至”就是 卷一 疆域志/四至/东至。
这个字段在落库之后非常有用,查某个关键字时可以直接 WHERE full_path LIKE '%四至%',瞬间把相关目录路径全捞出来,不用再一层层向上递归。它本质上是冗余存储,用一点磁盘空间换查询效率,对于这种目录表来说非常划算。
不过有个小坑:如果目录名里本身就带 /,比如“志/记”这种,full_path 拼接后会产生歧义。我在实际项目里遇到过一次,解决方法是把分隔符换成一个不太可能出现在目录名里的字符,也可以用 |。地方志目录名里偶尔有“一·二”这种,但几乎不会有 |,所以相对安全。
3.3 递归转栈以应对极端情况
递归虽然好理解,但它受 Python 递归深度限制。默认情况下,Python 的递归深度上限是 1000,超出就会 RecursionError。地方志目录树的深度通常不会超过几十层,所以递归其实够用,但如果你的爬虫以后要扩展到其他树形数据,比如公司组织架构、商品分类,深度可能就不好说了。
有一个替代方案是手动维护一个栈,用迭代方式模拟递归。思路是把待处理的 <ul> 节点压栈,每次弹出再处理,遇到子节点继续压栈。为了保证输出顺序和原来一致,压栈时要把同一层的多项反着压进去,先进后出的栈才会按正确顺序弹出。
python复制def parse_tree_iterative(root_ul):
nodes = []
temp_id_counter = iter(range(1, 10**9))
stack = [(root_ul, 0, "", 1)]
while stack:
current_ul, parent_id, parent_path, level = stack.pop()
children = current_ul.find_all("li", recursive=False)
for order in range(len(children) - 1, -1, -1):
li = children[order]
name_node = li.select_one("span.item") or li
name = name_node.get_text(strip=True)
if not name:
continue
node_id = next(temp_id_counter)
full_path = f"{parent_path}/{name}" if parent_path else name
nodes.append({
"temp_id": node_id,
"parent_id": parent_id,
"name": name,
"sort_order": order + 1,
"level": level,
"full_path": full_path,
})
child_ul = li.select_one(":scope > ul")
if child_ul is not None:
stack.append((child_ul, node_id, full_path, level + 1))
return nodes
这段代码里,stack 保存的是“还没处理的容器节点”,而不是“单个 li”。每次弹出一个 <ul>,就处理它的所有直接子 <li>,再把子级 <ul> 压栈。这样做的好处是不容易被递归深度卡住,所有状态都显式放在栈里,出了问题也好调试。
实际项目里,我优先推荐递归写法,因为可读性好、出 bug 概率低。只有当你的数据源真的出现超深层级,或者你想把这段逻辑拿来面试展示,才需要转成迭代版。
4. 数据落库到 SQLite
4.1 表结构设计
这一节是标题里“SQLite 落库”的核心。目录树解析出来之后,下一步就是设计表结构。先看我最终用的建表语句:
sql复制CREATE TABLE IF NOT EXISTS catalog_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL,
parent_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1,
full_path TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_unique
ON catalog_nodes(book_id, parent_id, sort_order);
book_id 用来区分不同书籍的目录,以后抓第二本地方志时,把 book_id 换掉就行,不用新建表。parent_id 保存父节点的主键 ID,根节点的父 ID 设为 0,这样查询从 0 开始就能一路往下找。sort_order 是兄弟顺序,level 是层级,full_path 是冗余的完整路径。
唯一索引 (book_id, parent_id, sort_order) 是防止重复插入的保险,它的意思是“同一本书下,同一个父节点下,第几个位置只能有一条记录”。万一重复抓取,同一位置的目录项会被识别出来,不会产生重复行。
4.2 写入策略:全量重建还是增量更新
写入 SQLite 之前,先要想清楚更新策略。我这里提供两种,各有适用场景。
第一种是全量重建。每次抓取前先 DELETE FROM catalog_nodes WHERE book_id = ?,再全部重新插入。优点是逻辑简单,不会残留旧数据,适合目录结构本身不太稳定、需要整体替换的场景。缺点是如果目录很大且站点访问压力大,全量重抓成本高。
第二种是增量更新,也就是标题热搜里常说的“存在就更新,不存在就新增”。SQLite 从 3.24.0 版本开始支持 ON CONFLICT DO UPDATE,配合唯一索引,一句话就能实现 upsert:
python复制import sqlite3
conn = sqlite3.connect("gazetteer.db")
conn.row_factory = sqlite3.Row
book_id = 1
def save_node(conn, book_id, parent_id, name, sort_order, level, full_path):
conn.execute(
"""
INSERT INTO catalog_nodes
(book_id, parent_id, name, sort_order, level, full_path)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(book_id, parent_id, sort_order)
DO UPDATE SET
name = excluded.name,
level = excluded.level,
full_path = excluded.full_path,
updated_at = CURRENT_TIMESTAMP
""",
(book_id, parent_id, name, sort_order, level, full_path),
)
这段代码里有一个关键细节:ON CONFLICT DO UPDATE 执行后,不能依赖 cursor.lastrowid 拿到刚插入或刚更新的 ID,因为冲突更新的时候 lastrowid 可能不可靠。正确的做法是用唯一键反查 ID:
python复制def get_node_id(conn, book_id, parent_id, sort_order):
row = conn.execute(
"""
SELECT id FROM catalog_nodes
WHERE book_id = ? AND parent_id = ? AND sort_order = ?
""",
(book_id, parent_id, sort_order),
).fetchone()
return row["id"] if row else None
我通常在递归函数里这样衔接:先调用 save_node,再调用 get_node_id 得到真实数据库 ID,然后把这个 ID 作为子节点的 parent_id 继续递归。这种方式的好处是兼容“首次插入”和“重复抓取”两种情况,缺点是每次插入后多一次 SELECT,但对于目录这种量级完全没关系。
4.3 用递归 CTE 查询整棵树
数据落进 SQLite 之后,怎么把它还原成一棵目录树?SQLite 支持递归公共表表达式,也就是 CTE,语法上比较像其他数据库的 WITH RECURSIVE。我用来查询整棵树的 SQL 是这样的:
sql复制WITH RECURSIVE tree AS (
SELECT id, parent_id, name, level, sort_order, full_path, 0 AS depth
FROM catalog_nodes
WHERE book_id = ? AND parent_id = 0
UNION ALL
SELECT c.id, c.parent_id, c.name, c.level, c.sort_order, c.full_path, t.depth + 1
FROM catalog_nodes c
INNER JOIN tree t ON c.parent_id = t.id
WHERE c.book_id = ?
)
SELECT id, parent_id, name, level, sort_order, full_path, depth
FROM tree
ORDER BY sort_order;
递归 CTE 的“递归”发生在 UNION ALL 的第二个查询里,每次拿上一轮结果 tree 作为父节点,再关联出下一层子节点。这个查询能把整棵目录树的所有节点一次性取出来,而且在 SQLite 标准库的 sqlite3 模块里直接就能执行,不需要额外安装 ORM。
要注意的是,ORDER BY sort_order 只保证同一父节点下的兄弟顺序,不保证整体输出顺序完全按照树形。如果后续要生成“卷 > 章 > 节”的严格顺序,最简单可靠的办法是取回结果后在 Python 里按照 parent_id 重新组装树,或者直接按 full_path 排序。因为我存了 full_path,很多场景其实不需要严格树序,直接按字符串路径展示也够直观。
4.4 事务与性能优化
落库的时候,千万不要每个节点插入一次就 commit() 一次。commit 是磁盘操作,频率太高会慢得让人怀疑人生。我习惯的做法是:所有节点插入之前只 BEGIN 一次,全部插完再 commit,中间出错就 rollback。
用 sqlite3 模块时,默认的 isolation_level 会帮我们控制事务,但更稳妥的是显式执行:
python复制try:
with conn:
save_all_nodes(conn, nodes)
except Exception:
conn.rollback()
raise
Python 的 with conn 会在块结束时自动提交,如果抛出异常就会自动回滚,这是很简洁的写法。但要注意,如果你的循环或递归过程中做了大量 SELECT 反查,整个事务持续的时间会比较长。此时可以开启 WAL 模式,让读和写不互相阻塞:
python复制conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA synchronous = NORMAL;")
WAL 模式对于本地单文件数据库来说很实用,尤其当你想一边写入一边用 Navicat 或 DB Browser 查看数据时,不会频繁出现 database is locked 的锁报错。
5. 常见问题与排查技巧
5.1 乱码、重复、层级丢失
我整理了实际项目中遇到比较多的问题,做成一个速查表,方便大家直接对照排查:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 中文乱码 | 页面编码识别错误 | 使用 resp.encoding = resp.apparent_encoding |
| 目录节点重复 | find_all("li") 没有加 recursive=False |
子级 li 被当成同级处理,改成直接子节点 |
| 层级全部变成 1 | 没有递归进入子 <ul> |
检查子容器选择器是否写对 |
| 某些目录为空 | 该节点没有 span.item |
用 or li 兜底,直接取整个 li 的文本 |
| 导入后树形错乱 | parent_id 映射错误 |
每次递归插入后用唯一键反查真实 ID |
| 数据库锁错误 | 多连接并发写 | 开 WAL 模式,或统一使用单连接 |
层级丢失是我个人觉得最坑的问题。表面上看目录抓下来了,但导进 SQLite 一查,所有节点的 level 都是 1,这就是因为在递归时没有正确找到子 <ul>。我的排查方法很简单:先打印前 20 条节点的 full_path,看有没有出现多级路径。如果全是单层,那 90% 是子容器选择器选错了。
重复数据的问题则多出在 recursive=False 没加对。find_all("li", recursive=False) 和 find_all("li") 的差别非常大,前者只找当前 <ul> 下一层的 li,后者会把这个 <ul> 下所有的 li 全捞出来。地方志目录这种多级嵌套结构,第二层 li 下面还套着第三层 li,如果你不加限制,同一个节点会被父级和祖父级各处理一遍,结果就是目录翻倍。
5.2 反爬和请求频率控制
虽然这个项目抓的是公开目录信息,但也要尊重目标站点的访问规则。我在实际代码里会给每次请求加一个随机延时,同时做失败重试。
python复制import time
import random
for attempt in range(3):
try:
resp = requests.get(url, headers=headers, timeout=15)
resp.raise_for_status()
break
except requests.RequestException:
if attempt == 2:
raise
time.sleep(2 ** attempt)
随机延时不要固定写死成同一个值,避免请求节奏太规整被识别成脚本:
python复制time.sleep(random.uniform(0.5, 1.5))
重试的等待时间可以按指数退避,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,这样能最大限度地避开临时性限流。
另外,就算网站没有明确反爬,也建议把抓取频率控制在比较保守的范围,尤其地方志类网站通常都是公益性质,服务器性能一般。你抓得慢一点,自己也能少遇到超时问题,这叫双赢。
6. 关于这个项目我最后想说的几点经验
整个项目做下来,最深刻的一个体会是:爬虫代码本身不难,难的是把“页面结构——树模型——数据库表结构”这条链路想清楚。如果你一开始不把目录当树处理,解析完直接往扁平表里塞,后面再想还原父子关系就非常痛苦。如果你一开始不设计唯一索引和更新策略,重复跑两遍脚本,数据库里就会出现一堆重复目录,查起来全是坑。
我后来养成了一个习惯:每次落库完成,都会打印一些关键统计信息,比如这次抓了 total_nodes 个节点、最大层级是几层、入库耗时多少。别小看这几行日志,它能帮你在下一次抓取时快速发现异常。如果上次最大层级是 6,这次突然变 2,那大概率是解析代码或页面结构出了问题。
还有一个很实用的小技巧:目录抓完后,不要急着写复杂查询,先做一次完整性校验。最简单的方法是统计每个 parent_id 的节点的子节点数和当前节点的 level,看看有没有不正常的层级跳跃。甚至可以直接在 SQLite 里跑一句:
sql复制SELECT level, COUNT(*) FROM catalog_nodes
WHERE book_id = 1
GROUP BY level
ORDER BY level;
输出结果如果是 1、2、3、4 层都有,且数量和页面预期一致,那基本可以判定落库成功。如果只有第 1 层有数据,说明第 2 层以下的节点全部丢了,赶紧回去检查递归逻辑。
这个项目还可以继续扩展,比如把目录和扫描件图片页码关联起来,做成一个在线阅读的目录导航,或者把同一个地方志的不同版本目录放在一起做对照。不过这些都是后话,先把目录页干净利落地抓下来、落进 SQLite,后续想干什么都有了基础。
