Python爬虫采集归档:XPath解析、MongoDB入库与Word导出 简介这篇实操文档面向Python爬虫和NoSQL入门者围绕“抓取网页内容并用MongoDB保存”这一典型任务展开适合已经掌握Python基础语法、希望打通请求、解析和存储链路的开发者参考。压缩包内只有1个docx文件约608KB用图文配合代码片段把MongoDB的文档、集合、数据库三层概念与requests请求、lxml解析、pymongo写入等环节逐一对应并以深圳市规划和自然资源局公示页面的爬取过程作为贯穿示例涵盖请求头定制、多页链接获取等细节。文中还说明了驱动的安装、连接字符串写法、数据库与集合是否存在以及用insert_one组织字段插入文档的思路读者可以据此搭建本地数据采集小项目理解文档型数据库如何灵活存储半结构化信息以及如何按字段建立索引来提高检索效率。目前已有11258人学习下载可作为爬虫与MongoDB配合使用的入门读物。1. 手上有一堆页面要搬进库里我第一反应是写个 Python 爬虫上个月接了个私活对方的需求就一句话把某市公示栏目近几年的公告全部存下来要能按日期和标题搜最后导出一份 Word 文档交差。人工复制粘贴三天也弄不完能想到的第一方案就是写个 Python 爬虫——requests 发请求、lxml 走 XPath 提字段、pymongo 直接塞进 MongoDB一份脚本跑完库里天然就是结构化的文档型数据。选 MongoDB 的理由很实在公示页面的字段不稳定今天多一张附表明天多一个来源栏用文档库不用提前建表而那份 .docx用 python-docx 从库里回读再排版就能生成。这份东西适合两类人一类是想跑通「采集 → 入库 → 导出」整条链路的初学者另一类是手里有一批网页要归档、又不想上重型爬虫框架的开发者。下面这段代码你改几个 XPath 就能换成自己的站点。2. 技术选型与工程结构四个依赖撑起采集到导出2.1 为什么是 requests lxml而不是上来就 Scrapy这个项目的量级是几万条记录、单站点、页面结构固定。在这个规模上Scrapy 的调度器、中间件、Item Pipeline 基本用不上反而会让人把时间花在调 settings 上。requests 的定位很单纯它是一个 HTTP 客户端库负责把页面源码拿回来写爬虫和测接口时最常用需要维持会话、复用 TCP 连接的场景用requests.Session()就够了。解析层选 lxml 而不是 BeautifulSoup主要原因是速度和 XPath。lxml 是 libxml2 和 libxslt 两个 C 库的 Python 绑定解析速度比纯 Python 实现的解析器快一截而且直接支持 XPath——这门语言专门用于在 XML/HTML 文档中按路径查找元素和属性写//ul[classlist-group]/li/a/href这种「取某容器下所有链接」的表达式比一层层find_all清爽得多。页面改版时你往往只需要改一行 XPath不用重写整段解析逻辑。存储层用 pymongo官方驱动insert_one、update_one、create_index这些接口和 MongoDB 的 shell 命令一一对应学一套就够。导出层用 python-docx纯 Python 操作 .docx能直接控制标题层级、字体、段落缩进不需要装 Office。这四件套的分工是requests 负责「要到页面」lxml 负责「从页面里挑出字段」pymongo 负责「把字段按文档存起来」python-docx 负责「把文档再变成人能看的文件」。每一层都能单独替换比如哪天想换 Playwright 渲染动态页只动第一层。2.2 目录怎么拆配置、抓取、解析、存储、导出五层分开单文件脚本跑一次没问题但只要你想改站点、加重试、换导出格式单文件就会变成一坨。我一般按职责拆成六个文件入口只负责组装流程# 工程目录换站点时原则上只改 config.py 和 parsers.py crawler-docx/ ├── config.py # 站点地址、翻页规则、XPath 表达式、Mongo 连接串全放这里 ├── fetcher.py # requests.Session 封装UA、超时、重试、编码修正 ├── parsers.py # 列表页取链接、详情页取字段只做纯解析不碰网络也不碰数据库 ├── store.py # pymongo 连接、集合、唯一索引、幂等写入 ├── exporter.py # 按查询条件从库里读写 .docx └── main.py # 入口组链接 → 进程池 → 落库这个拆法的价值在parsers.py它只接收 HTML 字符串返回字典不依赖网络和数据库。意味着你可以把任意一个页面的源码存成文件反复喂给它调试 XPath不用每次都发请求。这是我在调试解析规则时最省时间的一招。config.py单独存在的意义是防止硬编码散落。站点域名、列表页 URL 模板首页index.html加index_{}.html这种格式、每条 XPath、MongoDB 的 host 和端口全部集中。哪天对方说「换个栏目」你改三行字符串就完事。2.3 环境准备与依赖安装Python 版本建议 3.9 以上lxml 和 pymongo 都有现成的 wheel不需要本地编译。依赖清单写死版本号避免半年后重装时因为小版本升级踩到接口变更# requirements.txt —— 版本锁死重装环境时结果可复现 requests2.31.0 lxml4.9.3 pymongo4.6.1 python-docx1.1.0装依赖和起数据库的命令不长但顺序别搞反pip install -r requirements.txt # MongoDB 本地起一个实例默认端口 27017 mongod --dbpath ./data --port 27017 # 可视化客户端可选robo3t / Compass 都行用来肉眼核对写入结果参数说明--dbpath指向数据文件目录第一次跑要保证该目录存在且可写--port保持默认 27017代码里连的就是这个端口改了要同步改config.py。如果你用的是容器或远程实例把连接串写成mongodb://user:passhost:27017/这种完整形式别只写 host。提示装完先跑一句python -c import requests, lxml, pymongo, docx; print(ok)四个库都能导入再往下走能省掉后面一堆「跑起来才报错」的时间。3. 采集链路拆解从列表页到详情页的两级解析3.1 列表页XPath 取 href再用 urljoin 补全相对路径公示类站点的结构高度一致列表页每页放 20 条左右每条是一个li里包着a href./201911/t20191114_489608.html。原始代码里用re.sub(^., http://xxx/xxgk/gggs, str(i))来补全前缀这个写法有两个隐患点号在正则里没有转义.会匹配任意单个字符把链接首字符吃掉而且只替换一个字符遇到../开头的路径就拼歪了。稳妥的做法是用标准库的urljoin。from urllib.parse import urljoin import requests from lxml import etree HEADERS { # 不带 UA 时部分站点会返回 403 或一个内容为空的列表页这是最常见的「抓不到」原因 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) } LIST_XPATH //ul[classlist-group]/li/a/href def make_session(): # Session 复用连接几千次请求下比每次 requests.get 快不少 s requests.Session() s.headers.update(HEADERS) return s def get_links(session, url, base): res session.get(url, timeout10) res.encoding res.apparent_encoding # 先让编码探测器猜猜不准在 3.2 里手动兜底 tree etree.HTML(res.text) hrefs tree.xpath(LIST_XPATH) # urljoin 同时处理 ./a.html、../a.html、/a.html 三种写法 return [urljoin(base, h) for h in hrefs]逻辑说明session.get拿到响应后先修正编码再交给etree.HTML把字符串解析成可查询的树对象——注意etree.HTML有容错能力即使 HTML 标签没闭合也能建树这一点对老站点很重要。tree.xpath返回的是属性值列表元素是字符串。最后的urljoin(base, h)以base为基准解析相对地址base传栏目根地址即可。参数说明timeout10是必须的不设超时的话一旦某个页面卡住整个进程池会被拖死res.apparent_encoding走的是内容推测比res.encoding从响应头读更可靠但也不是百分百准下一节会说怎么兜底。3.2 详情页标题、来源、日期、正文、附件分段提取详情页的字段分布通常是标题在div classxxymh4里来源和日期挤在同一个h5里用空格或制表符分隔正文在div classTRS_Editor下的一堆p里附件链接挂在某个i图标下面的a上。原始代码用re.findall(来源(.*)\n, ...)和re.findall( (.*?)\t, ...)分别切这种写法强依赖分隔符页面里换行符一变成\r\n就抓空。我改成在一段文本里逐条正则匹配鲁棒性高得多。import re from urllib.parse import urljoin from lxml import etree def parse_detail(session, link): res session.get(link, timeout10) res.encoding res.apparent_encoding tree etree.HTML(res.text) # 标题用 or [] 兜底避免列表为空时 [0] 直接抛 IndexError title (tree.xpath(//div[classxxym]/h4/text()) or [])[0].strip() # 来源和日期同在一个 h5 里逐行正则比固定分隔符稳 source, date , for line in tree.xpath(//div[classxxym]/h5/text()): m re.search(r来源(.), line) if m: source m.group(1).strip() m re.search(r(\d{4}-\d{2}-\d{2}), line) if m: date m.group(1) # 正文用 //text() 兜底改版后段落可能包在 div 或 br 里不只是 p body \n.join( t.strip() for t in tree.xpath(//div[classTRS_Editor]//text()) if t.strip() ) # 附件相对路径要按详情页 URL 补全不是按栏目根地址 attach tree.xpath(//i[classfa fa-caret-right]/a/href) appendix urljoin(link, attach[0]) if attach else 无 return {标题: title, 来源: source, 日期: date, 公示内容: body, 附件链接: appendix}逻辑说明三个坑都在这段代码里堵上了。第一xpath(...)返回列表取[0]前必须判空否则一条结构异常的页面就能让整个进程崩掉第二来源和日期用正则匹配整行文本不依赖换行符和制表符的具体形式第三正文取的是//text()而不是/p/text()前者会把所有后代文本节点全捞出来页面从p改成div也不会丢内容。参数说明urljoin(link, attach[0])的第一个参数必须是详情页地址因为附件链接是相对于详情页写的./P020191114.docx传栏目根地址会拼出 404。if t.strip()过滤掉纯空白节点否则正文里会插进大量空行导出 Word 的时候很难看。3.3 进程池并发Pool.map 的用法和它的静默失败原始代码用Pool(processes4)加pool.map(page_scratch, links)思路是对的map会把第二个参数里的元素逐个传给第一个参数的函数分派到进程池里执行用来加速 IO 密集型任务很合适。但有两点必须改子进程里的异常不会传回主进程抓失败的链接你根本不知道每个子进程要自己建 Session不能共享父进程的对象。import time from multiprocessing import Pool def page_scratch(link): # 每个子进程独立建 session父进程的对象不能跨进程共享 session make_session() try: doc parse_detail(session, link) save_one(doc) # 落库放在子进程里省一次结果回传的内存开销 except Exception as e: # 子进程里的异常不会冒泡到主进程必须自己记盘 with open(failed.log, a, encodingutf-8) as f: f.write(f{link}\t{type(e).__name__}: {e}\n) finally: time.sleep(0.1) # 单进程限速4 进程约等于 40 请求/秒 if __name__ __main__: base http://gg.example-site.test urls [f{base}/xxgk/gggs/index.html] urls [f{base}/xxgk/gggs/index_{i}.html for i in range(1, 34)] session make_session() links [] for u in urls: links.extend(get_links(session, u, base)) print(待抓链接数, len(links)) with Pool(processes4) as pool: pool.map(page_scratch, links)逻辑说明if __name__ __main__:这个判断在 Windows 上是必须的缺了它会因为子进程重新导入主模块而无限递归创建进程。with Pool(...) as pool比裸Pool()好在会把进程正常回收不会留下僵尸进程。pool.map是阻塞的所有任务跑完才返回所以你可以在它后面直接写导出逻辑。参数说明processes4是经验值本机四核就跑 4机器性能一般就降到 2不是越大越好目标站点限流时开 8 个进程只会让封禁来得更快。time.sleep(0.1)是给每个进程单独限速实际并发速率约等于 4 × 10 40 次/秒。如果对方站点有明确的反爬阈值把这里调大或者干脆改成单进程。failed.log里记录的是链接和异常类型跑完 review 一遍比盲目重跑强。4. MongoDB 落库文档型存储到底省了哪些事4.1 连接、库与集合的惰性创建MongoDB 一个很反直觉的特性数据库和集合都不是「创建」出来的而是第一次写入时才真正落地。你myclient[webpages]拿到的只是一个句柄mydb[gov_publicity]同理直到第一条insert_one成功磁盘上才有对应文件。所以原始代码里list_database_names()那两段判断打印实际运行时经常是「不存在」——因为还没写东西。import time from pymongo import MongoClient, ASCENDING # serverSelectionTimeoutMS 要设小默认 30 秒连不上时会让脚本干等半分钟 client MongoClient(mongodb://127.0.0.1:27017/, serverSelectionTimeoutMS3000) db client[webpages] col db[gov_publicity] # 集合名用下划线别用点号见 4.3 的解释 # 唯一索引把「标题 日期」当业务主键配合 upsert 实现幂等重跑 col.create_index([(标题, ASCENDING), (日期, ASCENDING)], uniqueTrue) col.create_index([(日期, ASCENDING)])逻辑说明create_index是幂等的重复执行不会报错所以可以放在模块加载时直接调用。第一个索引是唯一复合索引作用是拦住重复文档第二个是普通索引作用是让「按日期范围查」的find走索引而不是全表扫描。参数说明serverSelectionTimeoutMS3000只影响「找不到可用节点」的等待时间不限制单条命令执行时长。连接串里如果你用localhost解析到 IPv6 而 Mongo 只监听了 IPv4也会连不上所以这里写127.0.0.1更保险。4.2 一条公示存成什么结构字段设计与嵌套MongoDB 的文档是键值对的有序集合值可以是字符串、数字、布尔也可以是嵌套的文档或数组。这正好对上网页这种「结构不稳定」的数据源——多一个字段就多一个键不需要ALTER TABLE。这个项目里我存的结构是这样字段类型说明备注标题string公示标题与日期组成唯一索引来源string发布单位可能为空串日期string格式统一为YYYY-MM-DD统一格式才能比较排序公示内容string正文段落用\n分隔保留换行导出时按行切段附件链接string完整 URL无附件时存无用字符串而非 null便于判断created_atstring首次入库时间只在插入时写这里有个设计取舍值得说公示内容存成一个带换行的长字符串而不是拆成段落数组。原因是导出 Word 时需要按段落还原split(\n)一行就够如果拆成数组查询时想按关键词模糊匹配正文反而麻烦还得用$elemMatch。注意日期字段千万别存成2019年11月14日这种中文格式。存字符串就要存成可比较的YYYY-MM-DD否则$gte/$lte的字典序比较结果会出错。要中文展示导出时格式化。4.3 去重写入把 insert_one 换成 update_one upsert原始的insert_one有个致命问题脚本重跑一次库里就多一份重复数据。爬虫这种东西几乎不可能一次跑通重跑是常态所以写入必须幂等。update_one带upsertTrue就是标准答案按查询条件找找到就更新找不到就插入。def save_one(doc): col.update_one( {标题: doc[标题], 日期: doc[日期]}, # 业务主键 { $set: doc, # 每次覆盖字段 $setOnInsert: {created_at: time.strftime(%Y-%m-%d %H:%M:%S)} }, upsertTrue )逻辑说明查询条件用的正是唯一索引的两个字段所以这次匹配走的是索引不会全表扫。$set会把doc里所有字段写进去页面内容更新后重跑能自动同步。$setOnInsert只在插入时生效更新时不动这样created_at就真的是「首次入库时间」不会被重跑刷掉。参数说明upsertTrue是「没有就创建」的开关漏了这个参数update_one在匹配不到时什么也不做你会以为代码跑了但库里是空的。另外集合名这里用的是gov_publicity而不是gov.publicity因为集合名里带点号在 shell 里要靠getCollection特殊写法访问容易踩坑能用下划线就用下划线。4.4 用可视化客户端核对数据写完不要急着写导出先把 robo3t 或者 Compass 连上 27017展开webpages→gov_publicity看着文档一条条列出来。我一般核对三件事条数对不对跟列表页总数对比、日期是不是都是标准格式、附件链接里存无的比例是否合理。这三项对了后面的导出才有意义。如果客户端里看不到库八成是压根没写进去——回去看failed.log或者确认save_one里有没有忘加upsertTrue。5. 避坑与排查抓取、编码、入库三道关的翻车现场5.1 链接拼完是 404或者拼出一个缺字符的地址现象列表页 XPath 明明取到了 20 个 href补全后逐个访问全是 404或者域名后面少了一个字符。原因原始写法re.sub(^., http://..., str(i))里的.在正则里表示「任意单个字符」它匹配并替换掉了链接的第一个字符。如果 href 是./201911/x.html替换后变成http://...201911/x.html中间的斜杠没了。解决换成urljoin(base, href)标准库会把相对路径按 RFC 3986 解析./、../、/三种形式都能正确处理。这类字符串拼接的活儿永远优先找标准库别自己写正则。5.2 中文全变成乱码或者 XPath 一条都取不到现象入库的标题是一串深圳或者res.text打印出来是空的XPath 返回空列表。原因res.apparent_encoding靠内容推测编码短页面或者以 ASCII 为主的页面会被猜成ascii或ISO-8859-1中文就废了。另一个可能是站点响应头里写的编码是错的。解决优先用res.content交给etree.HTML自己嗅探lxml 会读meta charset不行就手动指定比如老站点常是res.encoding gb2312实际内容多为 GBK写gbk更稳。判断方法很简单print(res.apparent_encoding)打出来看一眼不是 utf-8 或 gbk 就直接手动改。5.3 正文抓到了但只有第一段现象公示内容字段里只有一句话页面上明明有七八段。原因XPath 写成了//div[classTRS_Editor]/p/text()只取直接子级的p。实际页面里段落可能包在div里或者用br分隔甚至同一段文字被span包着加粗。解决改成//div[classTRS_Editor]//text()双斜杠取所有后代文本节点再用.join(...) if t.strip()过滤空白。代价是可能混入一些无关文本比如版权声明所以取完最好打印前 200 字人眼过一次。5.4 进程池跑完日志说成功但库里少了几百条现象pool.map正常返回failed.log是空的库里条数却明显少于链接数。原因子进程抛出的异常不会传播到主进程multiprocessing的实现里子进程崩溃时map只是不再返回结果主进程毫无感知。原始的裸try/except: print(error)更是把异常类型和信息全丢了。解决在子进程函数内部try/except把链接和异常类型写进文件主进程跑完后统计len(links)和库里实际条数差值就是失败数对着日志逐条重跑。别指望map帮你报错。5.5 重跑一次库里的数据翻倍现象第一次跑完 800 条第二天重跑变成 1600 条标题重复出现两遍。原因用了insert_one它无条件插入新文档不做任何判重。解决换成update_one(..., upsertTrue)并且在标题 日期上建唯一索引。唯一索引的作用是双保险——万一 upsert 的条件写漏了重复插入会直接报DuplicateKeyError比静默多出一倍数据好排查得多。加索引前记得先手动清理历史重复数据否则建索引会失败。6. 二次开发把库里的一次查询变成一份能交差的 Word6.1 从 Mongo 读数据用 python-docx 排版采集只是前半程标题里说的「导出为 word 文档」才是交付物。python-docx 的常用接口就四个Document()建文档、add_heading加标题、add_paragraph加段落、save存盘。配合col.find(query).sort(日期, 1)按时间顺序把记录一条条铺进去就行。from docx import Document from docx.shared import Pt from docx.enum.text import WD_ALIGN_PARAGRAPH def export_docx(query, out_path): doc Document() # 全局正文样式中文用宋体小四10.5pt这是公文最常见的排版 doc.styles[Normal].font.name 宋体 doc.styles[Normal].font.size Pt(10.5) for item in col.find(query).sort(日期, 1): doc.add_heading(item.get(标题, 无标题), level2) meta doc.add_paragraph(f来源{item.get(来源, )} 日期{item.get(日期, )}) meta.alignment WD_ALIGN_PARAGRAPH.LEFT for line in (item.get(公示内容) or ).split(\n): if line.strip(): doc.add_paragraph(line.strip()) attach item.get(附件链接) if attach and attach ! 无: doc.add_paragraph(f附件{attach}) doc.save(out_path) # 用法导出 2019 年全年的公示 export_docx({日期: {$gte: 2019-01-01, $lte: 2019-12-31}}, 2019年度公示汇总.docx)逻辑说明find(query)返回游标.sort(日期, 1)是升序游标是惰性的数据量大也不会一次性全读进内存。每条的标题用level2的 HeadingWord 里会自动生成导航目录对方翻起来方便。正文按\n切段一段一个add_paragraph比把整坨文字塞进一个段落好看得多。参数说明Pt(10.5)是「小四」公文正文标准字号doc.styles[Normal].font.name设置中文字体时在部分 Word 版本里只改name不生效稳妥做法是同时对rPr.rFonts设置w:eastAsia如果你的文档打开后字体没变就是这里的问题。query用 MongoDB 的查询语法日期范围就是$gte/$lte。6.2 改造前的验证清单改代码之前我会先把这几项手工验一遍成本低但能省掉大量返工验证项怎么验不通过说明什么链接数列表页显示「共 660 条」脚本打印的链接数应接近XPath 漏了某种结构字段完整率col.count_documents({标题: })应为 0某个 XPath 匹配不到日期格式col.count_documents({日期: {$regex: ^\\d{4}-\\d{2}-\\d{2}$}})应等于总数正则提取有问题重跑幂等同一条命令跑两次count_documents不变upsert 或唯一索引没生效导出完整性生成 docx 的记录数 查询结果数循环里漏了分支这套检查我在两个不同站点上复用过改的只是 XPath 和正则流程完全一样。6.3 我的教训第一次跑这个项目时我没加唯一索引就直接上了 4 进程池中途因为网络抖动断了一次我顺手重跑了一遍——结果库里每一条公示都存了两份日期还因为一次正则改动出现了两种格式导出 Word 之后自己看着都难受。从那以后我每次写爬虫不管数据量多小都是先把唯一索引和 upsert 写上再跑第一次采集宁可前面多花十分钟也不想后面清库清一下午。你要是正准备把手上那堆页面搬进库里先照着第 5 章那五条过一遍能少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取