简介:这份 docx 学习资料面向刚接触 Python 爬虫、希望把抓取结果真正保存下来的读者,围绕网页内容获取与文档型数据库写入两条主线展开。全包仅 1 个 docx 文件,约 608KB,以概念讲解配合代码片段编排,篇幅紧凑、知识点密度较高。正文先从 MongoDB 基本概念讲起,梳理文档、集合、数据库的层级关系,键值对顺序性、键命名规范,以及与关系型数据库的差异;随后进入实战,用 requests 定制请求头抓取公示页面源码,借助 lxml、正则与 BeautifulSoup 解析 HTML 提取目标字段,再通过 pymongo 连接本地 27017 端口,将结构化结果写入指定数据库与集合,并演示存在性检测、索引与查询等操作。文中还涉及进程池并发抓取与请求异常处理等工程细节,可帮助读者打通从请求、解析到入库的完整链路。已有 11258 人学习下载,适合作为爬虫与数据存储结合练习的参考材料。
1. Python 爬虫存 MongoDB:三行依赖装错就能让你卡一晚上
上周帮人排查一套「Python 爬虫 + MongoDB」的采集环境,机器上 Python 装了两个版本,pip 装到了另一个版本下:requests能 import,pymongo一 import 就报 ModuleNotFoundError;MongoDB 服务明明开着,MongoClient一连又超时。代码本身没有任何问题,全是环境没配对。用 Python 爬网页再落进 MongoDB,链路其实很短:requests 发请求、lxml 解析、pymongo 写库,三个库加一个服务端就能跑。麻烦的是版本、端口、路径、驱动这几处细节,装错了不一定报错,跑到一半才出错。这套环境适合需要长期采集、字段不固定、后面还要从库里导出成 docx 归档的人。
2. 先装 MongoDB 还是先装 pymongo:服务端与驱动的落位顺序
顺序搞反是新手最容易浪费一整晚的地方。MongoDB 是独立进程,跑在 27017 端口上;pymongo 只是一层客户端驱动,它不会替你启动数据库。先把驱动装上、代码写完,再运行才发现连不上,报错信息还分成「Connection refused」和「ServerSelectionTimeoutError」两种,看着像两回事,其实是同一个原因。我的习惯是:服务端先起,端口先通,然后才碰 Python 这一侧。
2.1 确认 Python 与 pip 指向同一套解释器
装任何库之前的第一个动作,不是pip install,是确认 pip 到底往哪个解释器里装。Windows 上同时存在 Python 3.8 和 3.11、又装了 Anaconda 的机器,这一步必查。
# 查看当前解释器版本与路径 python -V python -c "import sys; print(sys.executable)" # 查看 pip 归属,输出里的路径要和上面 sys.executable 的目录一致 pip -V两行路径不一致,后面所有依赖都装进了另一个环境。稳妥做法是给这个采集项目单独开虚拟环境,不要往全局环境里塞。
# 在项目目录下创建并激活虚拟环境 python -m venv venv-crawl # Windows venv-crawl\Scripts\activate # Linux / macOS source venv-crawl/bin/activate # 激活后再确认一次,此时 pip -V 的路径里应该带 venv-crawl python -V && pip -Vpython -m venv的好处是它跟你当前这个 python 绑定,创建出来的环境不会错位。虚拟环境激活后命令行前面会带(venv-crawl)前缀,没有这个前缀就说明没激活成功,先别往下走。
2.2 MongoDB 服务端安装与开机自启
服务端有三种装法,按你的机器选:Windows 用官方 msi 安装包,Linux 用包管理器,想省事就用容器。
Windows 上跑 msi,安装向导里有一个Install MongoDB as a Service的勾选项,默认是勾上的,别取消。服务化之后开机自启,你不需要每次手动开黑窗口跑mongod。数据目录默认在安装路径下的data里,日志在log里,这两个路径记下来,后面连不上时要去看日志。
Linux 上装完用 systemd 托管,启动和查状态:
# 启动并设为开机自启 sudo systemctl enable --now mongod # 看服务状态,重点看最后几行的 Active 和日志尾部 sudo systemctl status mongod --no-pager # 确认 27017 端口处于 LISTEN 状态 sudo ss -lntp | grep 27017ss -lntp里-l是只看监听态,-n是不做域名解析直接显示端口数字,-t是 TCP,-p是显示进程名。如果端口那一列显示的监听地址是127.0.0.1:27017而不是0.0.0.0:27017,说明只允许本机连。采集脚本和数据库在同一台机器上时,这个默认值正好够用,也更安全;真要跨机器连,去改配置文件里的bindIp,改完必须重启服务,光改文件不重启等于没改。
如果你用容器,一条命令起一个带数据卷的实例:
docker run -d --name mongo-crawl \ -p 27017:27017 \ -v /data/mongo-crawl:/data/db \ mongo:latest-v那个挂载别省,省了容器一删数据全没。端口映射写成27017:27017,前面是宿主机端口,后面是容器内端口,两个不一样的时候 pymongo 连的是前面那个。
2.3 pymongo 驱动的安装与最小连接
服务端跑起来了,再装驱动。这一步不要锁死版本,装当前稳定版即可,老版本 pymongo 对 MongoDB 新版服务端的兼容性反而更差。
pip install pymongo装完先看版本号,再写连接测试。连接串里我会显式加一个serverSelectionTimeoutMS,默认 30 秒,等它超时太磨人,改成 2 秒,错了立刻报。
from pymongo import MongoClient # 显式指定 IP 和端口,超时改小方便快速暴露问题 client = MongoClient("mongodb://127.0.0.1:27017", serverSelectionTimeoutMS=2000) # server_info() 会真正发一次请求,能拿到版本说明握手成功 info = client.server_info() print("服务端版本:", info["version"])MongoClient构造的时候并不真正建立连接,它是懒连接的,所以只写第一行不会报错。必须调一次server_info()或者执行一次实际命令,才能验证链路通不通。这一点很多人误会,以为MongoClient(...)没抛异常就是连上了。
3. 补齐抓取依赖:requests、lxml、pymongo 的安装与验证
采集脚本的依赖一共就三类:发请求的、解 HTML 的、写数据库的。前两个在安装阶段各有各的坑,尤其 lxml,它是 C 扩展,装不上时给出的报错跟 Python 语法错误完全两回事,第一次见容易懵。
3.1 三个库的安装顺序与 lxml 的编译依赖
一次性装当然可以,但出问题时不好定位是谁挂了。我的做法是先装纯 Python 的 requests 和 pymongo,最后单独装 lxml。
# 先装两个没有编译依赖的 pip install requests pymongo # 再单独装 lxml,它的报错要单独看 pip install lxmlWindows 上pip install lxml基本不会失败,因为 PyPI 上有预编译好的 wheel,pip 会直接下.whl而不是源码编译。你看到下载文件名以win_amd64.whl结尾,就不用管编译这回事。
Linux 上没装开发头文件时会走源码编译,然后在编译阶段报一屏红色。这种情况不是 pip 坏了,是缺系统库:
# Debian / Ubuntu 下补齐 lxml 编译所需的头文件 sudo apt-get update sudo apt-get install -y libxml2-dev libxslt1-dev zlib1g-dev python3-dev # 装完再重试,此时才会真正编译成功 pip install lxmllibxml2-dev和libxslt1-dev是 lxml 底层依赖的两个 C 库的开发包,-dev后缀的意思是把头文件也装上,只有运行时库是编译不了的。python3-dev提供 Python.h,缺了它连扩展模块都编不出来。这四个包装完再装 lxml,成功率接近百分之百。如果还是失败,把 pip 输出往上翻二十行,真正的第一处错误通常在最上面,不在最后。
3.2 用一段最小 HTML 验证 XPath 能跑通
库装上不等于会用。写正式脚本之前,先拿一段十几行的假 HTML 把解析链路走一遍,确认etree.HTML和xpath都正常。
from lxml import etree # 模拟一段列表页结构,ul 的 class 是 list-group,a 标签里放详情页链接 html = ''' <ul class="list-group"> <li><a href="./202401/t20240110_100001.html">公示一</a></li> <li><a href="./202401/t20240111_100002.html">公示二</a></li> </ul> ''' selector = etree.HTML(html) # 把字符串解析成可查询的 HTML 树 hrefs = selector.xpath('//ul[@class="list-group"]/li/a/@href') # 取所有 a 的 href 属性 print(hrefs)etree.HTML()会自动补齐html和body标签,所以你传进去的片段不完整也没关系。xpath的返回值永远是列表,哪怕只匹配到一条;@href表示取属性值而不是取文本,取文本要用/text()。匹配不到时返回空列表,不会抛异常,所以后面接[0]取值才是常见的崩溃点。
真实页面上取到的链接一般是./202401/t20240110_100001.html这种相对路径,需要拼成绝对地址。原文脚本里用re.sub('^.', 前缀, i)把首字符替换掉,这个写法能用但脆:.在正则里匹配任意单字符,一旦页面改成不带./前缀的写法,就会把域名首字母也吃掉。我更倾向显式处理:
import re BASE = "http://www.example.com/xxgk/gggs" def to_absolute(href: str) -> str: # 去掉开头的 ./ 或 /,再统一拼上站点前缀 path = re.sub(r'^\.?/', '', href) return f"{BASE}/{path}"r'^\.?/'里的\.是转义后的点号,?表示前面的点号可有可无,所以./202401/...和/202401/...两种都能处理成同一结果。这种写法比替换首字符稳,页面结构小改也不会翻车。
3.3 requests 的请求头与编码设置
请求头里最该管的是 User-Agent,很多站点对空 UA 的请求直接返回 403 或者一个空壳页面。原文用的是一段 Chrome 78 的 UA 字符串,这个值本身不敏感,换成一个近期版本的浏览器标识更不容易被识别成脚本。
import requests headers = { # 声明浏览器类型与版本,避免服务端因空 UA 直接拒绝 "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" } res = requests.get("http://www.example.com/xxgk/gggs/index.html", headers=headers, timeout=10) res.encoding = res.apparent_encoding # 按内容推测编码,比默认值可靠 print(res.status_code, res.encoding)timeout=10是我强烈建议加的参数,不加的话遇到不响应的服务器会一直挂着,进程池里几个进程一起卡死,看着像程序没反应。apparent_encoding依赖charset_normalizer或chardet,这两个通常随 requests 一起装上了,如果你在裸环境里手动删过依赖,单独补一下。中文页面乱码十有八九是这一行没写,res.text用了默认的 ISO-8859-1 去解。
4. 连接串与集合配置:端口、库名、集合名、进程池四个参数怎么定
代码能跑到写入这一步,剩下的都是配置取舍。原文脚本里连接、库名、集合名、进程数四样东西写在了一起,任何一样配错,表现都是「程序跑完了但库里没数据」,比报错更难查。
4.1 连接串的三种写法与 IPv6 的坑
import pymongo # 写法一:拆开传参,最直观 myclient = pymongo.MongoClient("localhost", 27017) # 写法二:完整 URI,后面加账号密码、副本集都靠它 myclient = pymongo.MongoClient("mongodb://127.0.0.1:27017") # 写法三:带认证库,开了用户权限后必须写 authSource myclient = pymongo.MongoClient("mongodb://user:pass@127.0.0.1:27017/?authSource=admin")第一种写法里的localhost在某些系统上会先解析成 IPv6 的::1,而 MongoDB 默认只监听 IPv4 的127.0.0.1,结果就是本机连本机也超时。这类问题排查起来极其费劲,因为ping localhost是通的。直接用127.0.0.1可以把这一层不确定性砍掉,我现在写连接串基本不用localhost。
第三种写法里的authSource=admin指的是去哪个库校验账号。用 admin 库里建的账号去连业务库时,不写这个参数就会认证失败,报Authentication failed。这三个字看着像密码错了,实际上八成是认证库没指定。
4.2 库名与集合名的命名,带点号的集合要特别留意
原文用的库名是webpages,集合名是gov.publicity。库名没问题,集合名里的点号是个隐藏的麻烦:MongoDB 允许集合名包含点,但 shell 和一些可视化客户端会把点当成命名空间分隔符来解析。在 mongo shell 里敲db.gov.publicity.find(),它找的是gov这个库下面的publicity集合,不是你要的那个。
// 错误理解:以为在查 webpages 库里的 gov.publicity 集合 use webpages db.gov.publicity.find() // 实际指向了 gov 库 // 正确做法:用 getCollection 明确指定带点号的集合名 db.getCollection("gov.publicity").find().limit(5)除了点号,还有几条硬性限制值得记:集合名不能以system.开头,那是系统集合保留前缀;不能包含$和空字符\0;名字区分大小写,Publicity和publicity是两个集合。至于以点号分层这种命名习惯,如果只是为了「看起来像层级」,我更建议直接用gov_publicity这种下划线命名,省掉后面所有工具里的转义麻烦。
数据库和集合都是懒创建的,这一点要建立概念。你写了myclient["webpages"],磁盘上什么都没发生;写了mydb["gov.publicity"],也什么都没发生;直到第一条insert_one成功返回,库和集合才真正落盘。所以「检测库是否存在」这类代码,在首次运行时必然是 False,别把它当成报错。
mydb = myclient["webpages"] mycol = mydb["gov_publicity"] # 用下划线命名,规避点号解析问题 # 手动触发一次写入,库和集合此时才真正创建 result = mycol.insert_one({"标题": "测试", "来源": "本站"}) print("插入的文档 id:", result.inserted_id) # inserted_id 是 ObjectId 对象insert_one返回的是InsertOneResult对象,inserted_id就是这条文档的_id。如果你在字典里没写_id,驱动会自动生成一个ObjectId,它是全局唯一的有序标识,后面按 id 回查、去重都靠它。
4.3 进程池参数与写入节流
列表页几十页、详情页几百条,串行跑太慢,原文用Pool(processes=4)开四个进程并发抓。四个不是拍脑袋定的,一般是 CPU 核数的一半到一倍之间,同时还要看目标站点能承受多少并发。
from multiprocessing import Pool if __name__ == "__main__": links = [...] # 前面收集好的详情页链接列表 pool = Pool(processes=4) # 同时最多 4 个进程在跑 pool.map(page_scratch, links) # 把 links 逐个喂给 page_scratch pool.close() # 不再接受新任务 pool.join() # 等所有子进程结束map会阻塞到全部任务完成,所以后面接close()和join()只是规范收尾,不写也不影响结果。processes开太大有两个后果:一是目标站点直接给你限流或者封 IP;二是每个进程各自建一条 MongoDB 连接,连接数上去之后服务端也会拒绝。
写入侧我一般还会加一行time.sleep(0.1),每个详情页抓完停 100 毫秒。四个进程乘起来,实际速率还是每秒几十条,对小型站点足够,又不至于把人家打挂。这个 0.1 不是玄学,是给对端留一点喘息,采集跑得久一点比中途被封强。
还有一个 fork 相关的坑必须提:在 Linux 或 macOS 上,进程池默认 fork 出子进程,父进程里创建的MongoClient会被子进程继承。连接对象跨进程共享是不安全的,症状是偶发的AutoReconnect或者写入丢失,时好时坏。干净的做法是在采集函数内部建连接,或者给MongoClient传connect=False让它在 fork 之后才真正连。
def page_scratch(link): # 在每个子进程内部建立连接,避免父进程连接被 fork 继承 client = pymongo.MongoClient("mongodb://127.0.0.1:27017", connect=False) col = client["webpages"]["gov_publicity"] doc = {"链接": link, "状态": "待解析"} col.insert_one(doc) client.close()连接建在函数里、用完关掉,看着效率低,实际上单条插入的开销远小于一次网络请求,这点成本换稳定很值。
5. 排查清单:五类翻车现场的现象、原因和救法
下面五条都是我在真实环境里遇到过的,按「看到什么 → 为什么 → 怎么修」记,遇到了直接照着对。
5.1 连接与库表类问题
现象一:ServerSelectionTimeoutError: localhost:27017: Connection refused,代码刚跑到server_info()就挂。
原因基本只有一个:MongoDB 服务没起来,或者起来但没监听你连的那个地址。Windows 上装了 msi 但没勾服务化,机器重启后服务就没了;Linux 上systemctl start过但没enable,重启一样没。
解决顺序是固定的:先systemctl status mongod(Windows 去服务管理器看 MongoDB 服务),看到inactive就启动;状态是 active 但依然连不上,用ss -lntp | grep 27017看监听地址,如果是127.0.0.1:27017而你连的是别的机器 IP,就得改bindIp并重启。改配置不重启是最常见的自欺欺人。
现象二:程序跑完不报错,可视化客户端打开是空的,没有webpages这个库。
原因在于 MongoDB 的懒创建机制。myclient["webpages"]和mydb["gov_publicity"]这两行只是 Python 侧的对象,没往服务端发任何请求。真正建库建集合的动作是第一次成功写入。
还有一种情况是写入确实执行了,但写进了别的库。检查你的MongoClient后面挂的库名,大小写、单复数都要对上。解决就是手动跑一次insert_one,看返回的inserted_id是否有值,有值再去客户端刷新。
现象三:在客户端里能看到集合,但打开报错,或者看不到gov.publicity这个带点号的名字。
点号被当成了库和集合的分隔符。某些客户端会把它拆成gov库下的publicity。解决方式有两条:短期在客户端里找gov这个库,或者直接用 shell 的db.getCollection("gov.publicity");长期是改命名,把点号换成下划线,一劳永逸。
5.2 抓取与解析类问题
现象四:IndexError: list index out of range,报在xpath(...)[0]这一行。
XPath 没匹配到任何节点,返回空列表,[0]就崩了。原因通常是页面结构变了,class 从xxym改成别的,或者目标站点把内容放进了 iframe、用 JS 渲染。
排查方法是把res.text存成本地文件,用编辑器打开搜那个 class 名,看还在不在。定位到新结构再改 XPath。防御性写法是先判断再取值:
nodes = selector.xpath('//div[@class="xxym"]/h4/text()') title = nodes[0].strip() if nodes else "标题缺失"比加一身 try/except 强,至少你知道哪条数据缺了字段,而不是整批静默失败。
现象五:抓下来的中文全是乱码,或者正文被截断成半句话。
编码没设对。不定res.encoding时,requests 会从响应头猜,很多站点响应头里根本没写 charset,就退化成 ISO-8859-1。加res.encoding = res.apparent_encoding能解决大部分。
还有一种是设了编码但取的节点不对:正文由多个<p>组成,只取第一个p就只拿到一段。正确做法是把所有段落文本收集起来再拼接:
# 收集所有段落文本,过滤空白行,用换行符连接 paragraphs = selector.xpath('//div[@class="TRS_Editor"]/p/text()') article = "\n".join(p.strip() for p in paragraphs if p.strip())6. 跑通验证:从一次 insert_one 到可视化客户端里看到文档
装完一整套,判断「到底跑通了没有」不能靠程序没报错。我按三步验证,每步过了再走下一步。
第一步,服务端层面。不碰 Python,直接从客户端连。
# 在服务端本机执行,能进交互界面说明端口和进程都正常 mongosh --host 127.0.0.1 --port 27017 # 进去后执行两行,看到版本号和数据库列表就对了 db.version() show dbs第二步,驱动层面。跑前面那三行server_info(),打印出服务端版本号。这一步过不去,后面所有代码都不用写。
第三步,端到端。挑一条链接手工跑一遍采集函数,看返回值。
if __name__ == "__main__": # 只跑单条,先确认写入链路通 page_scratch("http://www.example.com/xxgk/gggs/202401/t20240110_100001.html") # 回查最近一条,确认字段都落进去了 client = pymongo.MongoClient("mongodb://127.0.0.1:27017") col = client["webpages"]["gov_publicity"] doc = col.find_one(sort=[("_id", -1)]) print(doc.get("标题"), doc.get("日期"), len(doc.get("公示内容", "")))find_one(sort=[("_id", -1)])是按_id倒序取第一条,ObjectId本身带时间戳,所以这相当于「取最新插入的那条」。打印出标题、日期、正文长度三个值,标题非空、日期格式对、正文字数在几百以上,基本可以判定解析和写入都没问题。
最后一步才是打开可视化客户端。原文用的是 Robo3T,连接配置里填127.0.0.1和27017,左侧展开webpages→gov_publicity,右侧能看到刚插进去的那条文档,字段名和值都在。客户端里刷新不出来的时候,先确认你连的是同一个实例,再看集合名有没有被点号拆走。
| 验证层级 | 检查动作 | 通过标准 | 常见失败点 |
|---|---|---|---|
| 服务端 | mongosh连本机 27017 | 能进交互界面 | 服务没起、bindIp 不符 |
| 驱动 | server_info()["version"] | 打印出版本号 | 端口写错、缺 authSource |
| 单条写入 | 调用一次采集函数 | inserted_id有值 | 集合名带点被拆分 |
| 数据回查 | find_one倒序取最新 | 标题、正文长度正常 | XPath 失效、编码乱码 |
| 客户端 | Robo3T 连同一实例 | 能看到文档和字段 | 连了另一个实例 |
这套采集环境我前后装过不下十次,踩过的坑基本都在这张表里。从那以后我每次装完,不管多急,都强制先走一遍「mongosh → server_info → 单条写入 → find_one 回查」这四步,四步全绿才开始写批量采集。跳过任何一步,后面排查问题的时间都比省下来的多。等你把数据稳定落进库里,再往下游接 python-docx 导出成 docx 归档,就是另一条链路上的事了,前提是这一层先别塌。希望帮到你。
本文还有配套的精品资源,点击获取