资讯动态

Python爬虫数据采集入库MongoDB与Word文档批量导出实战

发布时间:2026/10/29 22:59:42 来源:尧图企业网站定制
简介一份围绕 Python 网页抓取、页面解析与结果文档化整理的学习材料面向已具备基础语法、希望掌握爬虫采集与数据保存流程的初学者和开发者。内容以 requests 发起请求、BeautifulSoup 或 lxml 解析页面为线索结合 MongoDB 中文档、集合、数据库等核心概念讲解如何抽取公示类网页信息、按字段组织数据并写入集合同时给出连接数据库、建库建集合、插入文档与简单查询的代码示例便于理解非关系型存储在 Web 数据管理中的实际用法。资源包共 1 个 docx 文件约 608KB以图文和代码片段组织适合作为从页面解析到持久化保存的参考笔记其中涉及文档键值结构、集合划分与索引等要点可帮助读者梳理爬虫采集、内容整理与结果保存的完整思路。已有 11258 人学习适合需要示例代码、排错参考和项目结构借鉴的读者按需查阅。1. 需求先立住从“抓完就完”到“入库出文档”的闭环做 Python 爬虫的人大多经历过这个阶段脚本跑通数据在终端里刷了一屏然后就没有了。这次的需求很具体——把某政务公示站点上几百条公告抓下来先落进 MongoDB 变成可检索的文档库再按批次导出成 Word 文档交出去存档。抓取只是第一环真正决定这套东西能不能交付的是数据落在哪里、字段怎么定、导出的 Word 能不能直接送去打印。适合看这套方案的人有三类课程设计要交一个“爬虫 数据库 文档导出”完整链路的学生毕业设计需要一个能演示、能答辩的数据采集子系统的人以及手上有零星网页数据、想把它整理成正式文档的从业者。下面按需求、选型、模型、抓取、落库、导出、验收的顺序把这套流程讲解清楚。2. 架构与数据模型三级存储结构 八个字段的记录设计方案整体分三段抓取层负责把列表页翻完、把详情页字段抠出来存储层负责把一条公告变成一个 BSON 文档落进 MongoDB导出层负责按条件把集合里的文档渲染成 Word。三层之间只靠一个约定通信——一条公告就是一个字典键名固定谁也别改。这个约定立住了后面哪一层出问题都好定位。2.1 为什么网页正文适合放文档型数据库公示类页面的字段结构其实很不稳定。有的公告带附件有的不带有的附件是一个 PDF有的是三张图片正文短的只有两行长的能到七八千字。用关系型数据库存得开一张主表放标题日期再开一张附件子表存链接导出的时候还要 join 回来。字段一改就得改表结构改动一次要重新迁移数据。MongoDB 的存储单位是文档一条公告就是一个文档附件是一个数组字段有就三个元素没有就空数组不需要任何表结构变更。这正好对上项目里“字段不固定、数据量中等、查询以单条和按日期范围为主”的场景。反过来说如果这个项目要做多表关联的统计报表、要求跨文档强事务那还是老老实实上关系型文档型数据库在这类需求上并不占便宜。选型没有绝对好坏只有跟需求对不对得上。集合的划分也有讲究。原文把所有公示塞进一个gov_publicity集合这个粒度对单一站点是够的。如果后面要抓多个栏目建议按栏目拆集合比如“通知公告”“政策解读”“结果公示”各一个管理上更清晰查询效率也更好。MongoDB 允许你随便往一个集合里塞结构完全不同的文档但“能塞”不等于“该塞”该分的还得分开。2.2 字段清单与命名约定字段设计是整个方案里最值得反复推敲的部分。综合抓取和导出两头的需求一条公示记录通常定成这八个字段字段名类型是否必有说明urlstring是详情页绝对地址建唯一索引幂等的依据titlestring是公告标题去掉首尾空白sourcestring否发布来源抓不到存空串publish_datestring否统一格式化成 YYYY-MM-DD 字符串contentstring是正文段落之间用 \n 拼接attachmentsarray否附件绝对链接数组无附件存 []fetched_atdatetime是入库时间用 datetime.now() 写入content_hashstring否正文的 md5 前 16 位用来判断页面有没有改过这里有一个容易被忽略的取舍要不要用中文键名。{标题: ..., 公示内容: ...}在 pymongo 里完全合法看起来还更直观。但导出 Word 的时候你要维护一张“字段名→中文小标题”的映射表抓取代码里写中文键、导出代码里写英文变量两边对不上就是一堆 KeyError。我一般统一用英文字段名中文只出现在展示层抓取、存储、导出三段的键名完全一致改一处不用改三处。content_hash这个字段看着多余实际很有用。站点经常对旧公告做小修订如果只按 url 做幂等重跑的时候旧文档会被覆盖但没人知道内容变了。有了哈希导出前跑一次比对把变化过的挑出来单独提醒比事后被人问“这版跟上一版怎么不一样”要从容得多。2.3 环境准备与依赖锁定环境这块没什么玄学照做就行。建虚拟环境、装四个包、把版本冻住python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install requests lxml pymongo python-docx pip freeze requirements.txt # 锁版本换机器重装时不会因为小版本差异翻车四个包各自管一摊requests发 HTTP 请求拿 HTMLlxml负责把 HTML 解析成树并支持 XPathpymongo是 MongoDB 的官方驱动python-docx负责生成 .docx。这里没有用 BeautifulSoup原因不是它不好而是这个站点要抠的字段都在固定的 class 上XPath 一行能定位到底省掉一层 find 链式调用。如果你的目标页面结构比较松、class 名不规整用 BeautifulSoup 配合 CSS 选择器反而顺手两个库不冲突。数据库端本地起一个实例就够默认端口 27017。启动后建议装一个可视化的客户端随便哪款都行主要图一个直观——跑完抓取脚本打开客户端看一眼集合里的文档数和字段是不是齐的比在终端print强太多。数据库命名webpages集合命名gov_publicity这两个名字一旦定下来后面所有代码和导出脚本都引用同一组常量别一边叫webpages一边叫web_page。3. 抓取层实现翻页发现、字段抽取与四进程并发抓取层的任务链条很短先拿到所有详情页的链接再逐个进详情页抠字段最后交给存储层。短归短坑几乎全在这一层。下面按“列表页 → 详情页 → 并发调度”的顺序拆。3.1 列表页翻页与链接补全列表页的翻页规律通常是index.html、index_1.html、index_2.html这样递增。先把全部页码生成出来再逐个请求import requests from lxml import etree from urllib.parse import urljoin HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36 } BASE http://www.example-notice.com/gggs # 列表页根地址换成目标站点 DETAIL_ROOT http://www.example-notice.com/ # 详情页链接补全的基准 def get_links(url, retry3): 请求一个列表页返回该页所有详情页的绝对链接 for i in range(retry): try: resp requests.get(url, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding tree etree.HTML(resp.text) # 列表项固定在 class 为 list-group 的 ul 下 hrefs tree.xpath(//ul[classlist-group]/li/a/href) # href 形如 ./201911/t20191114_489608.html用 urljoin 补成绝对地址 return [urljoin(DETAIL_ROOT, h) for h in hrefs] except Exception: if i retry - 1: return [] time.sleep(1 i) return []这段代码有两处刻意为之。一是urljoin而不是字符串替换。原始写法里用re.sub(^., BASE, href)拼链接正则里的.匹配的是任意字符只要 href 不是以点开头第一个字符就会被吃掉链接立刻 404。这种错误还特别隐蔽因为大部分链接恰好是./开头跑起来看着正常偶尔几条失败就以为是网络抖动。二是加了重试和超时。页面偶尔返回 502 或者连接卡住没有 timeout 的requests.get会一直挂着进程池里四个 worker 一起挂住整个脚本就卡死在那儿。翻页这一层还有个小细节首页是index.html从第 1 页开始才是index_1.html。生成 URL 列表的时候首页单独放循环从 1 开始别写成从 0 开始否则会多请求一个index_0.html返回 404 空页面白白浪费一次请求。3.2 详情页字段抽取与安全取值详情页是重头戏。标题、来源、日期、正文、附件五样东西分布在不同的 DOM 位置上取法各不相同import re, hashlib, time from datetime import datetime def pick(tree, xpath, default): 安全取值XPath 命中就返回第一个结果没命中返回默认值 res tree.xpath(xpath) return res[0].strip() if res else default def parse_detail(url): resp requests.get(url, headersHEADERS, timeout15) resp.encoding resp.apparent_encoding tree etree.HTML(resp.text) title pick(tree, //div[classxxym]/h4/text()) # 来源和日期挤在同一个 h5 里整段拿下来再用正则拆 h5 .join(tree.xpath(//div[classxxym]/h5//text())) m_src re.search(r来源[:]\s*(\S), h5) m_date re.search(r(\d{4})[-/年](\d{1,2})[-/月](\d{1,2}), h5) source m_src.group(1) if m_src else pub_date f{m_date.group(1)}-{int(m_date.group(2)):02d}-{int(m_date.group(3)):02d} if m_date else # 正文可能嵌在 p 里的 span 中用 // 而不是 / 才能抓到嵌套文本 paras tree.xpath(//div[classTRS_Editor]//p//text()) content \n.join(t.strip() for t in paras if t.strip()) # 附件链接是相对路径逐个补全 atts [urljoin(DETAIL_ROOT, a) for a in tree.xpath(//i[classfa fa-caret-right]/a/href)] return { url: url, title: title, source: source, publish_date: pub_date, content: content, attachments: atts, fetched_at: datetime.now(), content_hash: hashlib.md5(content.encode(utf-8)).hexdigest()[:16], }pick这个函数是整个解析层的保险。原始写法里大量xpath(...)[0]只要某条公告缺了日期或没有附件立刻抛 IndexError而且是在子进程里抛父进程只能看到一句“error”连是哪条 url 挂的都不知道成了典型的黑匣子。统一走安全取值 默认值单条数据缺失不会中断整批任务缺哪几项最后统计一下就知道页面结构是不是变了。正文取//p//text()而不是/p/text()是因为很多正文段落里嵌了span做加粗或者标记/p/text()只能拿到 p 的直接子文本节点遇到嵌套就返回空字符串结果就是正文一大片空白标题日期都正常只有内容丢了排查起来非常费劲。日期那段也不用固定的制表符切分直接上\d{4}开头的正则页面加个空格少个空格照样能匹配上。3.3 进程池并发与请求节流几百条详情页串行请求一条一秒跑完十几分钟。用进程池把并发提上来from multiprocessing import Pool def fetch_one(link): 子进程 worker抓一条、存一条、失败只影响这一条 try: doc parse_detail(link) col.update_one({url: doc[url]}, {$set: doc}, upsertTrue) time.sleep(0.1) # 主动降速别把对方站点打疼 return True except Exception as e: print(ffail: {link} - {e}) return False if __name__ __main__: urls [f{BASE}/index.html] [f{BASE}/index_{i}.html for i in range(1, 34)] links [] for u in urls: links.extend(get_links(u)) print(f共发现 {len(links)} 条详情页) with Pool(processes4) as pool: result pool.map(fetch_one, links, chunksize5) print(f成功 {sum(result)} / {len(result)})几个参数值得说清楚。processes4是保守值本机抓取开 4 到 8 个进程足够再往上提升有限反而更容易触发对方站点的频率限制。chunksize5让每个 worker 一次领五条任务减少进程间调度开销链接成千上万时效果明显。with Pool(...)的写法保证任务跑完自动回收不会留一堆僵尸进程尤其是中途 CtrlC 中断的时候。并发带来的副作用要说在前面time.sleep(0.1)不是可选项。四个进程同时发请求对单个站点来说已经是四倍压力如果不降速轻则被限流返回空页面重则封 IP前面抓的全白干。抓取是个需要讲规矩的活控制并发、加延时、只在合理时间内跑是最基本的职业习惯。4. 落库与导出幂等写入 python-docx 排版的两段式流水线前半段解决“数据从哪来”后半段解决“数据怎么变成能交付的东西”。这一章的两个点——写入幂等和文档排版——直接决定这份方案能不能反复跑、导出的文件能不能直接给人。4.1 幂等写入把 insert_one 换成 update_one原始代码用的insert_one跑第一次没问题跑第二次就重复了。几百条公告变成六百条导出 Word 的时候同一篇内容出现两遍人工去重能耗掉一下午。改成基于 url 的“有则更新、无则插入”from pymongo import MongoClient, ASCENDING, UpdateOne from pymongo.errors import BulkWriteError client MongoClient(mongodb://localhost:27017/, maxPoolSize50, connectFalse) col client[webpages][gov_publicity] # 唯一索引是整个幂等机制的地基没有它 update 也可能插重复 col.create_index([(url, ASCENDING)], uniqueTrue) col.create_index([(publish_date, ASCENDING)]) # 导出按日期排序会用到 def save_batch(docs): 批量写入一次网络往返处理 N 条比逐条 update_one 快一个量级 ops [UpdateOne({url: d[url]}, {$set: d}, upsertTrue) for d in docs] try: res col.bulk_write(ops, orderedFalse) # orderedFalse 允许部分失败继续 return res.upserted_count res.modified_count except BulkWriteError as e: print(f部分写入失败{e.details.get(writeErrors)}) return 0upsertTrue的意思是“按条件查查到了就更新查不到就插入”天然幂等。唯一索引是保险万一并发写入时两条同样的 url 同时进来数据库会直接拒掉其中一条而不是产生两份。orderedFalse让批量操作跳过失败项继续执行不会因为一条脏数据把整批回滚。还有一个常见的坑connectFalse这个参数。pymongo 默认在创建 MongoClient 时就连上数据库如果客户端是在父进程创建的、再被 fork 到子进程里用每个子进程会继承同一个 socket 句柄多进程并发操作同一个连接会出各种诡异错误——有时候报连接重置有时候干脆静默丢数据。加connectFalse让连接延迟到第一次实际操作时才建立每个子进程各自连各自的问题就没了。4.2 从集合到 Word中文字体与排版规则导出的核心是python-docx。它生成的是标准 .docxWord、WPS 都能打开也可以直接转 PDF。写法不复杂但中文字体这一块如果不处理导出来的文档全是等线标题正文一个样看着就很不正式from docx import Document from docx.shared import Pt, Cm from docx.oxml.ns import qn def set_cn(run, font宋体, size10.5, boldFalse): python-docx 设置中文字体必须同时设 ascii 和 eastAsia只设 name 没用 run.font.name font run.font.size Pt(size) run.bold bold run._element.rPr.rFonts.set(qn(w:eastAsia), font) def export_doc(docs, out_path, title公示汇编): doc Document() sec doc.sections[0] sec.left_margin sec.right_margin Cm(2.54) # 标准公文页边距 normal doc.styles[Normal] normal.font.name 宋体 normal.font.size Pt(10.5) normal._element.rPr.rFonts.set(qn(w:eastAsia), 宋体) doc.add_heading(title, level0) for i, item in enumerate(docs, 1): h doc.add_heading(level2) # 二级标题样式 set_cn(h.add_run(f{i}. {item.get(title) or 无标题}), 黑体, 14, True) meta f来源{item.get(source) or 未标注} 发布日期{item.get(publish_date) or 未标注} set_cn(doc.add_paragraph().add_run(meta), 楷体, 9) for para in (item.get(content) or ).split(\n): if para.strip(): p doc.add_paragraph() p.paragraph_format.first_line_indent Cm(0.74) # 首行缩进两字符 set_cn(p.add_run(para.strip())) for a in item.get(attachments) or []: p doc.add_paragraph(styleList Bullet) set_cn(p.add_run(a), 宋体, 9) doc.save(out_path)set_cn里那行qn(w:eastAsia)是关键。python-docx 的font.name只设置西文字体中文字符走的是另一套属性两者都设了 Word 里显示的才是宋体。只设一个的话英文数字是宋体、中文变成默认字体一页文档里两种字形混着第一次遇到会以为是编码问题查半天才发现是字体属性。这个坑几乎每个用 python-docx 做中文文档的人都踩过。排版上还有两个细节正文段落首行缩进 0.74 厘米对应五号字的两字符标题用add_heading而不是手动设字号这样 Word 的导航窗格里能直接看到目录树几百页的文件翻起来方便很多。附件用 List Bullet 样式列成项目符号比我手打“-”要规整。4.3 批量导出的目录与命名规范几百条公告塞进一个 Word 会变成上千页打开都卡。按批次拆开每份 50 条用日期范围做文件名import os, re def safe_name(name, limit50): 清掉文件名非法字符并截断防止超出系统路径长度限制 name re.sub(r[\\/:*?|\r\n\t], _, name or untitled).strip(_ ) return name[:limit] or untitled def batch_export(col, out_dir, size50): os.makedirs(out_dir, exist_okTrue) docs, idx [], 1 # 按发布日期倒序最新的排最前 for d in col.find({}, {_id: 0}).sort(publish_date, -1): docs.append(d) if len(docs) size: save_and_clear(docs, out_dir, idx); docs []; idx 1 if docs: save_and_clear(docs, out_dir, idx) def save_and_clear(docs, out_dir, idx): dates sorted(d[publish_date] for d in docs if d.get(publish_date)) span f{dates[0]}_{dates[-1]} if dates else 无日期 path os.path.join(out_dir, f公示汇编_{idx:02d}_{span}.docx) export_doc(docs, path)文件名清理这步别省。公告标题里带书名号、斜杠、冒号是常态直接拿来当文件名Linux 下会创建出一堆子目录Windows 下直接报错。再加上标题可能上百字超过路径长度限制又是一轮报错。统一safe_name处理长度截到 50问题一次性解决。导出目录建议按“年月/批次”两级组织比如export/2024-11/公示汇编_01_2024-11-01_2024-11-15.docx。找文件的时候按时间翻比在一堆杂乱文件名里翻要快得多。5. 避坑与排查五类最容易翻车的现场和对应解法这一章是从实际运行日志里总结出来的。每一条都按“现象 → 原因 → 解决”写遇到了直接对号入座。5.1 抓取侧的三类翻车现象正文抓下来全是乱码中文变成一串问号或者方块。原因requests在没有显式指定编码时会优先按响应头里的 charset 解码很多站点的响应头写的是text/html不带 charsetrequests 就退回到 ISO-8859-1中文自然全乱。这类站点实际用的是 GB2312 或 GBK。 解决请求后立刻resp.encoding resp.apparent_encoding让 chardet 去猜。但apparent_encoding会把整个响应体扫一遍页面大的时候明显变慢更快的做法是只喂前 2048 字节给检测器或者干脆看页面meta charset里写的是什么直接resp.encoding gb2312写死。另外注意etree.HTML(resp.text)用的是已经解码过的字符串如果编码猜错了lxml 也救不回来把resp.content直接传给etree.HTMLlxml 会自己读 meta 标签判断编码反而更稳。现象跑着跑着大批量失败日志里全是 IndexError。原因xpath(...)[0]在列表页改版、某条公告缺字段、或者页面返回了错误页的时候返回空列表取下标直接炸。而且异常发生在子进程里父进程拿不到堆栈只知道失败了。 解决所有取值统一走pick()安全函数给默认值子进程的 worker 用 try/except 包住并打印具体 url 和异常信息跑完之后统计各字段的空值率如果“来源”字段空值率突然从 2% 涨到 60%说明页面结构变了该改 XPath 了。这套东西就是给爬虫装的仪表盘没有它改版了你还以为抓得好好的。现象大部分链接正常零星几条拼出来的地址是错的。原因用正则re.sub(^., BASE, href)补全相对路径正则里的.匹配任意字符只要 href 不是以点开头第一个字符就被替换掉了。 解决把字符串拼接换成urllib.parse.urljoin(DETAIL_ROOT, href)。urljoin 是按 RFC 规范实现的能正确处理./、../、/开头和已经是绝对地址的四种情况比手写正则靠谱一个量级。同理附件链接、翻页链接全部走 urljoin。5.2 存储与导出侧的两类翻车现象并发抓取时抛出连接异常或者数据莫名其妙少了几条。原因MongoClient 在父进程创建被 fork 到子进程后共用同一个连接池。pymongo 的连接对象内部有锁和 socket多进程同时操作会互相踩表现出的错误五花八门——有时是AutoReconnect有时是写入成功但查不到。 解决创建客户端时加connectFalse让连接延迟到真正用的时候才建立这样每个子进程都会自己建一条新连接。如果代码结构允许更彻底的做法是在每个 worker 函数的开头各自实例化一个客户端用完就丢虽然多几次握手但最稳。现象导出的 Word 里中文全是默认字体标题和正文分不出来。原因python-docx 的run.font.name只影响西文字符中文字符走w:eastAsia这个 XML 属性不设它Word 就用主题字体渲染中文。 解决封装一个set_cn()同时设置font.name和rFonts的 eastAsia 属性正文样式styles[Normal]也要同样处理一遍否则没加 run 的空段落还是默认字体。改完拿一份文件双击打开翻到中间几页看字形一致不一致别只看第一页。现象同一个脚本跑两次Word 里同一篇公告出现了两遍。原因写入用的是insert_one没有幂等保护。 解决给 url 建唯一索引写入改成update_one(..., upsertTrue)或bulk_writeUpdateOne(..., upsertTrue)。索引这一步必须做只改写入方式不建索引在并发场景下仍然可能插进重复文档。6. 验收与答辩七个检查点和一套讲述顺序方案跑通和方案能交付是两码事。交付前我一般会照着下面这张表过一遍每项都必须有明确的通过标准不能靠“我看着还行”检查项通过标准怎么验完整性抓取条数与列表页总条数误差小于 1%人工数一遍前 5 页的总数跟数据库 count 对比字段完整率标题、正文、url 缺失率为 0其余字段缺失率低于 5%用聚合查询按字段统计空值数量幂等性连续跑两次集合总数不变记录第一次 count重跑后再 count 一次比对正文质量随机抽 10 条正文无乱码、无 HTML 残留、段落分隔正确打开文档逐条看重点看长正文和带附件的附件可用随机抽 5 个附件链接浏览器能打开直接点别只看字符串非空Word 排版打开后标题层级正常、中文字体统一、页码连续在导航窗格里看目录树翻到中间页看字体可复现换一台机器按 requirements.txt 装完能直接跑真的换一台机器试别凭感觉这七项里最容易被跳过的是“幂等性”和“可复现”。前一项决定了这套脚本能不能当日常工具用后一项决定了交出去的东西别人能不能接手。其余几项属于质量底线抽查十来个样本就能发现问题成本不高。答辩或者汇报的时候讲述顺序比讲了多少更重要。我的习惯是倒着讲先放一份导出的 Word翻到中间一页让人看到一条公告的标题、来源、日期、正文、附件整整齐齐排在那里再回过头讲这条数据是怎么从网页变成文档的——列表页发现链接、详情页 XPath 抽取、字段清洗、upsert入库、按批次渲染。听众先看到结果后面的技术细节才有落点否则一上来就讲进程池和 XPath讲了十分钟对方还不知道这东西长什么样。被问到技术选型时别背概念讲取舍。为什么用 MongoDB 不用 MySQL一句话说清字段不稳定、附件是变长数组、不需要跨表事务。为什么用 lxml 不用 BeautifulSoup一句话目标页面 class 固定XPath 一行定位到底省掉链式调用。为什么开四个进程不开二十个一句话对方站点扛不住被限流了整个任务全废。这种答法比背文档有说服力得多。还有一个常被忽略的点主动交代方案的边界。比如“这套流程只覆盖列表页能翻到的部分如果站点改成无限滚动加载得换抓包方案”“正文里的表格结构会被拍平成纯文本需要保表格的话要单独处理table节点”。提前把这些说清楚比被人当场问住要体面。答辩不是证明自己无所不能是证明自己知道边界在哪。从那以后我每次做这类“抓取 存储 导出”的项目都会在写第一行业务代码之前先把字段表和验收清单列出来跑完之后照着清单逐条打勾缺一项就不算完。这一步看着慢实际省下的是后面反复返工的时间。希望这套流程能帮到你拿去改改字段名和 XPath就是你自己的方案。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑