1. 项目背景与整体设计思路
1.1 为什么选地方志目录页做爬虫实战
地方志这东西,很多人可能只在图书馆或者政府网站上见过。它本质上是一个地方的历史百科全书,记载了某个行政区域从古至今的地理、人物、风俗、物产、建制沿革等信息。因为内容庞杂,一部完整的地方志通常被拆分成很多卷册,比如“建置卷”“人物卷”“艺文卷”“山川卷”,每一卷下面可能还有分册。所以地方志网站的卷册目录页,天然就是一棵树。
我在做地方志数字化整理的时候,碰到的第一个真实需求就是:把网站上的目录结构完整保存下来。这个需求本质上和做电商网站的商品分类抓取、做文档站点的侧边栏抓取没有区别,都是把网页上层级分明的树状结构解析出来,再存储到本地数据库里。所以别看标题里写的是“地方志”,这套思路换到任何一个树形目录场景都能直接用。
选这个案例还有一个现实理由:地方志网站的目录页结构相对规范,没有知乎、微博那种复杂的登录验证和动态加载机制,适合作为从零到一理解“树结构 + 爬虫 + 落库”完整链路的教学样本。你先把这套基本功练扎实,以后遇到反爬更强的站点,至少在解析和存储层面不会慌。
1.2 技术选型:为什么是 requests + SQLite
整个项目的技术栈很简单:requests 负责抓取网页,标准库 html.parser 或者 BeautifulSoup 负责解析,SQLite 负责存储。没有用 Scrapy,也没有用 MySQL,这不是偷懒,而是刻意为之。
requests 是 Python 生态里最基础、最常用的 HTTP 客户端库,API 设计简洁,出问题容易排查。用它可以让你把注意力集中在爬虫本身的逻辑上——怎么分析页面结构、怎么设计树模型、怎么处理数据关联——而不是被框架的配置项牵着走。等你把 requests 这套流程跑顺了,再去看 Scrapy 的中间件、Downloader 这些概念,会轻松很多。
SQLite 的选择也很有讲究。一个本地爬虫项目,数据量撑死几十万条记录,用 MySQL 属于杀鸡用牛刀,还得额外装服务端、配账号权限。SQLite 是一个嵌入式关系型数据库,就是一个单文件,Python 自带的 sqlite3 库就能直接操作,连安装都省了。而且 SQLite 支持标准的 SQL 语法,你在这里写熟了的 INSERT、UPDATE、SELECT,换到 MySQL、PostgreSQL 上一样通用。用 SQLite 做爬虫的本地存储,是性价比极高的方案。
提示:如果你的爬虫项目是多人协作、需要并发写入,或者未来数据量会增长到千万级别,那就老老实实上 MySQL 或者 PostgreSQL。SQLite 适合单机、个人使用、数据量可控的场景,这一点要心里有数。
1.3 树结构:这个项目里最核心的抽象
很多人一听到“树结构”就想到数据结构课本里的二叉树、红黑树,觉得高深莫测。其实在爬虫场景里,树结构就是一个“父子关系”的层级模型。地方志总集是根节点,下面挂着卷,卷下面挂着册,册里面可能还有章节,这就是一棵多叉树。
在代码里表达这棵树,有两种主流方式。一种是内存式,用 Python 的类或者字典嵌套来表示节点关系,边爬边在内存里构建整棵树,最后统一入库。另一种是关系式,直接把父子关系映射到数据库表里,每条记录通过 parent_id 字段指向它的父节点,通过递归查询还原树形。两种方式不冲突,实际项目中往往是结合使用:内存里临时构建,落库时保持 parent_id 关联。
我在这个项目里选择了一个更务实的方案:用 Node 类表示节点,用 parent_id 维护层级,边爬边存。也就是每解析出一个节点,立即写入 SQLite,不等待整棵树构建完成。这样做的优势是:一是防止爬到一半程序崩溃导致内存数据全部丢失,二是数据库里始终有一份最新的完整快照,方便随时查看进度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 目标页面分析与目录树建模
2.1 目录页的 HTML 结构长什么样
动手写代码之前,最重要的功课是分析目标页面的结构。这一步如果省了,后面全是坑。我这次的目标是某地方志网站的卷册目录页,用浏览器的“检查”功能看完结构后,发现它的目录是用嵌套的无序列表(ul/li)实现的,典型的树形布局。
简化后的 HTML 结构大致长这样:
html复制<ul>
<li data-id="1">
<span class="folder">地方志总集</span>
<ul>
<li data-id="2">
<span class="folder">建置卷</span>
<ul>
<li data-id="3"><a href="/book/3" class="file">上篇</a></li>
<li data-id="4"><a href="/book/4" class="file">中篇</a></li>
</ul>
</li>
<li data-id="5">
<span class="folder">人物卷</span>
<ul>
<li data-id="6"><a href="/book/6" class="file">列传一</a></li>
</ul>
</li>
</ul>
</li>
</ul>
看到没有,class 属性里已经给出了语义提示:folder 代表有子节点的分类,file 代表叶子节点(具体卷册)。data-id 是每个节点的唯一标识。这种结构在文档管理类网站中非常常见,算是最友好的爬虫目标之一。
2.2 设计 Node 类:让节点自带“层级基因”
分析完 HTML,接下来的工作就是设计数据模型。我定义了一个 Node 类,它承担两个职责:一是保存节点的自身属性,二是表达节点在树中的位置。核心字段包括 id、名称、父节点 id、类型、链接地址、排序值。
为什么要额外设计一个 sort_order 字段?因为在 HTML 里遍历的兄弟节点顺序就是它们在页面上的展示顺序,如果后续要在前端还原目录树,没有排序字段就只能按 id 排序,而 id 的分配顺序不一定等于展示顺序,这会导致树形展示错乱。这是一个我在实际项目中吃过亏才加上的字段,建议新手从一开始就留好这个位。
Node 类的实现如下:
python复制class Node:
def __init__(self, node_id, name, parent_id, node_type, url=""):
self.id = node_id
self.name = name
self.parent_id = parent_id
self.node_type = node_type
self.url = url
self.children = [] # 便于内存中构建树形
def add_child(self, child_node):
self.children.append(child_node)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"parent_id": self.parent_id,
"node_type": self.node_type,
"url": self.url
}
这里我额外给 Node 加了一个 children 列表。虽然落库时主要靠 parent_id 表达层级,但爬虫运行过程中需要在内存里快速组装父子关系,children 列表会让这个过程非常高效。比如你可以很容易地判断“这个节点有没有子节点”“某个节点下面的全部子节点有哪些”。
2.3 为什么说“递归解析”是树结构爬虫的灵魂
树结构页面的解析,最优雅的方案就是递归。思路非常直接:一个函数拿到一个 ul 节点,遍历它下面的所有 li 子节点,每个 li 要么是文件夹,要么是文件。如果是文件夹,就继续往它的内部 ul 递归深挖;如果是文件,就记录叶子信息。
递归的终止条件有两层:一是当前 li 下没有嵌套的 ul,说明这一条路径到头了;二是解析到叶子节点 a 标签且没有下一级列表,说明它是一个最终的卷册。只要这两个条件判断清楚,递归就不会出现死循环或者无限递归。
但在实现递归之前,还有一个前置问题要解决:怎么从 HTML 里准确拿到这些节点。我从实践中总结了两种方案。
第一种是使用 html.parser 标准库。这是 Python 自带的解析工具,不需要安装第三方包,但写起来非常繁琐,需要自己维护状态机来跟踪 ul、li、span、a 的进出。我不推荐新手直接上手,容易把自己绕晕。
第二种是使用 BeautifulSoup。它把 HTML 解析成对象树,你可以用 find_all、select 等 API 直接按标签名或 class 精确取节点,代码可读性好太多了。在接下来的实现里,我统一采用 BeautifulSoup 方案。
3. 核心功能实现:从页面到数据表的完整链路
3.1 环境准备与依赖安装
开始写代码前,先创建一个虚拟环境,避免依赖污染系统 Python。这个习惯我从第一个 Python 项目开始就坚持,尤其是爬虫这种依赖库较多的项目,虚拟环境能帮你省去无数“为什么我本地能跑服务器上报错”的麻烦。
bash复制mkdir local_gazetteer_crawler
cd local_gazetteer_crawler
python3 -m venv venv
source venv/bin/activate # Windows 下执行 venv\Scripts\activate
pip install requests beautifulsoup4
requests 和 beautifulsoup4 是这个项目仅有的两个第三方依赖。如果未来要处理动态渲染的目录页,你可能还会需要 selenium 或者 playwright,但那是后话,先把静态解析做到位。
3.2 请求目录页:伪装 User-Agent 是基本礼仪
写爬虫第一步就是发请求。但如果你直接用默认的 requests 头去请求,很多服务器会直接返回 403。原因很简单,requests 的默认 User-Agent 是 python-requests/x.x.x,服务器一眼就能认出这是脚本,而不是浏览器。
为了让请求看起来像真人浏览器,我设置了一个常见的 Chrome 浏览器 User-Agent:
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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "http://example-gazetteer-site.com/" # 换成实际的来源页
}
def fetch_page(url):
resp = requests.get(url, headers=HEADERS, timeout=15)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding # 关键:处理中文编码
return resp.text
这里有一个非常容易踩的坑:编码问题。很多网站在响应头里不声明 charset,或者声明的内容不准确。requests 默认会使用 ISO-8859-1 去解码,如果目标网站是 UTF-8 编码,你不手动处理,拿到的文本就是一串乱码。上面代码里 resp.apparent_encoding 会根据字节内容自动判断编码,实测在中文网站上的成功率很高。如果追求更快,也可以手动指定 resp.encoding = 'utf-8',前提是你确认目标站的编码方式。
注意:在实际抓取时,把断点调试放在拿到 response 之后、解析之前,先用 print 输出一小段 HTML 确认编码正常。这一步能避免你后面花了半小时调试解析逻辑,最后发现是编码错误。
3.3 递归解析目录:BeautifulSoup 的实战用法
拿到干净的 HTML 之后,解析逻辑就登场了。整体思路是:先找到最外层 ul,然后定义一个 recursive_parse 函数,传入 ul 节点和父节点 id,遍历其中的 li。对于每个 li,先判断它是文件夹还是文件,然后决定是继续递归还是创建叶子节点。
我写了一个稍微完整一点的版本,可以直接往工程里用:
python复制from bs4 import BeautifulSoup
class TreeParser:
def __init__(self, html_text):
self.soup = BeautifulSoup(html_text, "html.parser")
def parse(self):
root_ul = self.soup.find("ul")
if not root_ul:
raise ValueError("页面中未找到 ul 目录结构")
result = []
self._parse_ul(root_ul, parent_id=0, result=result)
return result
def _parse_ul(self, ul, parent_id, result):
order = 1
for li in ul.find_all("li", recursive=False):
folder_span = li.find("span", class_="folder")
file_link = li.find("a", class_="file")
if folder_span:
node = self._build_node(li, folder_span.get_text(strip=True), parent_id, "folder", order)
elif file_link:
node = self._build_node(li, file_link.get_text(strip=True), parent_id, "file", order, file_link.get("href"))
else:
order += 1
continue
result.append(node.to_dict())
child_ul = li.find("ul", recursive=False)
if child_ul:
self._parse_ul(child_ul, node.id, result)
order += 1
def _build_node(self, li, name, parent_id, node_type, order, url=""):
node_id = int(li.get("data-id"))
return Node(node_id=node_id, name=name, parent_id=parent_id,
node_type=node_type, url=url)
这里用了 find_all("li", recursive=False),是为了只拿当前 ul 的直接子 li,避免把更深层嵌套的 li 也捞出来,那样会导致层级混乱。在 BeautifulSoup 中,recursive=False 是一个高频使用、但新手很少知道的参数,强烈建议记下来。
你可能会问,为什么 node_id 直接用页面的 data-id,而不是自增?这是因为网站的 data-id 本身就具备唯一性,复用它可以让本地数据和源站对应起来,未来如果要做增量更新,直接根据 id 去判断哪些节点页面新增或删除了,非常方便。
3.4 SQLite 建表与数据模型设计
解析出来的是一批带层级关系的字典,下一步就是落库。我先设计了建表 SQL,当前这个版本的表结构足够健壮:
sql复制CREATE TABLE IF NOT EXISTS catalog_node (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER NOT NULL DEFAULT 0,
node_type TEXT NOT NULL,
url TEXT,
sort_order INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_node_parent ON catalog_node(parent_id);
parent_id 加索引是必须的,因为后续频繁操作就是“查某个父节点下有哪些子节点”。如果没有索引,数据量一上来,全表扫描的耗时指数级增长,再好的机器也扛不住几万条记录。
在 Python 中执行建表操作:
python复制import sqlite3
DB_PATH = "gazetteer.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS catalog_node (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER NOT NULL DEFAULT 0,
node_type TEXT NOT NULL,
url TEXT,
sort_order INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_node_parent ON catalog_node(parent_id)")
conn.commit()
conn.close()
3.5 落库策略:存在就更新,不存在就新增
落库的核心问题不是“怎么 insert”,而是“怎么安全地 insert”。如果爬虫中途挂了,重启后重新跑,同一个 id 的节点会被重复插入,导致主键冲突。所以我的落库策略采用 SQLite 的 UPSERT 语法(即 INSERT ... ON CONFLICT DO UPDATE),听起来有点高级,其实就是一句话:存在就更新,不存在就新增。
在 SQLite 3.24.0 及以上版本,支持标准的 ON CONFLICT 子句。写法如下:
python复制def upsert_node(conn, node_dict):
conn.execute("""
INSERT INTO catalog_node (id, name, parent_id, node_type, url, sort_order, updated_at)
VALUES (:id, :name, :parent_id, :node_type, :url, :sort_order, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
parent_id = excluded.parent_id,
node_type = excluded.node_type,
url = excluded.url,
sort_order = excluded.sort_order,
updated_at = CURRENT_TIMESTAMP
""", node_dict)
代码里的 excluded 是什么?它是 SQLite 在 UPSERT 场景下提供的特殊引用,代表你“本次想要插入的那条记录”。等号右边的 excluded.name 意思是:当遇到主键冲突时,用新传入的 name 覆盖掉表里的旧 name。这样即使目标网站改了目录名称,你重跑一次爬虫,数据库里的数据也会自动同步。
用这个方案,我再也不用担心重跑脚本导致数据脏了。这是我在多个爬虫项目中踩坑后总结出来的经验,强烈建议你不管做不做本项目的落库,都要掌握 UPSERT 这个技巧。
3.6 用事务批量写入:别一条条 commit
如果解析出来几百个节点,写一条 commit 一次,效率很低不说,中途出错还会产生大量零碎数据。正确做法是解析完一整轮之后,用一个事务统一提交。如果某个节点插入失败,整个事务回滚,保证数据库的一致性。
事务提交的代码非常简单:
python复制def save_all_nodes(nodes):
conn = sqlite3.connect(DB_PATH)
init_db()
try:
with conn: # 使用 with 语法,自动开启事务
for node in nodes:
upsert_node(conn, node)
finally:
conn.close()
这里的 with conn 是 Python sqlite3 模块给的语法糖:进入 with 块时开启事务,正常执行完会 commit,发生异常会自动 rollback。关于“手动 commit 时机”这个问题,我的建议是:批量写操作统一交给事务,单条查询不需要事务。
4. 主流程整合与反爬基础防护
4.1 把它们串起来:主函数设计
解析和落库都写好了,主流程就非常简洁了。它只需要做三步:请求页面、解析树结构、批量入库。我把主流程写成了一个 run 函数,这样后续做定时爬取或者命令行调用都很方便。
python复制def run():
init_db()
url = "http://example-gazetteer-site.com/catalog"
html_text = fetch_page(url)
parser = TreeParser(html_text)
nodes = parser.parse()
print(f"共解析到 {len(nodes)} 个目录节点")
save_all_nodes(nodes)
# 验证:统计各类型节点数量
conn = sqlite3.connect(DB_PATH)
folder_count = conn.execute("SELECT COUNT(*) FROM catalog_node WHERE node_type='folder'").fetchone()[0]
file_count = conn.execute("SELECT COUNT(*) FROM catalog_node WHERE node_type='file'").fetchone()[0]
print(f"文件夹节点: {folder_count}, 卷册节点: {file_count}")
conn.close()
if __name__ == "__main__":
run()
运行后打开数据库,你可以看到这样一条条记录:id 是网站给的唯一编号,parent_id 把父子的层级关系固定了下来,name 是目录名称,node_type 区分了分类和卷册,url 是卷册详情页的链接,sort_order 保证了同一层级下节点的展示顺序。
到这里,一个完整的树结构爬虫就已经跑通了。不过先别急着庆祝,爬虫领域还有一句老话:能用键盘解决的事情,千万别用遍历去挑战服务器的耐心。
4.2 限速与重试:对目标网站的基本尊重
requests 默认情况下发请求的速度非常快,如果是纯循环抓取,短时间内就能发出几十上百个请求。个人用途的爬虫一定要在请求之间加延时,这是基本素质,也是防止自己被封掉的最简单手段。
我在 fetch_page 里加上了重试机制和延时。考虑到目录页可能包含几十个详情页的链接,如果后续想抓取每个卷册的内容,限速就变得格外重要:
python复制import time
def fetch_with_retry(url, max_retries=3, delay=2):
for attempt in range(max_retries):
try:
html = fetch_page(url)
return html
except requests.RequestException as e:
print(f"第 {attempt + 1} 次请求失败: {e}")
if attempt < max_retries - 1:
time.sleep(delay * (attempt + 1)) # 线性退避
raise RuntimeError(f"请求失败: {url}")
def fetch_pages_concurrently(urls):
results = []
for url in urls:
results.append(fetch_with_retry(url))
time.sleep(1) # 控制请求频率
return results
time.sleep(1) 看起来微不足道,但对服务器来说,这 1 秒的间隔能把请求压力降低一个数量级。记住一个原则:爬虫是开着一辆车在别人院子里转,不是你开着坦克闯进去。 至少基本的限速要做到。
4.3 常见反爬状态码的应对思路
在实际抓取过程中,我会遇到几个典型的反爬响应:403 Forbidden、301/302 重定向、503 Service Unavailable。403 表示服务器认识你是谁但不想理你,通常是 User-Agent 或者 IP 被限制;301/302 表示内容搬家了,你需要把请求重定向到新地址;503 表示服务器忙不过来,或者它在试探你是不是真浏览器。
遇到 403,优先检查请求头,确认 User-Agent、Accept、Accept-Language 这些是否完整。如果请求头没问题,那就是 IP 被限制,这个时候不要想着硬破解,停一停,隔一段时间再跑。遇到 503,大概率是服务器压力大,加大延时、降低请求频率,等高峰期过了再抓。
注意:本项目只涉及公开的目录信息抓取,抓取频率务必克制、用途务必正当。爬虫技术本身是中性的,但你的使用方式决定了它的边界。做一个守规矩的爬虫开发者,才能长期玩得下去。
4.4 用异常捕获保护数据结构
解析过程中最容易遇到的异常是:某个 li 节点没有 data-id,或者某个文件夹名称取不到。如果不做保护,程序可能直接中断,已经解析到的数据即使有事务保护,也没法落库。我的建议是:在解析循环里捕获单节点异常,做一个轻量的日志记录,然后跳过这个节点继续往后走。
python复制def _parse_ul(self, ul, parent_id, result):
order = 1
for li in ul.find_all("li", recursive=False):
try:
folder_span = li.find("span", class_="folder")
file_link = li.find("a", class_="file")
# ... 节点构建逻辑 ...
except Exception as e:
print(f"解析节点异常: {e}")
order += 1
continue
这样处理的思路是:整个目录树是庞大的,一个节点坏了不影响其他节点入库。我们先把能拿到的数据全部落库,最后再根据日志定位具体是哪个节点出了问题,单独排查。这也是一种工程思维——不要因为一颗钉子废了一面墙。
5. 数据回读与树形还原
5.1 从 SQLite 查询所有节点
落库只是上半场,能方便地查出来才是下半场。数据回看最简单的场景:把整张表读出来,按 parent_id 分组,存成一个字典,然后从根节点开始递归打印。这一步相当于把入库的扁平数据重新还原成树形结构。
python复制def load_tree_from_db():
conn = sqlite3.connect(DB_PATH)
rows = conn.execute(
"SELECT id, name, parent_id, node_type, url, sort_order FROM catalog_node ORDER BY sort_order"
).fetchall()
conn.close()
nodes = {}
for row in rows:
nodes[row[0]] = {
"id": row[0],
"name": row[1],
"parent_id": row[2],
"node_type": row[3],
"url": row[4],
"sort_order": row[5],
"children": []
}
root = []
for node in nodes.values():
pid = node["parent_id"]
if pid == 0 or pid not in nodes:
root.append(node)
else:
nodes[pid]["children"].append(node)
return root
注意这里处理了一个细节:如果某条记录的 parent_id 指向的父节点不存在(可能是父节点在解析时被跳过了),我不会让它凭空消失,而是直接把它提升为根节点。这样保证了树的完整性,不会因为单个数据异常导致整棵子树不可达。
5.2 用缩进打印验证整棵树的完整性
树建好之后,用缩进打印一眼就能看出层级关系是否正确。缩进两个空格代表一级:
python复制def print_tree(nodes, level=0):
for node in nodes:
prefix = " " * level + ("[目录] " if node["node_type"] == "folder" else "[卷册] ")
print(f"{prefix}{node['name']}")
if node["children"]:
print_tree(node["children"], level + 1)
tree = load_tree_from_db()
print_tree(tree)
输出效果大概是:
code复制[目录] 地方志总集
[目录] 建置卷
[卷册] 上篇
[卷册] 中篇
[目录] 人物卷
[卷册] 列传一
如果你发现某个目录下面少了本该存在的卷册,问题大概率出在解析那一步,而不是落库。这时候不要重跑全量爬虫,先单测一下那个子页面的解析逻辑,把问题定位准了再全量重跑。
6. 常见问题与排查技巧实录
6.1 问题速查表
我在开发过程中整理了下面这些高频问题,几乎每个爬虫项目都会遇到,你可以直接对照排查。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 返回 403 | 请求头不完整被识别为爬虫 | 打印响应头,查看 Server 类型 | 补全 User-Agent、Accept、Referer |
| 中文乱码 | 编码判断错误 | 输出 resp.encoding 和页面 charset | 使用 resp.apparent_encoding 或手动指定 |
| 解析结果为空 | BeautifulSoup 没找到目标节点 | 打印前 500 字符 HTML | 检查 class 名是否改变、是否嵌套层级不同 |
| 数据库主键冲突 | 重复插入相同 id | 查看完整报错堆栈 | 使用 UPSERT 语法,或先 SELECT 再判断 |
| 爬取到一半中断 | 单节点异常导致整体崩溃 | 检查错误日志 | 在解析循环内加 try/except,跳过坏节点 |
| 目录层级错乱 | find_all 把嵌套 li 也取出来了 | 打印 li 的父子关系 | 在 find_all 中加 recursive=False |
6.2 实测中总结的独门经验
第一个经验是永远不要信浏览器检查面板里看到的 HTML 就是请求返回的 HTML。浏览器渲染后 DOM 会动态变化,有些节点是 JavaScript 生成的,而你用 requests 拿到的只是服务器返回的原始 HTML。如果解析不到内容,先去确认请求得到的原始响应里到底有没有你要找的标签。
第二个经验是每写一个阶段的代码,就立刻用 print 验证输出,不要写完一整套再跑。我见过太多新手把抓取、解析、落库串起来之后,第一次运行报错,结果一行行排查耗了半小时,其实第一步请求就已经失败了。分阶段验证,像盖楼一样,一层一层确认地基,效率高得多。
第三个经验是数据库字段要留富余。我当时只设计了 name、url、parent_id 这些基础字段,后续想增加一个 volume_level 字段来标记层级,就得手动修改表结构。如果你在建表时预留一个 extra 字段(TEXT 类型,存 JSON 字符串),以后扩展会舒服很多。
6.3 结合 SQLite 数据做二次应用的小扩展
最后再分享一个扩展方向:本地目录库建好之后,你可以把它接入到简单的检索工具里。比如用 Python 写一个模糊搜索函数:
python复制def search_catalog(keyword):
conn = sqlite3.connect(DB_PATH)
rows = conn.execute(
"SELECT id, name, parent_id, node_type FROM catalog_node WHERE name LIKE ? ORDER BY sort_order",
(f"%{keyword}%",)
).fetchall()
conn.close()
return rows
print(search_catalog("人物"))
这一步虽然简单,但把“爬虫”和“数据应用”打通了。你能查,就能基于同一套数据库进一步做词频统计、数据清洗、归档整理等操作。爬虫从来不是终点,把数据变成能用的资产才是。
7. 项目总结与后续优化建议
7.1 当前方案的适用边界
这套“requests + BeautifulSoup + SQLite + UPSERT”的方案,最适合的数据集特征是:网页结构清晰、数据量在十万条以内、允许延时循环抓取、单机即可完成。如果你遇到了需要登录才能看的目录、数据量突破百万、目标站有较强的反爬机制,或者需要实时更新,那就得考虑升级方案了。
升级路径也很清晰:登录态用 requests.Session 维护 Cookie,更大并发用 Scrapy + 分布式任务队列,更复杂的页面用 Playwright 或 Selenium 渲染,更重的存储换 MySQL 或 PostgreSQL。但不管换什么工具,解析树结构、维护父子层级、批量落库这三个核心思想完全一致。你在本项目里打下的基本功,是通用的。
7.2 增量更新与异常恢复机制
我后续给这个项目加了一个小功能:增量更新。逻辑很简单,爬虫启动时先查询本地库里已有的最大 sort_order 值,再和线上页面的值做对比。如果新增了目录,就只爬新增部分;如果目录被删除,不主动删除本地记录,只是标记为“历史遗留”。这样既节省请求资源,又保留了历史数据。
除此之外,我还建议给爬虫加一个检查点机制。如果你的爬虫需要分多天跑完,每天记录一下爬到哪个节点的哪个分支,第二天从这个位置继续。最简单的方式就是维护一个 progress 表,字段包括当前节点 id、日期、状态。这个办法投资回报率极高,特别适合数据量大的目录抓取项目。
7.3 关于代码托管与数据备份的一点建议
项目虽然小,代码也别放在临时目录里。建一个 Git 仓库,把爬虫代码和数据库文件分开管理。数据库文件(.db)默认放一个 backup 目录,每次跑完现场备份一下。我个人的习惯是:代码仓库里不放 .db 文件,而是放一个 backup.sh 脚本,每天定时把数据库复制到备份目录并带上日期后缀。坚持这么做之后,我再也没经历过“数据库文件损坏导致几个月的成果灰飞烟灭”这种事故。
最后说一句掏心窝的话:爬虫这门手艺,难度不在于写代码,而在于对结构的洞察和对边界的敬畏。目录页看起来很简单的树,你把层级理清楚、把数据存得明明白白,这就是能迁移到任何结构化数据抓取场景的核心能力。希望这篇文章能帮你把这条路走通。
