☰
Python爬虫实战指南:从静态抓取到反爬与工程化
2026/10/16 15:06:27 网站建设 项目流程

1. 项目整体设计与核心思路拆解

1.1 爬虫的本质与边界:搞清楚你要抓什么

先说一句容易被忽略的话:爬虫本身不是技术题,而是需求分析题。很多人一上来就写requests.get(url),结果抓到一半发现页面结构变了、字段缺了、数据量对不上,最后整个重来。这就是没在设计阶段把自己要什么、目标数据长什么样、存到哪里想清楚。

我拿到一个爬虫项目时,第一件事不是写代码,而是先问三个问题:

  • 目标站点的数据是静态渲染的还是动态加载的?这决定了你是用requests直接拿源码,还是需要模拟浏览器。
  • 需要的字段有哪些?拿电商商品页举例,你可能要标题、价格、销量、评论数、SKU、店铺名、评论内容。字段不同,解析策略完全不同。
  • 数据量级是多大?是几千条、几百万条还是持续增量?量级影响存储选型、抓取频率和并发方案。

这三个问题想清楚,项目就成功了一半。举个例子:如果目标数据是某个公开报表网站上的表格,纯静态HTML,那用requests+pandas.read_html几行就搞定了,完全不需要上浏览器渲染。反过来,如果数据是通过JS异步加载的,那么直接请求页面拿到的HTML里根本没有数据,只能去找接口或者用渲染工具。

另外,边界问题必须一开始就讲明白:哪些页面可以抓,哪些不能抓?判读依据是目标站点的robots.txt、服务条款、公开数据声明,以及你是否有正当用途(比如学习研究、个人数据分析)。这一点我在后面的合规章节还会专门强调。我的原则是:爬虫是工具,数据是资源,但资源是有主人的。你可以用工具,但不能偷资源,更不能破坏系统服务。把边界画清楚,技术上反而更自由。

1.2 技术选型:为什么是Python,以及各组件怎么搭配

技术栈选择上,Python 确实是爬虫领域的“第一公民”。这不是吹嘘,而是生态决定的。你要解析HTML,有BeautifulSoup、parsel、lxml;要发请求,有requests、httpx;要模拟浏览器,有Selenium、Playwright;要异步并发,有aiohttp。每一个环节都有成熟方案,而且社区教程多,踩坑记录也多。

我的常用搭配是:

场景推荐工具理由
简单静态页面requests+parsel轻量、上手快,解析语法统一
中等规模数据requests+lxml+pandas性能好,数据分析顺手
动态渲染页面Playwright现代、自动等待、支持浏览器指纹
大规模分布式Scrapy+CrawlSpider内置去重、调度、管道、扩展
接口形式数据httpx+jsonschema异步支持好,验证数据结构方便

很多人初学者纠结该学Scrapy还是该先手写爬虫。我的建议是:先把底层的requests和解析库用熟,再上手Scrapy。因为Scrapy封装了很多机制,比如调度器、下载中间件、Item Pipeline,如果你不理解底层原理,出了问题只能靠猜。而手写爬虫能让你把每一个坑都踩一遍,之后再去看Scrapy的源码,就会有一种“原来如此”的通透感。

选型背后的另一个考量是维护成本。爬虫最大的敌人不是抓不到,而是目标站点改版。所以在设计时,我会有意识地把“请求逻辑”和“解析逻辑”分开。比如写一个spider.py,里面定义两个函数:fetch_page(url)只负责拿数据,parse_page(html)只负责提取字段。这样就算页面结构变了,我只需要改parse_page,请求部分完全不用动。这也是从小项目到大项目过渡时,代码组织能力的分水岭。

2. 入门实战:静态页面抓取的核心三板斧

2.1 用 requests 发起一个可靠请求,而不是“碰运气”

很多入门教程只会教你requests.get(url),然后打印response.text。但实际项目中,这样写等于裸奔。我来拆解一个可靠请求需要哪些细节。

首先是请求头(Headers)。服务器是通过请求头里的User-Agent、Accept、Referer等字段来判断客户端类型的。如果你拿着默认的python-requests/2.31.0去访问,稍微有点反爬意识的站点会直接拒绝。我一般会准备一份真实的浏览器请求头模板,至少包含:

HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/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.9,en;q=0.8", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", }

其次是超时和重试。如果不设置timeout,请求挂起时脚本会一直等,最终堆积成僵尸进程。我的习惯是连接超时设为5秒,读取超时设为15秒,并配合重试机制。重试不是盲目,而是对连接错误、超时和5xx状态码做有限次重试,每次间隔递增。

import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry( total=3, # 总重试次数 connect=3, # 连接失败重试 backoff_factor=1, # 重试间隔因子,1 -> 2 -> 4 秒 status_forcelist=[500, 502, 503, 504], ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) session.headers.update(HEADERS) resp = session.get("https://example.com", timeout=(5, 15))

这里有个很实用的经验:用requests.Session()而不是每次requests.get()。Session 会复用底层的 TCP 连接,保持 cookies,对目标服务器更友好,性能也更好。实测在小规模抓取时,Session 比裸请求速度快30%以上。

最后是状态码检查。很多人只判断resp.status_code == 200,但有经验的爬虫会看resp.history是否发生了重定向,resp.url是否与请求 URL 一致,resp.text的长度是否异常。有时候服务器用200状态码返回一个“安全验证”页面,这时候就需要额外的校验,比如判断页面标题或关键字段。这个我在后面“常见问题”章节里再展开。

2.2 解析提取:正则、XPath、CSS 选择器到底怎么选

拿到 HTML 之后,提取数据是核心环节。三种主流方式各有适用场景,我分享一下实际取舍。

正则表达式适合提取“内嵌在脚本变量里的数据”,比如页面里的window.__INITIAL_STATE__ = {...}。这种结构用 XPath 反而费劲,正则直接定位然后json.loads更快。但正则可读性差,嵌套结构不好处理,所以只在特定场景用。

XPath 和 CSS 选择器是主流。parsel这个库同时支持两套语法,而且底层是lxml,性能不错。我的个人建议是:优先掌握 XPath,因为它的功能更强,能处理兄弟节点、父节点、文本节点、属性筛选等复杂结构。CSS 选择器更简洁,适合简单情况。

举一个电商列表页的例子:

<div class="product-item"> <h2><a href="/goods/123">无线鼠标</a></h2> <span class="price">89.00</span> <span class="sales">累计评价 1200</span> </div>

用parsel写:

from parsel import Selector html = ... # 页面源码 sel = Selector(text=html) items = sel.xpath('//div[contains(@class, "product-item")]') for item in items: title = item.xpath('.//h2/a/text()').get() url = item.xpath('.//h2/a/@href').get() price = item.xpath('.//span[@class="price"]/text()').get() sales = item.xpath('.//span[@class="sales"]/text()').re_first(r'(\d+)')

注意几个细节:.//前面的点是相对当前节点查找;get()返回第一个匹配,getall()返回列表;re_first是用正则从文本中提取数字。解析完字符串后,记得做类型转换,比如价格转Decimal,销量转int。否则排序、计算时全是字符串,坑死自己。

另外,解析时不要迷信一次性写一个大选择器。我的习惯是先把页面按大区块拆开,再逐层细化。比如列表页先提取所有商品外层节点,遍历后再从每个节点里提取具体字段。这样如果某个字段解析失败,不会导致整个列表失效。

2.3 数据存储:从 CSV 到 SQLite 的合理演进

入门阶段不需要一上来就接数据库,从纯文本到轻量数据库是个平滑路径。

CSV 和 JSON 最适合小批量结果。但这里有个坑:直接用 Python 的csv库写入中文时,可能出现编码问题。建议写文件时指定encoding="utf-8"并且用newline=""参数避免多余空行。JSON 则要注意确保每次写入是完整数组,而不是“一行一个JSON”那种非标准格式,否则后期读入会麻烦。

对于100万条以内、字段不太多的数据,我强烈推荐用 SQLite。它是文件型数据库,不需要单独部署服务,Python 自带sqlite3模块。而且它的去重能力比在内存里维护一个set强太多。比如抓商品,我只需要给goods_id建一个唯一索引,插入时用INSERT OR IGNORE,天然防重。

import sqlite3 conn = sqlite3.connect("data.db") conn.execute(""" CREATE TABLE IF NOT EXISTS goods ( id INTEGER PRIMARY KEY AUTOINCREMENT, goods_id TEXT UNIQUE, title TEXT, price REAL, sales INTEGER, crawled_at TEXT ) """) conn.execute(""" INSERT OR IGNORE INTO goods (goods_id, title, price, sales, crawled_at) VALUES (?, ?, ?, ?, ?) """, ("123", "无线鼠标", 89.0, 1200, "2025-01-01 12:00:00")) conn.commit()

实际项目中,存储层最好单独封装一个函数,比如save_item(item)。这样后期如果换成 MySQL、MongoDB,只需要改这一个函数。我在多个项目里都吃过“存储逻辑散落各处”的亏,每次改需求都要全局搜索,非常痛苦。

3. 进阶必备:动态渲染页面与反爬应对

3.1 动态页面抓取:先找接口,再考虑渲染

遇到一个页面,数据是异步加载的,很多人的第一反应是上Selenium。但我的建议是:先打开浏览器的开发者工具,切到 Network 面板,刷新页面,看看有没有 XHR 或 Fetch 请求直接返回 JSON 数据。如果有,直接构造接口请求,既快又省资源。

比如某个排行榜页面,滚动时才加载下一屏数据。你观察网络请求,发现请求了一个api/list?page=2&limit=50,返回 JSON,里面有下一页的页码。这种情况下,完全不需要模拟浏览器,直接用requests请求这个接口即可。

那什么时候才需要Playwright或Selenium?通常是这些场景:

  • 数据在页面加载后由复杂的 JS 计算生成,没有独立接口;
  • 接口需要签名,且签名算法被混淆得很厉害;
  • 目标页面要登录,而登录过程有大量动态验证;
  • 你需要执行点击、滚动、拖拽等交互才能触发数据加载。

这里我强烈推荐Playwright而不是Selenium。原因有三个:第一,Playwright 原生支持自动等待和网络空闲判断,出错更少;第二,它对 iframe、新标签页、弹窗的处理更顺手;第三,它支持拦截请求和响应,可以做请求修造。而且它的录制脚本功能playwright codegen可以直接把浏览器操作转成 Python 代码,效率很高。

一个典型的 Playwright 用法:

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example.com", wait_until="networkidle") # 等待某个元素出现 page.wait_for_selector('.product-item') html = page.content() browser.close()

注意networkidle并不总是可靠,有些页面持续有轮询请求,会一直等待超时。我更常用的是wait_for_selector或wait_for_load_state("domcontentloaded"),再结合轮询判断关键数据是否出现。这种“等待条件”的思路,比固定sleep(3)靠谱得多。

3.2 反爬机制拆解:UA、频率、验证码与签名参数

反爬的本质是服务器在判断“你是不是真人”。常见的机制有:

  • User-Agent 检查:解决方式很简单,伪装一个真实浏览器的 UA,并且保持一致性。我在 Session 里设置好 UA 后不会频繁换,频率太高反而像机器人。
  • 请求频率限制:单位时间请求数超过阈值,直接封 IP。解决方式是随机延时,比如time.sleep(random.uniform(1, 3)),高级一点可以用指数退避。
  • Cookie 验证:某些站点首次访问会种下__jsluid之类的 cookie,后续请求必须带上。用 Session 保持 cookie 即可,但有些站点是 JS 生成的 cookie,就需要先用浏览器拿到,再交给requests。
  • JS 签名参数:比如请求 URL 带一个token或sign,由 JS 根据时间戳、路径等计算。破解签名需要逆向 JS,这属于比较深的领域。我的建议是:优先寻找其他数据源或移动端接口,很多时候移动端接口的签名强度反而更弱。但注意,如果签名算法被加密混淆,轻举妄动可能涉及法律风险,要三思。
  • 验证码:图片验证码可以用 OCR,点选验证码则需要专门方案。我基本不去硬碰,而是通过降低频率、换登录态来规避。如果业务必须,建议使用官方提供的打码平台,但要在服务条款允许的前提下。

关于 IP 封禁,很多人第一反应是买代理池。但我想提醒一句:代理池很贵,而且免费代理的质量很差。在小规模抓取、日请求量在千级以下时,降低频率比花钱买代理更划算。

3.3 让爬虫学会“克制”:请求速率控制与并发策略

这是一个经常被忽视的工程素养。爬虫不是发请求越猛越好,而是“在不对目标服务器造成压力的情况下,尽量高效”。我通常在抓取模块里加一个全局的速率控制器,用令牌桶算法来控制。

简单实现可以参考这个思路:

import threading import time class RateLimiter: def __init__(self, max_qps=1): self.interval = 1.0 / max_qps self.lock = threading.Lock() self.next_time = time.time() def wait(self): with self.lock: now = time.time() if now < self.next_time: time.sleep(self.next_time - now) self.next_time = time.time() + self.interval

设置max_qps=1就表示每秒最多请求1次。对于需要大量抓取的场景,再用多线程或异步来提高吞吐。但前提是速率控制器是线程安全的。

如果你用Scrapy,它内置了DOWNLOAD_DELAY和AUTOTHROTTLE_ENABLED,前者是固定的抓取延迟,后者会根据服务器响应时间自动调整延迟。我的经验是两都配合使用:AUTOTHROTTLE_ENABLED = True设为默认开启,再设置DOWNLOAD_DELAY作为兜底。这样既能保证速度,又不会把服务器打挂。

有人说,抓取当然越快越好。我不这么看。目标是“可持续地拿到数据”,不是“一次性把 IP 封掉”。很多大站有风控团队专门盯这个,你控制不好频率,轻则当前任务失败,重则整段 IP 段被拉黑。

4. 工程化与稳定性:从“能跑”到“跑不停”

4.1 异常捕获与重试策略,把崩溃消灭在萌芽里

手写爬虫最烦人的就是:跑到一半因为网络波动抛异常,进程退出,前面抓的数据全白费。所以工程化的第一步就是给请求和解析加上完整的异常处理。

我习惯把所有网络请求统一封装成一个函数,里面处理几种典型异常:

def fetch_url(url, session, retries=3): for attempt in range(retries): try: resp = session.get(url, timeout=(5, 15)) if resp.status_code == 200: return resp except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.ChunkedEncodingError) as e: wait = 2 ** attempt time.sleep(wait) return None

这里2 ** attempt就是指数退避,第一次失败等1秒、第二次2秒、第三次4秒。比固定等待更科学。还要注意,有些异常不是网络层面的,例如requests.exceptions.TooManyRedirects,这说明目标站点配合有问题,要检查 URL 和 headers,而不是盲目重试。

解析阶段的异常往往是被忽略的。比如字段缺失导致KeyError、空值导致NoneType错误。我建议写一个safe_extract工具模块,所有字段提取都通过它,内部做空值兜底和类型转换。不要因为一个脏数据中断整个任务。

4.2 断点续爬与数据去重:让爬虫有“记忆”

爬虫跑在大规模任务时,很可能中途崩溃、断电、被人为中止。如果没有断点续爬能力,重启后要么从头开始、浪费之前进度,要么漏抓一部分数据。

我常用的方案是“已抓取标识”记录法。具体做法是:为每个待抓取对象分配一个唯一 ID(比如商品ID),抓取前先查“已抓取表”,抓取成功后写入这张表。这样即使脚本中断,重启后也能从crawled表里找出哪些已经完成,哪些还需要重试。

SQLite 实现起来非常方便:

def mark_done(conn, item_id): conn.execute("INSERT OR IGNORE INTO crawled (item_id, crawled_at) VALUES (?, ?)", (item_id, time.strftime("%Y-%m-%d %H:%M:%S"))) conn.commit()

如果是分布式爬虫,可以把这个“已抓取表”放到 Redis 的SET里,利用SADD的天然去重性。Scrapy则自带去重过滤器,默认基于请求指纹,存到内存或文件里,也可以通过DUPEFILTER扩展接入 Redis。

另一个要点是“任务队列”设计,而不是把所有 URL 一次性放进内存。我用queue.Queue或Scrapy的调度器来管理任务,队列里只放待抓取 URL。已经完成的任务不要堆积在内存,及时写入数据库。

4.3 日志与监控:让爬虫的状态“看得见”

脚本运行后,你不可能一直盯着终端。所以一个好的爬虫必须会“说话”——输出结构化日志,并且在异常时能通知你。

Python 标准库logging足够使用,我推荐配置两种 handler:一种是 FileHandler,写到本地文件;一种是 StreamHandler,输出到屏幕。日志级别设置为INFO,每一批数据抓取完成时打印一条摘要,比如“第 5000 条完成,耗时 80 秒,平均速率 62.5 条/秒,当前重试次数 3”。这样你扫一眼日志就能判断爬虫是否正常。

对于长期运行的任务,我还会加一个“看门狗”逻辑:如果连续 N 次请求都失败或者长时间没有新数据入库,就往钉钉、企业微信或邮件发一条告警。实现方式很简单,定时检查数据库里的最新记录时间,超过 10 分钟没有新数据就触发告警。这比每天手动看日志高效太多。

5. 常见问题与排查技巧实录

5.1 请求阶段的经典报错:连接超时、403、SSL证书错误

这里我整理了一张速查表,都是我在实战里踩过的坑。

现象可能原因排查思路
TimeoutError/Read timed out网络波动、服务器慢、代理失效检查网络,增加超时时间,换代理或降低并发
403 ForbiddenUA被识别、缺少必需Header、IP被风控检查请求头,是否漏了Referer、Origin,降低频率
SSL: CERTIFICATE_VERIFY_FAILED目标站证书链不完整不推荐全局关验证,建议用verify指向指定证书或使用ssl.CERT_NONE并捕获警告
TooManyRedirects站内做了重定向循环去掉allow_redirects=True,查看实际响应头
ConnectionError: Connection refused目标IP被防火墙拦截、端口不对确认访问协议,test用curl验证

特别说一下 403。很多时候不是 IP 被封,而是你的请求头缺了Referer。比如模拟浏览器访问详情页,服务器校验来源必须是列表页的 URL,如果你直接裸请求详情页,被拒就很正常。解决方法是把上下两个请求关联起来,带上正确的Referer。另外,动态请求头还要注意顺序,某些极端服务器会校验Accept-Encoding和实际响应是否一致。

SSL 证书问题也要注意:不要无脑设置verify=False,这等于关闭了加密连接的安全校验,容易被中间人攻击。如果是公司内网测试,可以暂时忽略,但公网抓取我建议找到问题的根源,比如有些证书是自签名的,你可以用requests.get(url, verify="/path/to/cert.pem")指定信任证书。

5.2 页面拿下来了,但解析结果全为空:十有八九是动态加载

我自己最常遇见的“灵异事件”:打印resp.text发现 HTML 完整,但xpath提取全是空列表。排查思路从下面几个方面入手。

先判断数据是否真的在静态 HTML 里。你可以用浏览器的“显示网页源代码”功能查看原始 HTML,注意不是“检查”里的元素面板。如果源代码里没有你要的数据,那必然是 JS 动态加载,需要回到 Network 面板找接口。这一步能避免你在死胡同里浪费大量时间。

如果数据确实在 HTML 中,但解析不到,可能是页面有多层嵌套或 HTML 不规范。比如标签没有闭合、class 属性有额外空格、数据被 HTML 实体转义。这时候用parsel解析时,可以打印sel.xpath('//body').get()看看,或者用lxml.html.etree.tostring转成标准格式再排查。

还有一种隐蔽情况:目标页面有 A/B 测试,不同用户看到的页面结构略有不同,导致你的 XPath 在部分请求上失效。解决方法是把选择器写得宽松一点,用contains而不是完全等于,同时做“多重匹配”的兜底。

5.3 乱码、重复数据、字段缺失:数据治理的三座大山

先说乱码。即使resp.encoding = "utf-8"已经设置了,有时仍然乱码。原因是服务器返回的 content-type 里声明了charset=gbk,而实际内容又是 UTF-8,或者反过来。可靠做法是优先从Content-Type里取编码,如果没有,再用resp.apparent_encoding(依赖文本统计)兜底。

字段缺失处理的核心原则是:宁可留空,不要让程序崩溃。我用一个Dict承接解析结果,所有字段都用defaultdict(str)。后续入库前做数据清洗,比如去掉价格里的货币符号、把“1.2万”转成12000、把日期统一成YYYY-MM-DD。

重复数据问题,前面提到了数据库唯一索引,这里再补一个细节:如果数据源本身没有唯一 ID 可选,可以用多个字段的哈希值作为指纹。比如商品标题+价格+URL 做md5或sha1,存到唯一列。这样即便没有明确的 ID 也能去重。

5.4 合规红线:爬虫不是“为所欲为”的通行证

这部分我想认真讲讲。爬虫相关法律纠纷这些年不少,很多人一开始没注意,等收到律师函才慌。我给自己定了几条铁律:

  • 尊重目标站点的robots.txt。它不是法律文件,但是一个重要的“道德信号”和技术边界指示。
  • 绝不抓取需要绕过登录、破解加密、突破盗版防护的内容。这已经超出了技术讨论的范畴。
  • 抓取公开数据,但使用场景要正当。不用于恶意竞争、批量注册、刷量、爬取个人隐私。
  • 数据量要有节制。哪怕对方允许抓取,也要控制频率,避免影响对方正常服务。
  • 对外发布数据时要标注来源,并遵守目标站点的条款约定。

另外,很多开发者在写教程时默认拿某网站练手,但真实上线前一定要检查目标站点的服务条款。有些站点明确写了“禁止爬虫”,那你技术上能做,但做了就要承担风险。我的建议是:练习时优先用官方提供的公开数据集、开放的 API,或者自建一个测试站点。既能练手,又没有纠纷。

在我维护的项目里,爬虫模块都会单独加一个settings.py,里面写上ROBOTS_POLICY = "obey"这样的配置,从代码层面默认遵守规则。这不仅是合规要求,也让项目能长期稳定运行——一个被封的爬虫,再好的技术也白搭。

写在最后,分享一点个人的实践经验。爬虫项目没有“一锤子买卖”,它更像园艺:种下一棵数据小树,需要持续修剪枝叶、浇水施肥。我见过太多人冲动地写一百行代码,抓到一批数据后热情消退,丢在服务器上不管。其实真正有价值的爬虫项目,是那些能跑上几个季度、数据持续更新、能支撑业务决策的系统。而要做到这一点,根据我的经验,比掌握某个具体语法更重要的,是养成“设计先行、克制并发、充分日志、敬畏合规”的习惯。

如果这篇文章让你少走几个弯路,那我很高兴。你现在可以拿一个自己感兴趣、又确定合规的公开数据集试试手,从静态页面的第一行requests开始。抓取本身不难,难的是在无数次踩坑之后,依然保持把一件事做完整的耐心。祝你的数据之路,既有“拿捏”的成就感,也有边界内的从容。

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

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

立即咨询