ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫实战:requests+BeautifulSoup+lxml构建采集器

Python爬虫实战:requests+BeautifulSoup+lxml构建采集器

简介:这份资源面向具备一定编程基础、希望系统掌握网络数据采集的开发者与学习者,围绕Python爬虫从基础概念到实战应用展开,帮助读者理解自动化抓取工具的工作原理并落地到实际项目。内容涵盖发送请求、解析网页、数据存储三大核心环节,重点讲解requests、BeautifulSoup、lxml与Scrapy等常用库的特点与适用场景,并延伸至动态加载处理、反爬应对、效率优化以及法律法规与隐私保护等规范议题。资源包共1个docx文件,约16KB,以文档形式集中呈现知识要点与代码示例,便于按章节查阅和对照练习。目前已有629人学习,适合需要批量获取公开数据的研究人员、构建数据挖掘或信息监测系统的开发者,以及希望将爬虫技术应用于个人小项目的学习者参考,在合法合规前提下提升数据采集效率。

1. 从一份爬虫笔记说起:为什么我把它当项目脚手架用

前阵子接手一个数据聚合的活儿,需求很朴素:从几个公开页面上把标题、链接、发布时间扒下来,清洗后入库。我翻出自己压箱底的一份 Python 爬虫笔记,本来只想抄两段requests的调用,结果发现这份材料从 HTTP 请求、HTML 解析、数据落盘一路讲到动态渲染和并发抓取,结构完整得可以直接当项目脚手架。它讲的不是某个具体网站怎么爬,而是把「发请求 → 解析 → 存储 → 提速 → 合规」这条链路拆开揉碎,每个环节都给了能跑的代码。适合谁?刚学完 Python 语法、想动手抓点真实数据的新手,以及需要快速搭一个采集原型、后面再往 Scrapy 或异步方案上迁的老手。我按它的骨架搭了个小采集器,两小时跑通,这篇就把我拆解和改造的过程写下来。

2. 技术选型与目录结构:requests + BeautifulSoup + lxml 的分工

2.1 为什么是这套组合而不是一上来就 Scrapy

这份材料的技术栈很克制:请求层用requests,解析层用BeautifulSoup和lxml,存储层用pandas落 Excel,规模化再上Scrapy。这个顺序是有讲究的。requests的 API 极简,response.text一行拿到 HTML,新手不用理解连接池、会话保持这些概念就能跑通第一个请求。BeautifulSoup容错性强,遇到闭合标签乱七八糟的页面不会直接崩,适合练手;lxml基于 C 实现,解析速度比纯 Python 的解析器快一个量级,还支持 XPath,等数据量上来再切过去。

我一般会跟人说:先用requests + BeautifulSoup把单页抓取跑通,确认目标数据结构稳定,再考虑要不要上Scrapy。Scrapy的 Item、Spider、Pipeline 是一整套规则,学习曲线陡,如果只是抓几百条数据,杀鸡用牛刀反而拖慢进度。这份材料把Scrapy放在「常用库」最后讲,定位是「大规模数据」的解决方案,这个取舍是对的。

2.2 我按材料搭出来的目录结构

材料本身是笔记体,没有给工程目录。我按它的模块划分整理成下面这样,每个文件对应一个职责,改起来不会牵一发动全身:

spider_demo/ ├── config.py # URL、请求头、间隔时间等配置 ├── fetcher.py # 请求层:封装 requests,统一加 headers 和重试 ├── parser.py # 解析层:BeautifulSoup / lxml 提取字段 ├── storage.py # 存储层:写 Excel / CSV / 数据库 ├── pipeline.py # 串联:抓取 → 解析 → 存储 └── requirements.txt # 依赖清单

这个结构的好处是,当你要换解析库时只动parser.py,要换存储目标时只动storage.py,请求层的重试和限速逻辑集中在fetcher.py。材料里把「发送请求、解析网页、数据存储」列为爬虫三大核心技术,这个目录就是按这三块切的,一一对应。

2.3 依赖清单与版本注意

requirements.txt里至少要锁这几个:

requests beautifulsoup4 lxml pandas openpyxl

pandas写 Excel 依赖openpyxl,这个容易漏,漏了会在to_excel时报ModuleNotFoundError。lxml在部分环境需要编译工具链,如果pip install lxml卡住,常见做法是先用beautifulsoup4自带的html.parser顶着,等环境配好再切。材料里没提这些依赖细节,但实际搭的时候这几步绕不开。

3. 核心链路拆解:一个请求从发出到落盘的完整代码

3.1 请求层:headers 和超时是保命配置

材料里给的requests.get(url)是最简形态,直接拿去跑真实站点大概率吃闭门羹。我在fetcher.py里补了请求头和超时:

import requests import time 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", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch(url, retry=3, interval=1.5): """带重试和间隔的请求封装""" for i in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 4xx/5xx 直接抛异常 resp.encoding = resp.apparent_encoding # 修正中文乱码 return resp.text except requests.RequestException as e: print(f"第 {i+1} 次失败: {e}") time.sleep(interval * (i + 1)) # 退避,越失败等越久 return None

逻辑说明:timeout=10防止某个请求卡死拖垮整个流程;raise_for_status()把 HTTP 错误码转成异常,交给except统一处理;apparent_encoding是根据内容猜编码,比默认的resp.encoding更靠谱,中文页面乱码十有八九是这里没设。interval * (i + 1)是简单的退避策略,第一次失败等 1.5 秒,第二次等 3 秒,给目标站点喘息空间,也降低被封的概率。参数上,retry别设太大,3 次足够,再多说明目标站点已经明确拒绝你了,硬刚没意义。

3.2 解析层:BeautifulSoup 和 lxml 各管一段

材料里BeautifulSoup用find_all按标签抓,lxml用 XPath 精确定位。我把两者放在parser.py里,按页面复杂度选用:

from bs4 import BeautifulSoup from lxml import etree def parse_by_soup(html): """结构规整的页面用 BeautifulSoup,容错好""" soup = BeautifulSoup(html, "html.parser") items = [] for h in soup.find_all("h1"): items.append({"title": h.get_text(strip=True)}) return items def parse_by_xpath(html): """字段嵌套深、需要精确定位时用 lxml + XPath""" tree = etree.HTML(html) titles = tree.xpath("//h1/text()") links = tree.xpath("//a/@href") return [{"title": t, "link": l} for t, l in zip(titles, links)]

逻辑说明:get_text(strip=True)会去掉标签内多余的空白和换行,直接拿到干净文本;xpath("//a/@href")里的@href表示取属性值而不是文本,这是 XPath 比find_all灵活的地方。参数上,etree.HTML()会自动补全缺失的<html>、<body>标签,所以哪怕拿到的是片段 HTML 也能解析。选哪个?页面结构稳定、字段少,BeautifulSoup写起来快;字段藏在多层div里、需要按属性筛选,lxml的 XPath 一行顶十行。

3.3 存储层:先落 Excel,再考虑数据库

材料里用pandas把数据写 Excel,这个选择对原型阶段很务实:

import pandas as pd def save_to_excel(records, path="output.xlsx"): """records 是 list[dict],每个 dict 是一条记录""" if not records: print("没有数据可写") return df = pd.DataFrame(records) df.drop_duplicates(subset=["title"], inplace=True) # 按标题去重 df.to_excel(path, index=False) print(f"已写入 {len(df)} 条到 {path}")

逻辑说明:pd.DataFrame(records)直接把字典列表转成表格,键自动变成列名;drop_duplicates按标题去重,爬虫重复抓同一页时这步能省掉很多脏数据;index=False避免多出一列无意义的行号。参数上,subset指定按哪几列判重,如果标题可能重复但链接唯一,就改成subset=["link"]。数据量超过几万条后 Excel 会变慢,这时候再换 SQLite 或 MySQL,pandas的to_sql接口能平滑迁移。

3.4 串联:pipeline 把三段接起来

from fetcher import fetch from parser import parse_by_soup from storage import save_to_excel def run(urls): all_records = [] for url in urls: html = fetch(url) if html: all_records.extend(parse_by_soup(html)) save_to_excel(all_records) if __name__ == "__main__": run(["https://www.example.com/page1", "https://www.example.com/page2"])

这段是整个项目的入口,逻辑就是「逐个 URL 抓取 → 解析 → 汇总 → 落盘」。if html这个判断不能省,fetch失败返回None,直接丢给解析器会报TypeError。要加新站点,就在run里换解析函数;要加并发,就把for循环换成线程池,下一章讲。

4. 动态页面与并发提速:Selenium 和线程池怎么接进来

4.1 动态加载:requests 拿不到的内容交给 Selenium

材料里点出了一个关键问题:AJAX 和 JavaScript 异步加载的内容,requests拿到的 HTML 里根本没有。这时候得上Selenium模拟浏览器:

from selenium import webdriver from selenium.webdriver.chrome.options import Options def fetch_dynamic(url): opts = Options() opts.add_argument("--headless") # 无头模式,不弹窗口 opts.add_argument("--disable-gpu") driver = webdriver.Chrome(options=opts) try: driver.get(url) # 滚动到底部触发懒加载 driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") import time; time.sleep(2) # 等异步请求返回 return driver.page_source finally: driver.quit() # 必须关,否则进程残留

逻辑说明:--headless让浏览器在后台跑,服务器上没图形界面也能用;execute_script执行滚动,很多页面要滚到底才加载后续内容;time.sleep(2)是等 JS 执行完,这个值要按页面实际速度调,设短了拿到空数据,设长了拖慢整体。finally里的driver.quit()是血泪经验,不写的话每抓一次就留一个 Chrome 进程,跑几十次机器就卡死了。参数上,webdriver.Chrome()需要本机有对应版本的 ChromeDriver,版本不匹配会直接报错,这是新手最容易翻车的地方。

4.2 并发抓取:线程池比手写 Thread 省心

材料里用threading.Thread手动管理线程,能跑但代码啰嗦。我一般用concurrent.futures的线程池:

from concurrent.futures import ThreadPoolExecutor, as_completed from fetcher import fetch from parser import parse_by_soup def crawl_all(urls, max_workers=5): results = [] with ThreadPoolExecutor(max_workers=max_workers) as pool: future_map = {pool.submit(fetch, u): u for u in urls} for future in as_completed(future_map): url = future_map[future] html = future.result() if html: results.extend(parse_by_soup(html)) return results

逻辑说明:max_workers=5表示同时最多 5 个请求在跑,这个数别贪大,设成 20、50 反而容易触发目标站点的频率限制,5 到 10 是比较稳的区间。as_completed按完成顺序取结果,谁先回来先处理,不用等最慢的那个。future_map把 future 对象和 URL 对应起来,出错时能定位是哪个 URL 挂了。参数上,如果目标站点响应慢,可以适当调大max_workers;如果频繁收到 429 状态码,说明太快了,往下调。

4.3 反爬应对:间隔、请求头、代理三件套

材料里列了设置请求间隔、模拟正常用户、使用代理 IP 三条。落到代码上,间隔用time.sleep控制,请求头在fetcher.py里统一加,代理则在requests.get里传proxies参数。这里要提醒一句:代理 IP 的获取和使用要遵守目标站点的规定,别拿它去绕明确禁止抓取的站点。材料里还提到验证码处理,这个在实际项目里通常是遇到才处理,提前准备意义不大,因为验证码形态千变万化。

5. 避坑与排查:那些让我重跑三遍的坑

5.1 中文乱码:现象是标题变成一串问号

现象:抓下来的中文全是????或者乱码方块。原因:requests默认按 HTTP 响应头里的charset解码,很多站点不写或者写错,导致解码用错编码表。解决:在fetch里加resp.encoding = resp.apparent_encoding,让requests根据实际字节内容猜编码。如果还乱,就手动指定resp.encoding = "utf-8"或"gbk"试。

5.2 拿到空列表:解析器返回空但请求是成功的

现象:fetch返回了 HTML,但find_all("h1")结果是空列表。原因:目标内容根本不在初始 HTML 里,是 JS 渲染出来的,requests拿到的只是骨架。解决:先用浏览器开发者工具看「查看源代码」里有没有目标内容,没有就说明要上Selenium。另一个常见原因是选择器写错,比如类名里带空格,find_all("div", class_="a b")要写成列表["a", "b"]。

5.3 请求被拒:状态码 403 或 429

现象:跑着跑着开始返回 403 或 429。原因:403 通常是请求头缺失或 UA 被识别为爬虫;429 是请求频率超了。解决:403 补全User-Agent、Accept-Language等头;429 降低并发数、加大time.sleep间隔。如果补了头还是 403,检查是不是漏了Referer,有些站点会校验来源页。

5.4 Selenium 报驱动版本不匹配

现象:SessionNotCreatedException,提示 ChromeDriver 版本和 Chrome 对不上。原因:Chrome 自动更新后,本地的 ChromeDriver 还是旧版。解决:用webdriver-manager自动管理驱动版本,或者手动下载对应版本替换。这个坑几乎每个用 Selenium 的人都踩过,属于玄学范畴,版本号差一位就跑不起来。

5.5 数据重复:同一页被抓了多次

现象:Excel 里同一条数据出现好几遍。原因:分页逻辑没去重,或者重试机制把成功的请求又发了一遍。解决:在存储层用drop_duplicates兜底,同时在pipeline里维护一个已抓 URL 的集合,抓过的直接跳过。重试逻辑里也要判断,只有失败才重试,别把成功的也重发。

6. 二次开发:把这份笔记改造成自己的采集器

拿到这份材料后,最省力的改造路径是「先跑通单页,再扩到多页,最后加调度」。第一步,把config.py里的 URL 换成你真正要抓的页面,跑一遍pipeline.py,确认能拿到数据、能落盘。这一步别急着加并发,单线程跑通最重要,因为并发会把错误信息搅在一起,新手很难定位。

第二步,扩多页。如果目标站点是page=1、page=2这种规律 URL,直接在run里生成 URL 列表;如果是「下一页」按钮,就得先解析出下一页链接再递归抓。这里我一般会加一个max_pages参数,防止不小心抓了几千页。

第三步,加调度和增量。最简单的做法是记录已抓 URL 到一个文本文件,每次启动先读进来,抓过的跳过。再进一步,用 SQLite 存抓取记录,字段包括 URL、抓取时间、状态,这样断点续抓和去重都方便。

第四步,按需替换组件。解析规则变了就改parser.py;要存数据库就把storage.py里的to_excel换成to_sql;要上规模就把线程池换成Scrapy或asyncio。因为目录是按职责切的,换哪块都不影响其他部分。

验证改造是否成功,我有个笨办法但很管用:拿一个只有 3 条数据的测试页面跑,人工核对抓下来的字段和页面上的内容是否一一对应。字段对上了,再换真实页面。从那以后我每次改解析规则,都强制先跑这个小测试页,确认没把选择器改坏再上量。希望这份拆解能帮你少走点弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进