简介这是一份面向Python初学者的爬虫与数据存储实战教程围绕网页抓取、MongoDB文档数据库应用及Word文档导出展开适合需要快速掌握requests、BeautifulSoup、pymongo等库联动使用的学习者。资源以单个docx文档形式提供全文约608KB内含完整可运行的代码示例与环境配置说明覆盖从发送HTTP请求、解析HTML源码到连接MongoDB并插入文档的完整流程同时结合深圳市规划和自然资源局公示页面等真实案例帮助读者理解文档型数据库与传统关系数据库的区别以及如何利用MongoDB的灵活结构存储非结构化网页数据并附带将爬取结果整理导出为Word文档的操作思路。此外文档中还补充了进程池、请求头设置、正则表达式提取等实用技巧便于读者直接迁移到自己的项目中。已有11250人学习下载作为爬虫入门与MongoDB实践结合的参考资料具有较强的操作性。1. 用 Python 爬网页导出 Word难点不在爬在把 HTML 映射成文档结构用Python爬取网页并导出为word文档.docx很多人第一反应是抓HTML、存文本、改后缀名。真这么做Word打开就是一堆带尖括号的源码。难点不在网络请求而在两种格式的结构不对等网页靠div和span划分区块Word靠段落、表格、样式组织内容。把URL变成docx真正花时间的是建立HTML到Word节点的映射规则再处理编码、反爬、图片防盗链这些边缘问题。这篇文章给出一套可直接跑通的最小实现从requests抓取、BeautifulSoup清洗到python-docx写入完整走一遍并针对高频翻车点给排查思路。适合刚接触爬虫的数据分析新手也适合需要批量把网页归档成可编辑文档的运营和文档处理人员。2. 抓取与解析的最小闭环requests BeautifulSoup 选型与首版脚本2.1 选型为什么这套组合是默认答案面对“网页转Word”这个需求选型的关键不是功能多少而是链路短、出问题好排查。requests负责HTTP层BeautifulSoup负责HTML解析python-docx负责文档生成三者的分工非常干净。相比scrapy它的学习成本和环境复杂度低很多不需要建工程、配管道一个.py文件就能跑完相比selenium它不启动浏览器几十个页面的批量归档场景下速度和资源占用优势明显。需要说明的是如果目标页面大量依赖JavaScript渲染requests拿到的只是空壳HTML这时才需要引入selenium或playwright。判断标准很简单用浏览器“查看源代码”看到的正文和你用requests抓到的内容是否一致。不一致就得走渲染方案这个我在第5章会专门讲。解析器方面我一般选lxml比BeautifulSoup自带的html.parser快一个量级对残缺标签的容错也更好。安装时容易踩一个坑lxml在某些Python版本上需要预编译包Windows用户直接pip install lxml基本没问题macOS偶尔会编译失败这时换成pip install lxml --only-binary :all:强制拉二进制版本就好。2.2 最小爬虫脚本从 URL 到结构化正文先给一个能直接跑通的最小脚本。这个版本只做两件事拿到页面内容解析出标题和正文容器。import requests from bs4 import BeautifulSoup def fetch_html(url, timeout10): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } resp requests.get(url, headersheaders, timeouttimeout) resp.raise_for_status() return resp def parse_document(resp): soup BeautifulSoup(resp.content, lxml) title_tag soup.find(h1) title title_tag.get_text(stripTrue) if title_tag else 未命名文档 content ( soup.find(article) or soup.find(div, class_content) or soup.find(div, idcontent) or soup.find(main) ) return title, content or soup.body url https://example.com/docs/guide resp fetch_html(url) title, content parse_document(resp) print(title)逻辑说明在requests.get这里headers里的User-Agent是必须的很多站点对默认的python-requests标识直接返回403带上常见浏览器UA能过掉第一层拦截。timeout参数同时约束连接和读取防止某个页面卡死把整个批量任务拖崩生产环境我会按页面大小调成10到30秒。resp.raise_for_status()会在状态码为4xx或5xx时直接抛异常比手动判断resp.status_code更省事。解析部分有一个细节值得记住soup.find(h1)只返回第一个h1如果页面里有多级标题这个逻辑只会取到最顶层。正文容器的查找用了or链从上到下依次尝试article标签、class为content的div、id为content的div、main标签这是国内内容站比较常见的几种结构。如果全都没命中最后退回soup.body宁可用整个body也不能让脚本崩溃。2.3 编码乱码别迷信 response.text用二进制自己解码爬虫新手最容易在这里翻车页面显示正常但resp.text里中文变成“锟斤拷”或者一堆问号。原因在于requests的resp.text会按照resp.encoding猜测编码而很多服务器响应头里没写charsetrequests就默认用ISO-8859-1去解码中文自然全乱。解决办法是绕过text直接用resp.content拿原始字节再手动解码。常见的中文网页编码就三种UTF-8、GB18030、GBK按优先级依次尝试即可。顺手还能装一个chardet做兜底它通过字节特征推断编码准确率对常见站点足够。在2.2的parse_document里改一行就能避开这个坑。上面代码直接传resp.content给BeautifulSoup就是因为BeautifulSoup内部会从meta标签重新探测编码比text可靠得多。如果你需要在解析前就把文本拿出来做正则匹配我一般这么写import chardet def decode_content(resp): if resp.encoding and resp.encoding.lower() in (utf-8, gbk, gb18030): return resp.content.decode(resp.encoding, errorsreplace) detected chardet.detect(resp.content) return resp.content.decode(detected[encoding] or utf-8, errorsreplace)参数说明errorsreplace很关键它把无法解码的字节替换成“”而不是抛异常。批量爬取时我宁可看到个别替换符也不想整个任务因为一篇文章里的冷门字符中断。3. 把 HTML 洗干净正文提取、标签清除与列表表格结构保留3.1 正文提取语义标签优先文本密度兜底第2章的content查找逻辑只处理了四种常见结构真实页面比这复杂。有的站点把正文放在多层嵌套的div里有的用classarticle-content有的干脆没有article标签。纯靠预定义规则容易漏。一个更鲁棒的思路是先按语义标签找找不到就按文本密度判断。文本密度的直觉很朴素正文区域的文本量远大于导航、侧栏和页脚。对每个可疑的div统计它直接包含的文本长度和子节点数量剔除掉文本少的节点剩下的就是正文。这里给一个简化实现适合做兜底def extract_main_content(soup): candidates soup.find_all(div) best None best_score 0 for div in candidates: text_len len(div.get_text(, stripTrue)) tag_count len(div.find_all([p, br])) score text_len tag_count * 50 if score best_score and text_len 200: best_score score best div return best or soup.body评分逻辑里给p和br标签加了50的权重是因为正文区段落多而导航栏即使文本不少段落标签也很少。text_len 200这个阈值过滤掉大部分碎片区块。这个算法不完美但作为语义标签查找失败后的兜底足够用。我实际做项目时会把两个策略串起来先跑语义标签命中就直接用没命中再用密度评分。不要在同一个函数里混着写否则出问题时很难判断是哪个分支拿错了容器。3.2 清洗script、style、空节点一次清干净拿到正文容器后里面通常混着script、style、noscript、iframe、svg这些不该进Word的节点。它们的特征很统一不是给读者看的内容而是给浏览器或脚本用的。用一个循环全部拆掉然后统一提取文本这是整个清洗流程的核心动作for tag in content.find_all([script, style, noscript, iframe, svg, form]): tag.decompose() cleaned_text content.get_text(separator\n, stripTrue)decompose()和extract()的区别值得说一句extract()会把节点从文档树中移除但保留引用decompose()直接销毁节点并释放内存。批量爬取时decompose()能避免内存里的死对象越积越多。get_text的两个参数是清洗的重点。separator\n让不同标签之间的文本用换行分隔避免标题和段落、段落和段落粘在一起stripTrue去掉每段首尾空白。如果漏掉separator参数div里的p和p之间不会自动换行导出后就是一大坨文字。这里提取的是纯文本原本加粗、斜体的行内样式会丢失想保留富文本格式就得走节点级遍历这个在第4章展开。3.3 列表与表格结构化数据不能丢很多页面正文里带ul、ol、table如果统一get_text表格的所有单元格会糊成一行列表的编号也消失。要导出可编辑的Word必须把这些结构单独拆出来。我的做法是遍历正文容器的直接子节点按标签类型分派到不同处理函数def parse_content_blocks(content): blocks [] for child in content.children: if not getattr(child, name, None): continue if child.name in (h1, h2, h3, h4, h5, h6): blocks.append({type: heading, level: int(child.name[1]), text: child.get_text( , stripTrue)}) elif child.name in (ul, ol): items [li.get_text( , stripTrue) for li in child.find_all(li, recursiveFalse)] blocks.append({type: list, ordered: child.name ol, items: items}) elif child.name table: rows [] for tr in child.find_all(tr): cells [td.get_text( , stripTrue) for td in tr.find_all([td, th])] rows.append(cells) blocks.append({type: table, rows: rows}) elif child.name p: blocks.append({type: paragraph, text: child.get_text( , stripTrue)}) return blocks这个解析器的设计思路是“只处理直接子节点”避免嵌套列表和表格里的内容被二次扫描。注意li.find_all(li, recursiveFalse)里的recursiveFalse它只找当前li的直接子li这样嵌套列表能保留层级而不会把子列表项和父列表项混在一起。处理表格时tr.find_all([td, th])会按行内顺序取单元格但没处理rowspan和colspan合并的情况。真实页面里合并单元格很常见python-docx的add_table又不直接支持合并我的方案是合并的单元格在后续行里会缺失遍历时补一个空字符串占位。这样导出的表格行列对齐内容位置不错位代价是合并效果变成普通空单元格属于可接受的降级。4. python-docx 导出对象模型、样式参数与图片落地的完整写法4.1 python-docx 三个核心对象Document、Paragraph、Runpython-docx的文档模型和Word的docx格式严格对应。Document是整份文档Paragraph是段落Run是段落内拥有相同格式的文本片段。很多人第一次写时直接在Paragraph上设置字体发现没效果因为字体样式实际作用在Run上。记住这个层级关系后面所有样式问题都好理解。还有一个容易忽略的对象是styles它存放文档级样式定义。python-docx内置了Heading 1到Heading 9、List Bullet、List Number、Normal等常用样式名add_heading和add_paragraph都能直接引用。实际效果取决于这些样式有没有绑定中文字体默认的主题字体对中文支持不好4.3节会讲怎么改。from docx import Document from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.shared import Pt doc Document() para doc.add_paragraph(styleList Bullet) run para.add_run(这是一个列表项) run.font.size Pt(12) para.alignment WD_ALIGN_PARAGRAPH.LEFT上面这段是python-docx最小用法。add_paragraph只是创建空段落文字要add_run进去run才是字体属性的作用对象。alignment在Paragraph上设置控制的是整段对齐。初学者最容易搞混的点就在这里字体属性找Run对齐和行距找Paragraph。4.2 从解析结果写 Word标题、段落、列表、表格的完整函数把第3章的parse_content_blocks和python-docx对接是整个方案的核心代码。这里我给出完整写法照着复制就能用from docx.shared import Pt, Inches, Cm def write_blocks_to_docx(doc, blocks): for block in blocks: if block[type] heading: level min(block[level], 4) doc.add_heading(block[text], levellevel) elif block[type] paragraph: doc.add_paragraph(block[text]) elif block[type] list: style List Number if block[ordered] else List Bullet for item in block[items]: doc.add_paragraph(item, stylestyle) elif block[type] table: rows block[rows] if not rows: continue cols max(len(row) for row in rows) table doc.add_table(rowslen(rows), colscols) table.style Table Grid for i, row in enumerate(rows): for j, cell_text in enumerate(row): table.cell(i, j).text cell_text几个参数需要解释。add_heading的level最大只能取9但网页里h4之后的标题在Word里很少需要缩成更高级别硬塞反而会让文档结构层级过深所以我用min(level, 4)把h4及以下统一压到四级标题。add_paragraph的style参数不传时用默认正文样式传List Bullet就是圆点列表、List Number就是自动编号列表编号由Word自动生成比手动写死数字强。table.styleTable Grid是带边框线的表格样式如果不设置导出后表格看不到框线打印和阅读都很费劲。这个函数有一个隐含假设blocks已经是清洗后且按文档顺序排列的列表。如果解析器遍历顺序不对导出的内容顺序就会错乱。第3章的parse_content_blocks直接遍历content.childrenHTML文档顺序在BS4里是保序的所以这个假设通常成立。4.3 样式参数字体、字号、行距与对齐一次设对默认的docx正文样式是Calibri 11磅中文字符在Word里会被自动回退到宋体但回退结果在不同机器上不一致。要让输出稳定必须显式设置中文字体。问题在于python-docx的font.name只设置西文字体中文字体要另外写XML属性from docx.oxml.ns import qn def set_font_style(doc, font_name微软雅黑, size11): style doc.styles[Normal] style.font.name font_name style.font.size Pt(size) style._element.rPr.rFonts.set(qn(w:eastAsia), font_name)set(qn(w:eastAsia), font_name)这一行是处理中文的关键它直接操作底层XML的rFonts节点。如果不写这句中文字体设置无效Word打开后中文仍用默认字体。字号单位用Pt从docx.shared导入12磅对应小四10.5磅对应五号国内Word用户习惯用中文字号但python-docx只认磅和厘米导出后可以在Word里再调。标题样式的字体也建议统一改一遍否则标题和正文字体不一致视觉上很割裂for i in range(1, 5): style doc.styles[fHeading {i}] style.font.name font_name style._element.rPr.rFonts.set(qn(w:eastAsia), font_name)注意样式名是Heading 1这种带空格的字符串不是Title或H1。行距可以通过paragraph_format.line_spacing设置多倍行距传浮点数固定值传Pt对象。我在实际批量导出时一般设1.5倍行距阅读体验比默认的单倍行距舒服也不像2倍那么费纸。4.4 图片导出相对路径、防盗链与内存流网页图片要进Word涉及三个坑src是相对路径、图片服务器校验Referer、下载失败导致整个程序崩掉。完整处理代码如下import io import requests from urllib.parse import urljoin def add_images_to_doc(doc, content, page_url, headers): for img in content.find_all(img): src img.get(src) or img.get(data-src) if not src: continue absolute_url urljoin(page_url, src) try: img_headers {**headers, Referer: page_url} img_resp requests.get(absolute_url, headersimg_headers, timeout15) if img_resp.status_code ! 200: continue image_stream io.BytesIO(img_resp.content) doc.add_picture(image_stream, widthInches(5.5)) doc.add_paragraph() # 图片后加空行避免和下一段文字贴在一起 except Exception: continueurljoin(page_url, src)是第一个关键点它把“/images/a.jpg”这类相对路径拼成完整URL否则requests会直接抛异常。img.get(data-src)是第二个关键点很多懒加载站点把真实地址放在data-src里src只是一个占位图只读src会导出一堆空白。Referer头在下载时很重要部分图片服务器会检查来源不带Referer返回403带上页面URL就正常。doc.add_picture接收文件路径或文件对象这里用io.BytesIO直接把内存中的图片字节喂进去不落盘临时文件。图片宽度统一设为5.5英寸适合A4纸默认页边距太宽会被Word裁剪。单张图片下载失败用try-except跳过绝不因为一张图让整个导出任务白跑。如果页面图片很多建议改成异步下载或加个简单的并发池但单篇文章场景同步就够用。5. 爬虫导出 Word 的高频翻车现场5 个问题与排查顺序第5章前先说明一个经验爬虫导出Word这类任务代码写出来只是开始真正耗时间的是各种边缘情况。下面5个问题是我在批量抓取归档时反复遇到的按出现频率排序。每条都给出现象、原因和解决路径。1. 拿不到正文requests抓到的HTML里没有内容现象是页面在浏览器里显示正常但爬虫解析后正文为空或者只有几个空div。原因是页面使用前端框架渲染正文由JavaScript动态生成requests拿到的只是初始HTML骨架。解决思路有两个一是看页面是否有接口直接返回数据很多站点首屏数据是JSON接口吐出来的用浏览器开发者工具的网络面板找XHR请求直接请求那个接口拿数据比渲染页面更轻量二是用playwright或selenium先渲染再交给BeautifulSoup代价是速度和资源。判断用哪种方式的标准看JSON接口的数据是否完整完整就优先用接口否则走渲染。2. 状态码200但全是跳转代码反爬校验现象是resp.status_code等于200但响应内容是一段JavaScript跳转或验证页面根本解析不到正文。原因通常是站点有反爬策略对有自动化特征的请求返回一个假页面。排查顺序先打印resp.text的前200个字符看到window.location.href或“验证”字样就是被拦了。解决方法是补全请求头尤其是User-Agent、Accept、Accept-Language必要时带上Cookie。Cookie可以从浏览器开发者工具里复制也可以在脚本里先用requests登录拿到会话。这一步没有通用解法遇到阻拦就只能具体问题具体分析。3. 导出的Word打不开文件损坏现象是代码跑完没有报错但Word打开时提示“文件已损坏是否尝试修复”。原因一般是写入的文本里包含XML非法字符比如\x00-\x08、\x0b、\x0c这类控制字符。docx本质是XML格式XML规范不允许这些字符出现。解决写入前统一过滤一遍。过滤函数很简单import re ILLEGAL_XML_CHARS re.compile( r[\x00-\x08\x0b\x0c\x0e-\x1f\x7f] ) def sanitize_for_docx(text): return ILLEGAL_XML_CHARS.sub(, text)这个过滤函数要放在write_blocks_to_docx的入口处确保所有文本经过清洗再写入。容易漏的是表格单元格和列表项它们在HTML解析时没有走统一出口。4. 图片显示为空白防盗链和懒加载双重问题现象是脚本下载图片成功但Word里图片位置却是空白或者只有一张占位小图。原因按前面4.4节的顺序排查先确认src有没有值很多懒加载页面src为空、真实地址在data-src再确认下载时有没有带Referer不带Referer可能返回一张“图片不存在”的占位图状态码还是200最后确认下载下来的内容长度如果只有几百字节那多半不是真正的图片。5. 批量抓取时内存持续上涨解析结果没有释放现象是抓几十个页面没问题抓几百个页面后程序越跑越慢最后卡死。原因是每次循环生成的BeautifulSoup对象和docx Document对象没有被及时回收。解决方法是把“抓取-解析-写入”拆成两个阶段先抓HTML存成临时文件再逐个解析写入同一个docx处理完一个页面就del掉soup对象。如果页面非常大字典缓存全部清掉Python的引用计数在4.x版本下能退回大部分内存。另一个相关参数是requests的Session复用批量抓取时用requests.Session()保持连接池比每次新建连接快不少也稳不少。6. 进阶把脚本改成带参数的命令行小工具并验证导出的 docx前面的代码都是函数级写法适合在编辑器里调试。真正要落地给团队用可以套一层argparse做成命令行工具接收URL、输出路径和图片开关。这样不同页面只需要改命令参数不用改代码。核心入口大概是这样import argparse def main(): parser argparse.ArgumentParser(description网页归档为Word文档) parser.add_argument(url, help目标网页URL) parser.add_argument(-o, --output, defaultoutput.docx, help导出docx路径) parser.add_argument(--no-images, actionstore_true, help跳过图片下载) parser.add_argument(--timeout, typeint, default10, help请求超时秒数) args parser.parse_args() # 主流程fetch - parse - clean - write - save参数设计上url是位置参数必须传-o是可选参数默认输出到当前目录的output.docx。--no-images用store_true传了就是关闭图片适合只需要文字的场景。timeout给默认值10秒大多数内容站10秒足够。验证导出结果建议写一个独立的回读脚本不依赖原流程用python-docx把docx读出来检查段落数、表格数和是否乱码from docx import Document doc Document(output.docx) print(段落数:, len(doc.paragraphs)) print(表格数:, len(doc.tables)) for para in doc.paragraphs[:10]: if para.text.strip(): print(para.text[:60])这段验证代码的价值在于它能发现导出前就存在的结构问题。比如段落数明显少于预期多半是正文容器没找对拿到的只有导航栏表格数为0但源页面有表格说明parse_content_blocks里的table分支没被触发。我自己做批量归档时会先跑验证脚本抽查3到5个文件确认结构稳定后再放开全量跑免得几百个文件生成完才发现全部缺表格。另一个值得养成的习惯是增量验证抓完一个页面先不急着写Word打印出parse_content_blocks的统计信息比如“heading: 3, paragraph: 25, list: 1, table: 1”和肉眼看到的页面结构对一下。这一眼能确认解析链路没有跑偏比最后导出再发现问题省事得多。这个习惯是我前几次批量翻车后养成的当时就是跳过中间检查导出50个docx才发现正文容器全选成了侧栏希望帮到你。本文还有配套的精品资源点击获取