☰
Python爬虫工程实战:requests与Scrapy的抓取解析存储闭环
2026/10/15 11:33:29 网站建设 项目流程

简介:这是一份系统讲解Python爬虫技术的实战指南,面向有一定编程经验、希望用Python批量采集公开数据的研究者与开发者。内容从爬虫基本概念、优势与工作原理讲起,重点拆解发送请求、解析网页、数据存储三大核心环节,覆盖requests、BeautifulSoup、lxml、Scrapy等常用库的适用场景与代码示例,并针对动态加载页面、反爬虫机制和抓取效率给出可落地的处理思路。资源包为单个docx文档,共1个文件,体积约16KB,便于直接阅读与后续检索。当前已有628人学习下载,适合用于构建数据挖掘项目、信息监测系统或快速上手个人爬虫小项目。对希望深入理解爬虫工作原理的读者而言,文档中的代码片段与拆解说明也能作为直接参考,便于边学边练。文中还特别强调法律法规、网站协议与用户隐私保护,帮助读者在合规前提下高效完成数据采集。

1. Python 爬虫不只是调库:先看懂抓取、解析、存储这三个闭环

很多人学 Python 爬虫,上来就写两行 requests 抓个页面,再用 BeautifulSoup 筛几个标题,然后就觉得会了。等到真要抓一个业务数据源,发现要么被重定向带偏,要么编码乱成一锅粥,要么请求间隔没控制好,IP 直接被封。真正能落地的网络爬虫,不是单点技巧,而是发送请求、解析网页、数据存储这三个环节串成一个闭环,每个环节都有对应的库选型和边界条件。Python 之所以成为首选,不在于语法多简单,而在于 requests、lxml、Scrapy 这组工具链恰好能把 HTTP 请求、HTML 解析、异步调度、持久化存储全部覆盖。这篇文章从实际工程角度拆一遍这套流程,适合有基础编程经验、想用 Python 做批量数据采集或搭建信息监测系统的开发者。

2. 发送 HTTP 请求:requests 的请求头、Session 与超时重试细节

2.1 为什么是 requests 而不是 urllib

requests 是 Python 里最常用的 HTTP 客户端库,底层封装了 urllib3,把连接池、重试、证书校验这些脏活都藏在 API 后面。对爬虫来说,你只需要关心 URL、请求方法、请求头、参数和超时这五件事。urllib 虽然标准库自带,但处理 Cookie、重定向、代理时要手写大量样板代码,工程效率差得多。

requests 默认行为里,有几个点值得注意。它不会自动附加 User-Agent,直接请求某些网站会返回 403;它默认跟随重定向,但不会告诉你重定向了几次;它对响应内容做编码推断,遇到中文网页经常猜错。所以在写爬虫时,第一件事是建立会话 Session,把常用的请求头固定在会话里,避免每个请求重复设置。

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() # 固定请求头,模拟正常浏览器访问 session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.8,en;q=0.6", "Connection": "keep-alive", }) # 挂载重试策略:连接错误时重试 3 次,遇到 503 或 429 也重试 retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 503, 429]) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter) try: resp = session.get("https://www.example.com/data", timeout=(3.05, 10)) resp.raise_for_status() except requests.exceptions.RequestException as e: print(f"请求失败: {e}") else: print(resp.status_code, resp.url)

Retry 里的 backoff_factor 控制重试间隔,0.5 表示第一次重试等 0.5 秒,第二次等 1 秒,第三次等 2 秒。对反爬严格的站点,total=3 已经够用,再多容易触发封禁。timeout 用元组形式分别指定连接超时和读取超时,连接超时设短一点,读取超时按页面大小适当放宽。这里还调用了 raise_for_status(),把 4xx 和 5xx 变成异常抛出,避免拿到错误页面还继续解析。

2.2 GET 与 POST 的参数传递和 JSON 提交

爬虫不只处理 GET 请求。搜索接口、登录接口、分页接口大量使用 POST,而且参数格式从表单到 JSON 都有。requests 对这两类参数做了区分,data 参数走表单编码,json 参数自动转成 JSON 字符串并设置 Content-Type。

# GET 请求,参数拼接在 URL 上 params = {"keyword": "数据采集", "page": 1, "pagesize": 20} resp = session.get("https://www.example.com/search", params=params) # POST 表单数据 form_data = {"username": "demo", "password": "pass123"} resp = session.post("https://www.example.com/login", data=form_data) # POST JSON 数据,常见于前后端分离的接口 json_body = {"query": "爬虫实战", "filter": {"date_range": ["2024-01-01", "2024-06-30"]}} resp = session.post("https://www.example.com/api/search", json=json_body)

这里有几个隐藏细节。GET 请求用 params 传参时,requests 会自动做 URL 编码,中文关键词不会出错;如果手拼 URL,经常因为忘了编码导致服务器返回 400。登录接口完成后,Session 会自动保存服务端下发的 Cookie,后续请求不用手动处理会话状态。JSON 提交则是接口联调最常用的方式,很多网站虽然页面是 HTML,但内部接口直接用 JSON 通信,抓这类接口比解析 HTML 要稳定得多。

2.3 编码处理与响应内容的正确打开方式

requests 获取响应内容时,response.text 会根据 headers 里声明的编码来解码。问题是很多页面不声明 charset,或者声明的编码与实际不符,典型表现就是中文乱码。稳妥的做法是直接从响应头推断,拿不到就用页面里的 meta 标签信息,实在不行再 fallback 到 utf-8。

# 优先从响应头取编码 encoding = resp.encoding or resp.apparent_encoding if encoding and encoding.lower() != "utf-8": resp.encoding = encoding html = resp.text

apparent_encoding 是 requests 根据内容字节分布猜出来的编码,准确率在大多数场景下够用。但注意,这个推断是全局的,如果网页里混着不同编码的内容,猜错仍有可能。更保险的方式是拿响应字节后,先用 chardet 检测,再手动解码。处理编码问题,核心思路是不要依赖 response.text 的默认行为,主动控制编码,而且要在写解析逻辑之前就确定好,否则后续所有解析都会建立在乱码之上。

提示:遇到 GBK 编码的老站,把 resp.encoding 手动设成 "gbk" 比自动推断可靠得多。这类站点在电商、政府数据源里仍然大量存在。

3. 解析 HTML:BeautifulSoup 与 lxml 的选型、选择器和 XPath 实践

3.1 BeautifulSoup 的定位能力边界

BeautifulSoup 拿手的是对不规范 HTML 的容错处理,网页标签没闭合、属性缺失这些毛病它都能容忍,适合快速写原型。核心操作是 find、find_all 和 select,分别对应条件查找和 CSS 选择器查找。用 bs4 做解析,代码写起来直观,但是性能上限不高,大规模抓取时速度会拖后腿。

from bs4 import BeautifulSoup soup = BeautifulSoup(html, "html.parser") # 按标签名查找第一个 h1 title_tag = soup.find("h1") print(title_tag.get_text(strip=True)) # 按属性查找,比如 id 为 content 的 div content_div = soup.find("div", id="content") # find_all 返回列表,配合 CSS 类名筛选 items = soup.find_all("div", class_="news-item") for item in items[:5]: link = item.find("a")["href"] title = item.get_text(separator=" ", strip=True) print(title, link) # CSS 选择器方式:选 class 为 pagination 下的所有 a pager_links = soup.select("div.pagination a")

注意直接用 find_all 时,class_ 参数必须带上后面的下划线,因为 class 在 Python 里是关键字。get_text 里的 strip=True 会去掉首尾空白,但不会压缩内部多空格,遇到排版乱的页面要在外部再清洗。select 返回的是列表,没有匹配到元素时返回空列表,不像 find 返回 None,所以判断逻辑要区分开。

3.2 用 XPath 获得精确解析能力

XPath 是 lxml 的核心武器,写法的表达能力比 BeautifulSoup 的属性查找更强。比如按文本内容定位、按位置索引、按兄弟节点关系查找,XPath 都能一行搞定。lxml 基于 C 实现,解析速度大约比 bs4 快一个数量级,适合体量大的爬取任务。

from lxml import etree tree = etree.HTML(html.encode("utf-8")) # 提取页面标题 title = tree.xpath("//h1/text()") print(title[0].strip() if title else "无标题") # 按 class 定位:找到所有 news-item 下的 a 标签的 href 和文本 items = tree.xpath("//div[contains(@class, 'news-item')]") for item in items: link = item.xpath(".//a/@href") text = item.xpath(".//a//text()") print(link, text) # 用索引定位:取第二行表格的全部单元格 cells = tree.xpath("//table/tbody/tr[2]/td/text()") # 按文本内容定位:找包含“下一页”的链接 next_link = tree.xpath("//a[contains(text(), '下一页')]/@href")

etree.HTML 的入参需要字节串,直接传 str 可能丢失部分字符,所以先用 encode 转一下。contains(@class, 'news-item') 比 @class='news-item' 更宽容,后者要求完全匹配,前者允许元素同时携带多个类名。.//a里的点表示从当前节点继续向下找,漏掉点会变成从文档根节点找,这是最常见的低级错误。

3.3 解析结果的数据清洗与结构化

解析出来的数据往往是脏的,需要对文本做清洗、对列表做去重、把字符串转成类型。清洗环节最容易翻车,因为页面结构和内容随时在变,拿到一个值就盲目入库,后面分析时才发现格式对不上。

import re import json def clean_price(text): """从价格字符串中提取数字,支持'¥1,299.00'这类格式""" if not text: return None # 去掉货币符号和千分位逗号 cleaned = text.replace("¥", "").replace(",", "") match = re.search(r"\d+(\.\d+)?", cleaned) return float(match.group()) if match else None def parse_item(raw_item): """把解析出的原始数据整理成结构化字典""" title = raw_item.get("title", "").strip() price_text = raw_item.get("price", "0") return { "title": re.sub(r"\s+", " ", title), "price": clean_price(price_text), "url": raw_item.get("url"), "source": "example_site" } data = [] for raw in items: parsed = parse_item(raw) if parsed["title"] and parsed["url"]: data.append(parsed) with open("output/result.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)

clean_price 这个函数单独抽出来,是因为价格的格式在不同站点差异很大,集中管理格式转换逻辑,后续扩展站点时不用到处改。去重逻辑放在入库之前,以 url 作为唯一键。数据清洗要写进解析流程里,而不是抓完再单独洗,否则数据量一大,清洗脚本会越写越复杂,最后变成黑匣子。

4. Scrapy 框架实战:Item、Pipeline 与抓取调度的工程化改造

4.1 Scrapy 相比手写爬虫的优势

手写 requests 加 lxml 的脚本,爬几个页面没问题,但扩展到几千个页面时,调度、去重、并发控制、失败重试全都要自己实现。Scrapy 把这些机制都内置了,Twisted 异步框架驱动并发请求,Scheduler 自动管理 URL 队列,DupFilter 负责去重,Pipeline 统一做数据持久化。工程上用 Scrapy 组织爬虫,核心是把业务逻辑拆到组件里,而不是在一个文件里堆命令。

4.2 搭建一个完整的 Scrapy 项目结构

Scrapy 的目录结构有约定,创建项目之后,spider 目录放爬虫逻辑,items.py 定义数据结构,pipelines.py 处理数据落地,settings.py 控制并发、延迟、下载间隔。

# items.py - 定义抓取数据的结构 import scrapy class NewsItem(scrapy.Item): title = scrapy.Field() publish_time = scrapy.Field() source = scrapy.Field() content = scrapy.Field() url = scrapy.Field()
# spiders/news_spider.py - 核心爬虫逻辑 import scrapy from demo.items import NewsItem class NewsSpider(scrapy.Spider): name = "news_spider" allowed_domains = ["example.com"] start_urls = ["https://www.example.com/news"] def parse(self, response): # 列表页:提取详情页链接并交给 parse_detail article_links = response.xpath("//div[@class='article-list']//a/@href").extract() for link in article_links: yield response.follow(link, callback=self.parse_detail) # 翻页:提取下一页链接继续抓 next_page = response.xpath("//a[contains(text(), '下一页')]/@href").extract_first() if next_page: yield response.follow(next_page, callback=self.parse) def parse_detail(self, response): item = NewsItem() item["title"] = response.xpath("//h1[@class='article-title']/text()").get() item["publish_time"] = response.xpath("//span[@class='publish-time']/text()").get() item["source"] = response.xpath("//span[@class='source']//text()").get() item["content"] = "".join(response.xpath("//div[@class='article-content']//p//text()").getall()).strip() item["url"] = response.url yield item

response.follow 是关键 API,它会自动补全相对路径,不需要手拼 URL。parse 方法里同时做了列表页解析和翻页,这是一种常见的递归抓取模式。注意 get() 和 getall() 的区别,前者返回字符串或 None,后者返回列表。用 getall() 时记得 join,因为网页正文被拆到多个 p 标签里。allowed_domains 限制了抓取范围,防止顺着外链爬到别的站点。

4.3 Pipeline 持久化与 settings 核心参数

Pipeline 负责数据写入,一个爬虫可以有多个 Pipeline,按优先级顺序执行。场景上区分:去重、清洗、入库各写一个 Pipeline,职责越单一越好维护。

# pipelines.py - 数据存储:JSON Lines 和数据库双写 import json import sqlite3 class JsonWriterPipeline: def open_spider(self, spider): self.file = open(f"output/{spider.name}.jl", "w", encoding="utf-8") def close_spider(self, spider): self.file.close() def process_item(self, item, spider): line = json.dumps(dict(item), ensure_ascii=False) + "\n" self.file.write(line) return item class SqlitePipeline: def open_spider(self, spider): self.conn = sqlite3.connect("output/data.db") self.cur = self.conn.cursor() self.cur.execute(""" CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, publish_time TEXT, source TEXT, url TEXT UNIQUE ) """) def close_spider(self, spider): self.conn.commit() self.conn.close() def process_item(self, item, spider): self.cur.execute( "INSERT OR IGNORE INTO articles (title, publish_time, source, url) VALUES (?,?,?,?)", (item["title"], item["publish_time"], item["source"], item["url"]) ) return item
# settings.py 关键参数配置 BOT_NAME = "demo" ROBOTSTXT_OBEY = True CONCURRENT_REQUESTS = 16 DOWNLOAD_DELAY = 0.5 COOKIES_ENABLED = True DEFAULT_REQUEST_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", } ITEM_PIPELINES = { "demo.pipelines.JsonWriterPipeline": 300, "demo.pipelines.SqlitePipeline": 400, }

CONCURRENT_REQUESTS 控制同时发出的请求数,数值越高抓越快,但被封的风险也越高。DOWNLOAD_DELAY 是请求间隔秒数,配合 CONCURRENT_REQUESTS 一起决定整体抓取节奏。ROBOTSTXT_OBEY 设为 True 表示自动遵守目标站点的 robots.txt 协议。Pipeline 后面的数字决定执行顺序,数字小的先跑,比如先去重再入库。SQLite 的 UNIQUE 约束配合 INSERT OR IGNORE 实现按 URL 幂等写入,重复爬取不会插重复数据。

5. 反爬避坑与常见故障排查:403、验证码、动态加载的处理思路

5.1 请求被 403 拦截,返回的不是业务页面

现象是浏览器打开正常,爬虫请求返回 403 Forbidden,页面内容变成验证页面或安全拦截提示。原因是目标站点检测到请求缺少浏览器特征,或者访问频率异常。

解决思路分三层。第一层是把请求头补齐,重点补 User-Agent、Accept、Accept-Language、Referer,有些站点还校验 Sec-Fetch-* 系列请求头。第二层是降低请求频率,把 DOWNLOAD_DELAY 调到 2 到 3 秒,同时把并发数降到 4 以下,先确认能稳定抓取再逐步调大。第三层是换 IP 来源,常见做法是准备一个代理池,按轮换策略分发请求,但这部分要和自己的抓取场景匹配,量小直接单 IP 降频就行,不值得为几十个页面引入代理组件。

有时候 403 不是反爬,而是请求参数缺失。比如详情页需要带一个 signature 参数,直接从列表页解析出来的 URL 少了这个参数,服务端就拒绝响应。排查时先看一眼 URL 结构,对比浏览器地址栏里的完整请求。

5.2 网页结构变了,XPath 全部失效

现象是昨天还正常的解析规则,今天抓回来看全是空列表。原因是站点改版了,class 名换了,或者是 DOM 结构整体调整,连标签层级都变了。

解决的思路不是试图写出永远正确的 XPath,而是让规则和页面结构解耦。一个做法是抓取时同时保存整个 HTML 响应正文,解析出错时可以通过排查页面结构定位问题。另一个做法是降低 XPath 的绝对层级依赖,优先用稳定的属性定位,比如 id、data-* 自定义属性。实际开发中,我习惯在 XPath 里多套一层 contains 匹配,减少对完整类名的依赖。把解析规则抽成独立的配置文件,站点改版时只改配置不动主代码。

5.3 动态加载的列表页,抓到的 HTML 里没有数据

现象是页面能看到完整内容,但用 requests 抓回来找不到目标数据,response.text 里只有空壳页面。原因是数据由 JavaScript 异步请求接口获取,服务端返回的 HTML 里不含数据字段。

处理有两条路线。一条是直接找背后的数据接口。打开浏览器的网络面板,刷新页面后筛选 XHR 请求,找到返回 JSON 数据的那几个请求,直接模拟这些接口的请求参数,抓取效率最高,解析也最简单。另一条是使用重放浏览器行为的工具,比如 Selenium 或 Pyppeteer 渲染完整页面后再取 page_source,但这种方式资源占用高,适合接口加密复杂、无法直接模拟的场景。

5.4 爬取中遇到验证码

现象是连续抓取几个页面后,突然返回一个验证码页面。原因是频率触发阈值,或者站点对特定路径做了额外校验。验证码的应对思路有很多,但首要原则是降低触发概率:延长下载延迟、随机化请求间隔、减少并发。如果业务上确实需要频繁抓取,再考虑接入打码平台或者人工处理,这属于合规边界之外的决策,通常不建议写入常规流程。还有一种情况是站点只在登录流程里出验证码,对已登录的会话不会再次校验,这种情况用 Session 保持登录状态就能绕过大部分验证环节。

5.5 SSL 证书校验报错和重定向陷阱

现象是请求时报 SSLError,或者抓到的页面内容和预期不一致。前者多发生在自签名证书的测试站点,后者可能出现 302 重定向到登录页或首页但你没察觉。SSL 报错可以通过 verify=False 规避,但会连带产生 InsecureRequestWarning,建议在开发环境里用,生产环境还是应该维护正规证书或使用已有证书的请求库。重定向问题,可以在请求时设置 allow_redirects=False,手动检查 resp.status_code 和 resp.headers 里的 Location,确认重定向目标符合预期再手动跟进。

import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) resp = requests.get(url, verify=False, allow_redirects=False) if resp.status_code in (301, 302): redirect_url = resp.headers.get("Location") print("重定向到:", redirect_url)

注意:verify=False 只是在本地环境绕过证书链校验,不代表目标网站是可信的,抓取敏感数据时不要因此放松判断。

6. 合规抓取与频率控制的落地习惯:robots.txt、请求间隔与数据分级存储

Scrapy 的 ROBOTSTXT_OBEY 只能帮你挡掉明令禁止的路径,真正决定一个爬虫是否合规运行,更多取决于你在请求频率、数据范围和数据用途上的自控。以建一个资讯监测系统为例,抓取公开文章的标题、发布时间、正文摘要属于常规操作,但把评论区里的个人账号信息也一并入库,就可能越界。我的做法是做一个两级分类:页面级公开数据直接存储,涉及个人可识别信息的字段一律不采集。这个边界在写 items.py 的时候就定死,而不是等入库了再做二次过滤。

频率控制方面,我的习惯是下载延迟不低于 1 秒,并发不超过 8,单站单 IP 日请求量控制在 1 万以内。即使目标站没有任何反爬机制,过高的请求频率也会拖垮对方服务器,这属于基本的使用伦理。大规模抓取前,先手工访问目标站检查 robots.txt,再把抓取范围限制在协议允许的目录内。另有一个细节值得保留:抓取任务结束时,对每个站点生成一份请求日志,记录起始时间、结束时间、请求总数、失败数。这套日志在后期排查问题或解释合规性时,都比口头说明更有说服力。

数据存储上,我按使用频度分了三层:原始响应存一份 JSON Lines,解析后的结构化数据存 SQLite,需要给数据团队用的结果单独导出 CSV。这样做的原因是原始包和解析结果耦合在一起,后续要重解析或换字段时就不用从头抓一遍,等于给未来的自己留了后悔药。

爬虫技术更新得快,但核心的请求、解析、存储闭环没有变。从那以后,我每次写爬虫都强制走一遍同样的顺序:先查 robots.txt,再写请求层,再定数据结构,最后调频率参数,不在思路清晰之前动代码。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询