Python爬虫抓取解析与MongoDB文档落库及Word导出

发布时间:2026/10/29 14:36:24
Python爬虫抓取解析与MongoDB文档落库及Word导出 简介面向已有Python基础、需要把网页数据抓取后转成可管理文档的开发者与学习者这份教程文档围绕requests请求、BeautifulSoup解析和pymongo入库展开重点说明文档、集合、数据库等MongoDB核心概念及其灵活存储方式。压缩包内只有1个docx文件大小约608KB内容紧凑适合作为爬虫数据持久化的入门参考和随手查阅材料。目前已有11258人学习下载具备一定实践参考价值。文档结合公示网页抓取案例给出请求头设置、响应获取、目标内容提取、数据库连接、集合创建、文档组装与insert_one插入等代码片段并提示数据库和集合的存在性检测帮助读者理解从网页源码到结构化文档的完整链路同时认识文档型数据库在结构不固定、数据量较大的网页信息管理中的适用场景为后续字段设计、查询检索和分类存储打下基础。1. 抓取只是入口为什么网页数据最终要落进文档型数据库写 Python 爬虫最容易产生的错觉是以为难点在请求头和数据解析。真跑起来才发现requests.get()那一行从来不是瓶颈让人返工的是另一件事抓下来的东西用什么形状落地。用关系库建表字段长度、层级深度、附件个数全是变量正文一会儿三段一会儿三十段源站改个版就得改 schema改完历史数据全废。文档型数据库在这里的价值不是快而是容忍结构漂移——一条公示就是一个文档字段可以缺、可以嵌套、可以变长抓多抓少都不会把表撑爆。再往后一步把库里的文档导成 Word评审和归档的人才能直接翻开看。搞懂「HTML → 文档 → Word」这条链路为什么这么切分比背十个库的 API 有用得多。2. 抓取骨架URL 列表、进程池和一次 GET 的真实开销爬虫的第一层不是解析是把多少个 URL 变成多少个任务。这一层写歪了后面解析再准也白搭——要么漏抓一半页面要么四个进程一起卡在一个永远不会返回的请求上。2.1 分页 URL 的构造range 的右边界与「探底」公示列表页的地址通常有规律首页index.html第 n 页index_{n}.html。构造这串地址只要三行但右边界是最容易写错的地方。原文里range(1, 34)生成 1 到 33加上首页共 34 个页面而旁边注释写的是1-8 页加上首页共 9 页——注释和代码差了一个数量级。这种偏差不会报错只会让后面二十多页返回 404 或空列表程序照常退出你以为抓全了。import requests from lxml import etree from urllib.parse import urljoin BASE http://demo-site.local/xxgk/gggs 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) } def list_url(page: int) - str: page0 是首页page1 是 index_{page}.html return f{BASE}/index.html if page 0 else f{BASE}/index_{page}.html def get_links(url: str, timeout: float 10) - list: 返回这一页里所有详情页的绝对 URL页面结构不符时返回空列表 res requests.get(url, headersHEADERS, timeouttimeout) res.raise_for_status() # 404/500 立刻炸别让空页面混进结果 tree etree.HTML(res.text) hrefs tree.xpath(//ul[classlist-group]/li/a/href) return [u for h in hrefs if (u : urljoin(url, h.strip()))]timeout卡的是单次连接加读取的耗时不是整个请求的生命周期urljoin(url, h)的第一个参数必须是当前列表页的完整地址而不是站点根否则遇到../这类相对路径会合并错层级。列表推导里的海象运算符要求 Python 3.8 以上作用是顺手把空串和javascript:之类的无效 href 滤掉省得后面再判一次。硬编码页数的替代方案是探底一页一页往后走直到某页解析不出任何链接就停。源站改版、页数变化都不用改代码代价是多发一次请求。def probe_links(hard_limit: int 200) - list: 从首页开始翻直到某页没有链接hard_limit 是防死循环的后悔药 all_links, page [], 0 while page hard_limit: got get_links(list_url(page)) if not got: break all_links.extend(got) page 1 return all_linkshard_limit不是给正常情况用的。有些站点会把不存在的页码重定向回首页而不是返回 404探底就变成无限循环每轮都在追加同一批链接。加上限之后最坏情况是多跑两百次请求而不是跑到天荒地老。2.3 进程池map 怎么切任务fork 又复制了什么Pool(processes4)加pool.map(fn, links)看起来是一个链接一个进程实际不是。CPython 的Pool.map在chunksizeNone时会自己算chunksize, extra divmod(len(iterable), len(self._pool) * 4) if extra: chunksize 1也就是总任务数 ÷ (进程数 × 4)。1000 个链接、4 个进程chunksize 算下来是 63最终只切成 16 个批次每个 worker 拿 4 批。批次越大进程间通信越少、开销越低但负载越不均——某一批里混进几个慢页面那个 worker 就会拖尾其余三个早早空转。所以中小规模抓取手动设chunksize8更平滑代价是 IPC 次数变多。from multiprocessing import Pool if __name__ __main__: links probe_links() with Pool(processes4) as pool: pool.map(page_scratch, links, chunksize8)if __name__ __main__这一行不是风格问题。Windows 和 macOS 默认用 spawn 启动子进程子进程会重新 import 主模块少了这个守卫import 时又建一个池又 fork 一批进程直接递归到崩。Linux 上默认 fork 不会触发但代码一旦换平台就翻车属于典型的在我机器上是好的。with Pool()也要留意语义Pool.__exit__调用的是terminate()不是close()join()。map是同步阻塞的执行完再退出没问题换成imap_unordered又不消费结果退出时未完成的任务会被直接杀掉一条不报。要保证跑完用close()加join()显式收尾。fork 还复制了另一样东西父进程里所有打开的文件描述符包括数据库连接。顶层建的客户端对象在子进程里指向的是同一个 TCP 连接父子共用一套协议状态后面第 5 章会专门讲这个坑。2.4 一次 GET 里被忽略的三件事请求头里的 User-Agent 不是为了伪装而是为了让服务端返回和浏览器一致的模板。很多站点按 UA 分流给爬虫返回精简页XPath 一取就是空列表。原代码里那串 Chrome 78 的 UA 就是干这个的虽然版本旧但结构对得上。第二个是超时。requests默认不设超时DNS 卡住或服务端不回包时进程会永久阻塞。进程池里四个 worker 全被占满map永远不返回表现出来就是程序卡住了但没报错。第三个是状态码。只读res.text不看res.status_code404 页面同样有 HTMLXPath 得到空列表[0]抛 IndexError然后被最外层的except吞掉。raise_for_status()一行就能把这类静默失败变成明确异常。注意抓取前先手动打开一个详情页把 XPath 表达式粘到浏览器开发者工具里跑一遍比抓完再猜为什么字段是空的省时间。3. 解析层XPath 定结构、正则切文本边界在哪抓回来的是一坨字符串要变成五个字段的字典。这一步的核心判断是哪些信息该交给 XPath哪些该交给正则。3.1 etree.HTML 与 xpath 的返回值语义etree.HTML()走的是 libxml2 的 HTML 解析器会补全html/body、修复错位标签、容忍没闭合的br和img。换成etree.fromstring()或etree.parse()就走 XML 解析器遇到 HTML 里那些不闭合标签直接抛异常。抓网页一律用前者这不是偏好问题。tree etree.HTML(res.text) title_list tree.xpath(//div[classxxym]/h4/text()) title title_list[0].strip() if title_list else xpath()永远返回 list哪怕是取单个属性。原代码直接写[0]等于隐式断言这个节点一定存在。标题的容器 class 一改IndexError 立刻出现然后被外层吞掉——表现就是库里一条没有日志里一行 error。加个长度判断成本一行收益是把静默失败变成字段为空但流程继续。更隐蔽的是text()的语义它只返回直接子文本节点。p前半句strong加粗部分/strong后半句/p用/p/text()只能拿到[前半句, 后半句]加粗那两个词凭空消失。公示正文里带链接、带加粗的段落很常见原代码的\n.join(selector.xpath(//div[classTRS_Editor]/p/text()))会把这类内容悄悄挖掉一块而且不报错。# 只取直接子文本嵌套标签里的字会丢 loose tree.xpath(//div[classTRS_Editor]/p/text()) article_a \n.join(t.strip() for t in loose if t.strip()) # 按段落节点重建不会丢字段落边界也保住了 nodes tree.xpath(//div[classTRS_Editor]/p) article_b \n.join(.join(p.itertext()).strip() for p in nodes if p.itertext())itertext()是 lxml Element 的方法按文档顺序产出该节点下所有文本等价于对每个p再跑一次//text()但不用发起新查询性能更好。两种写法抓下来字数能差 5% 到 15%取决于正文里内联标签的密度。3.2 相对链接补全re.sub(^., ...)的三个前提原代码补全链接是这么写的links.append(re.sub(^., http://demo-site.local/xxgk/gggs, str(i)))^只匹配字符串开头.匹配除换行外任意一个字符——这行的真实语义是把 href 的第一个字符替换成那串前缀。它能正常工作纯粹因为同时满足了三个前提页面里的 href 都以.开头形如./201911/t20191114_489608.html、替换串末尾自带/、href 里没有别的前导字符。缺一条就废。href/xxgk/gggs/2019/t.html会把开头的/替换掉得到http://demo-site.local/xxgk/gggsxxgk/gggs/2019/t.htmlhrefhttp://cdn.demo/t.html会把h替换掉链接彻底报废href../other/t.html少合并一层。这三种情况在真实站点里都会出现尤其是附件链接。def absolutize(base_url: str, href: str): href (href or ).strip() if not href or href.startswith((javascript:, #, mailto:)): return None return urljoin(base_url, href) # 绝对地址原样返回相对地址按 RFC 3986 合并urljoin处理了./、../、协议相对地址//host/path以及已经是绝对 URL这四种情况逻辑都在标准库里不用自己维护正则。唯一的坑是 base_url 传错——必须是详情页自己的地址不能传列表页或站点根。3.3 编码为什么 text/html 没带 charset 就会变乱码requests决定res.text用什么编码走的是固定顺序res.encoding有值就用它没值就去 HTTP 头的Content-Type里找charset找到了用找不到且类型是text/*回落到 ISO-8859-1只有encoding为 None 时才会调用apparent_encoding去探测。关键在第三步text/html没写 charset 时encoding被设成ISO-8859-1不是 None所以探测根本不会发生中文直接变成中文。原代码那句res.encoding res.apparent_encoding就是绕开这个默认值——apparent_encoding内部用 chardet 对响应体做统计推断。def decode(res): ctype res.headers.get(Content-Type, ).lower() if charset in ctype: pass # 头部写了就别猜 elif res.apparent_encoding: res.encoding res.apparent_encoding # 没写才探测 else: res.encoding gb18030 # 兜底GBK 的超集 return res.text要付代价apparent_encoding会对整个content跑一遍 chardet200KB 的页面大概几十毫秒一千个页面就是几十秒纯 CPU。源站编码已知这类政务站点多是 GB2312 系时直接写死更快。chardet 对短文本的误判率也不低重定向到错误页时容易把一段 GBK 认成 ISO-8859-1。3.4 正则只做已知格式里的局部切片来源和日期这两个字段藏在h5的文本里格式是来源XXX加日期加制表符。原代码用source re.findall(来源(.*)\n, h5_text)[0] date re.findall( (.*?)\t, h5_text)[1]两行都依赖分隔符。(.*)贪婪同一行后面还有别的字段会被一起吃掉\n不存在就返回空列表[0]直接 IndexError[1]靠位置索引前面多一个空格分隔的片段就取错。正则适合格式稳定、边界明确的局部切片不适合当主解析器用。包一层带默认值的取值函数把崩溃降级成空串def pick(pattern: str, text: str, default: str ) - str: m re.search(pattern, text, flagsre.S) return m.group(1).strip() if m else default source pick(r来源[:]\s*([^\n\t]), h5_text) date pick(r(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}), h5_text)把匹配不到就崩改成匹配不到给空串字段缺失会体现在数据里而不是体现在异常栈里——后者至少还能看见前者至少还能补数据最怕的是异常被吞掉。4. 落库契约insert_one 之前 MongoDB 已经替你做的决定到这一步手上是一个字典标题、来源、日期、正文、附件链接。insert_one只有一行但它背后有一串默认行为不理解就会写出跑通了但数据不对的代码。4.1 惰性创建list_database_names()的顺序陷阱mydb myclient[webpages] # 只是拿到 Database 对象不发命令 dblist myclient.list_database_names() if webpages in dblist: print(该数据库存在) mycol mydb[gov.publicity] # Collection 对象同样是懒的client[webpages]和 db[gov.public本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询