☰
Python爬虫稳过反爬:UA轮换与IP代理池实战方案
2026/9/28 14:54:51 网站建设 项目流程

搞爬虫最烦的是什么?不是不会解析 HTML,而是你辛辛苦苦写好的采集脚本,跑起来没几分钟就被对方识破,返回一片 403 Forbidden,日志里全是红字。我早期做资讯网站数据采集的时候,这个坑踩了不止一次。后来把 User-Agent 轮换和 IP 代理池结合起来,才真正把采集任务稳定跑起来。今天就把这套“拿来即用”的方案完整拆一遍,包含代码、参数、以及我踩过的各种坑,希望能让你少走几个月的弯路。

如果你刚入门 Python 爬虫,或者正被反爬机制折磨,这篇文章适合你。里面没有花里胡哨的高深框架,就是 requests + 代理池 + UA 轮换这一套最实用的组合拳,你看完就能直接抄作业。

1. 被 403 劝退前,先看清反爬虫的三个“减速带”

1.1 User-Agent 为什么会被盯上

User-Agent 是 HTTP 请求头里一个非常显眼的字符串,它告诉服务器“我是谁”。正常情况下,浏览器访问网站时会带上完整的 UA,比如 Chrome、Firefox、Safari。但如果你直接用 Python 的 requests 默认配置去抓数据,服务端看到的 UA 是类似python-requests/2.31.0这样的字符串,这几乎等于在脑门上贴了一张“我是爬虫”的标签。

很多资讯网站的第一道防线就是检查 UA。它们会维护一个黑名单,把常见爬虫库的 UA 特征直接拦截掉。你以为自己只是发了个请求,实际上对方看一眼请求头就知道你是个机器人。所以,先做 UA 伪装,是爬虫工程里性价比最高的一步。

但要注意,光换一个固定 UA 也不够。比如你把自己的 UA 改成 Chrome 的,网站一般不会拒绝你,可如果你的采集脚本在几秒钟内同时发起几十个请求,每个请求的 UA 一模一样,服务端很容易通过流量分析识别出你是个脚本。这个时候就需要把 UA 做成一个池子,每次请求随机抽取一个,模拟真实浏览器的“人口多样性”。

1.2 IP 封禁是如何发生的

UA 只解决“身份伪装”问题,IP 地址才是一个用户在网络世界里的“门牌号”。资讯网站的反爬体系里,最常见的手段就是基于 IP 做访问频率统计。比如同一 IP 在 1 分钟之内访问超过 30 次,直接触发封禁策略,轻则返回验证码,重则拉黑一段时间。

这跟你是不是用了假 UA 关系不大,因为 IP 是绕不开的。你家里宽带的出口 IP 就一个,公司网络出口 IP 大概率也是固定的。一旦被封,整个办公网都跟着遭殃,这才是最让人头疼的。

所以,IP 代理池的核心思路很简单:把请求分散到不同的 IP 上,让每个 IP 的访问频率都保持在安全线以下。换句话说,UA 轮换负责“每次换张脸”,代理池负责“每次换个地方”。两层配合使用,被识别的概率能大大降低。

1.3 本方案的总体设计思路

说了这么多,我直接给出我常用的架构,分四层:

层级作用实现方式
随机 UA模拟不同浏览器指纹预先准备 UA 列表,每次请求随机选择
代理池分散出口 IP,降低单 IP 压力从免费/付费源采集候选代理,动态清洗维护
HTTP 会话统一 headers、连接复用、超时控制requests.Session + HTTPAdapter
重试机制网络抖动、代理失效时自动恢复Retry 策略 + 自定义循环

这套架构不止适用于资讯网站,做电商比价、新闻聚合、舆情监控等场景都可以直接复用。我觉得它最聪明的地方在于“低耦合”:UA 模块、代理池模块、请求模块互相独立,你想换掉哪一部分都很容易。

2. 搭建自己的 User-Agent 轮换器

2.1 准备高质量的 UA 指纹库

我以前用过网上一大堆 UA 列表,后来发现一个问题:很多 UA 其实非常老旧,比如还在用 Windows 7 + 远古版本 Chrome,这种 UA 在现在的服务端日志里反而是异常信号。真实用户的主流 UA 会随着时间变化,你要定期更新。

这里给出一份我近期在用的精简列表,包含 Windows、macOS、Linux 三平台的主流浏览器,覆盖 Chrome、Edge、Firefox、Safari:

UA_LIST = [ # Windows + Chrome "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", # Windows + Edge "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", # Windows + Firefox "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0", # macOS + Chrome "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", # macOS + Safari "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15", # Linux + Chrome "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", ]

这份列表看着不长,但已经覆盖了绝大多数资讯网站的主流访问群体。建议你不要盲目求多,而是要“真实、新鲜、主流”。

2.2 随机选择与 Headers 完整构造

单独随机选一个 UA 只是第一步,真正容易被忽略的是其他请求头。下面的函数是我封装好的,直接用即可:

import random def build_headers(): return { "User-Agent": random.choice(UA_LIST), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Cache-Control": "max-age=0", }

这里说几个细节:

  • Accept表示你能接受的内容类型,很多站点会校验它,尤其是资讯站。
  • Accept-Language最好和 UA 声明的地区保持一致。我说真的,有些反爬系统会做“一致性校验”,你 Linux UA 配 zh-CN 语言没问题,但 Windows UA 配英文语言也别扭。
  • Accept-Encoding写成gzip, deflate, br是告诉服务器你支持压缩,响应体会变小很多,抓起来更省流量。

2.3 请求头里容易被忽视的字段

使用者经常漏掉Referer和Sec-Fetch-*系列字段。资讯网站内部的页面之间跳转,通常都带 Referer。如果直接裸请求首页或者文章页,有些服务器会怀疑你“不是正常的页面跳转路径”。

建议先在浏览器开发者工具里看一下实际请求长什么样,然后把关键字段补上。对了,Sec-Fetch-Site,Sec-Fetch-Mode,Sec-Fetch-Dest这几个字段,现在的反爬系统越来越关注,冒充浏览器时最好也一起带上。比如请求一个文章页,可以设置:

headers = build_headers() headers["Referer"] = "https://news.example.com/" headers["Sec-Fetch-Site"] = "same-origin" headers["Sec-Fetch-Mode"] = "navigate" headers["Sec-Fetch-Dest"] = "document"

这套东西配合好了,你的请求从请求头层面看,跟一个真实用户几乎没有差别。

3. 实现一个可用的 IP 代理池模块

3.1 代理从哪里来:免费源采集与手动维护

代理池听起来很高级,说白了就是维护一个“可用的代理 IP 地址列表”。我把它分成两步:第一步找到代理来源,第二步验证并持续更新。

免费的代理源有几个渠道:公开的免费代理列表网站、TG 频道、GitHub 上定期更新的代理仓库。我自己比较推荐从公开代理列表站定时采集,再用脚本做存活验证。下面是一个简单的采集思路:

import requests from bs4 import BeautifulSoup def fetch_free_proxies(source_url): resp = requests.get(source_url, headers=build_headers(), timeout=10) soup = BeautifulSoup(resp.text, "html.parser") proxy_list = [] # 这里根据具体网站的表格结构解析 for row in soup.select("table tr"): tds = row.find_all("td") if len(tds) >= 2: ip = tds[0].text.strip() port = tds[1].text.strip() proxy_list.append(f"{ip}:{port}") return proxy_list

要注意,免费代理的寿命极短,很多几分钟就失效了。所以我强烈建议你不要把免费代理用在“生产环境”,它们更适合做测试和临时任务。

如果你做的是长期稳定的资讯采集,尤其是有业务价值的项目,付费代理池几乎是必须的。市面上有很多代理服务商,按量计费或者按 IP 池大小计费,稳定性完全不是一个级别。我自己在关键任务上就是用付费代理,免费代理只用来补量。

3.2 代理可用性验证与打分

不管代理从哪来,拿回来之后都要过一遍“质检”。我每次都会对候选代理发一个稳定的目标 URL,比如随便一个响应快的站点,并记录三个指标:

  1. 是否连通:能返回 200,才算有效。
  2. 响应时间:越快越好,超过 5 秒的直接弃用。
  3. 连续稳定性:同一个代理多测几次,成功率高才有保留价值。

下面给一个简单的验证方法:

import requests TEST_URL = "https://httpbin.org/ip" VALID_TIMEOUT = 5 def check_proxy(proxy): test_headers = build_headers() proxies = { "http": f"http://{proxy}", "https": f"http://{proxy}", } try: resp = requests.get( TEST_URL, headers=test_headers, proxies=proxies, timeout=VALID_TIMEOUT, ) if resp.status_code == 200: return resp.elapsed.total_seconds() except Exception: return None return None

每次拿到新代理后,我会把它们统一丢进一个队列,按响应时间排序,响应时间越短、验证通过次数越多的代理,权重越高。这样在正式抓取时,优先用质量好的代理,数量不够了再用备选。

3.3 把代理池接入 requests 会话

requests 的 Session 对象非常方便,它不仅能保持 cookies,还能统一设置代理。下面是一个带代理池的 Session 工厂函数:

import random import requests class ProxyPool: def __init__(self): self._proxies = [] self._blacklist = set() def load_from_file(self, filepath): with open(filepath, "r", encoding="utf-8") as f: for line in f: proxy = line.strip() if proxy and proxy not in self._blacklist: self._proxies.append(proxy) def get_random_proxy(self): if not self._proxies: return None return random.choice(self._proxies) def remove_proxy(self, proxy): if proxy in self._proxies: self._proxies.remove(proxy) self._blacklist.add(proxy) def refresh_proxies(self, new_proxy_list): self._proxies = [p for p in new_proxy_list if p not in self._blacklist] def create_session(pool): session = requests.Session() session.headers.update(build_headers()) proxy = pool.get_random_proxy() if proxy: session.proxies.update({ "http": f"http://{proxy}", "https": f"http://{proxy}", }) return session

这一段是核心中的核心。ProxyPool类负责维护代理列表和黑名单,每次请求从池子里随机挑一个,用完了如果发现失效,就丢进黑名单,避免下次再浪费请求。

3.4 关于超时、重试与连接池的参数选择

很多初学者写爬虫,只想着把代码跑通,完全不设置超时和重试,结果就是脚本卡死在一个失效代理上,等上几分钟才报错。

我在请求里固定加两个参数:

  • timeout: 建议设成(3, 5),分别代表连接超时 3 秒、读取超时 5 秒。
  • 重试次数:除了requests库内部的 Retry 策略,我还会在外面套一层自定义循环。

Retry 策略我用得很克制,只对网络层面的连接错误做重试,不轻易重试业务层错误,否则会加重对方服务器的压力。一个比较合理的 Retry 设置如下:

from requests.adapters import HTTPAdapter def create_session_with_retries(pool, retries=3): session = create_session(pool) adapter = HTTPAdapter( max_retries=retries, pool_connections=20, pool_maxsize=20, ) session.mount("http://", adapter) session.mount("https://", adapter) return session

注意,max_retries只是针对连接错误的重试,如果对方返回 403,Retry 并不会自动帮你换代理。所以真正的“换代理重试”逻辑,要靠外部循环来实现。

4. 实战:稳定爬取资讯网站的完整流程

4.1 目标分析与抓取策略

在写正式代码之前,先理解资讯网站的几个常见页面结构:

  • 首页:最新资讯列表,通常有新闻标题、时间、链接。
  • 列表页:分类下的文章卡片,主要采集标题、摘要、链接。
  • 详情页:文章正文内容、作者、发布时间等。

因为资讯网站更新频率高,我的策略一般是:先抓列表页拿到文章 URL,再根据 URL 爬详情页。两个步骤都要走“UA 轮换 + 代理池”这套机制。

在正式动手之前,我还会大致估算页面数量:比如一个分类有 100 页,每页 20 条,那就是 2000 次请求,一次跑完压力不小。所以我一般会加上延时,控制在每 2-4 秒一个请求,并且配合代理池足够大,才能做到“既快又不封”。

4.2 代码骨架:UA轮换 + 代理池 + 请求会话

这一步直接给一套可以跑起来的完整代码。为了让读者更容易理解,我把它写成一个小的爬虫模块:

import time import random import requests from bs4 import BeautifulSoup class NewsSpider: def __init__(self, pool): self.pool = pool self.session = None def get_page(self, url, max_attempts=3): for attempt in range(max_attempts): self.session = create_session(self.pool) try: resp = self.session.get( url, timeout=(3, 5), allow_redirects=True, ) if resp.status_code == 200: return resp.text elif resp.status_code in (403, 418): # 被拦截 old_proxy = self.session.proxies.get("http") if old_proxy: self.pool.remove_proxy(old_proxy) print(f"代理 {old_proxy} 被封,切换新代理") else: print("当前请求被拦截,无代理可用") else: print(f"请求失败: {resp.status_code}") except requests.RequestException as e: print(f"请求异常: {e}") time.sleep(random.uniform(1, 2)) return None def parse_article(self, html): soup = BeautifulSoup(html, "html.parser") title = soup.select_one("h1").get_text(strip=True) if soup.select_one("h1") else "未知标题" content = soup.select_one("article") or soup.select_one(".article-content") text = content.get_text(strip=True) if content else "" return {"title": title, "content": text}

这段代码里有几个关键点:

  • 每次请求前调create_session(self.pool),保证随机 UA、随机代理。
  • 如果遇到 403/418,就把当前代理拉黑,换新的代理重试。
  • 失败后time.sleep(random.uniform(1, 2)),模拟真人操作节奏。

4.3 解析页面与翻页处理

资讯网站的翻页逻辑很简单,很多都是?page=2这种路径。你可以循环生成 URL,也可以从页面里提取“下一页”链接。推荐后者,因为有的站点是动态加载,下一页链接藏在接口里。

下面这段可以处理常见的下一页形式:

def split_html_to_list(html): ...

其实我认为翻页最关键的不是解析链接,而是记录“已经抓过哪些 URL”。因为资讯网站超链接满天飞,不加上去重,你的爬虫很容易陷入循环,或者重复抓取大量页面。我常用的去重方法就是维护一个 set,抓之前先判断,抓完之后立即写入:

seen_urls = set() def fetch_and_process(url, spider): if url in seen_urls: return seen_urls.add(url) html = spider.get_page(url) if html: data = spider.parse_article(html) # 后续处理 data

这是我在实战里养成的好习惯,因为一旦加上代理池,请求成本变高了,去重就是在省钱。

4.4 数据处理与落库(含 SQLAlchemy 提点)

抓到文章之后,输出到 CSV 是最简单的,但项目规模一大,CSV 就不好使了。我的习惯是用 SQLAlchemy ORM,把采集结果存进 SQLite 或 MySQL。

一个最小示例:

from sqlalchemy import create_engine, Column, String, Text, DateTime from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Article(Base): __tablename__ = "articles" id = Column(String, primary_key=True) title = Column(String, nullable=False) content = Column(Text, nullable=False) url = Column(String, unique=True, nullable=False) created_at = Column(DateTime) engine = create_engine("sqlite:///news.db") Base.metadata.create_all(engine) SessionLocal = sessionmaker(bind=engine)

这里我特意给url加了 unique 约束,这样就算程序跑重了,数据库层也会帮你去掉重复数据。这个细节在长期采集任务里非常省心。

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

5.1 频繁出现 403/418/429 怎么判断

这三个状态码经常让人傻傻分不清,我简单总结一下:

状态码含义常见原因应对方式
403拒绝访问UA 被识别、IP 被封、Referer 缺失换 UA、换代理、补请求头
418我是茶壶常见于反爬机制故意返回,用来混淆该代理/UA 已经不可靠,立即切换
429请求过多当前 IP 请求频率过高降低频率或换新代理

如果抓到的页面全是 403,优先检查是不是代理失效,网络层问题往往比业务层问题更隐蔽。你可以先拿一个不带反爬的测试站验证代理池是否正常工作,这样能把问题定位到是“代理问题”还是“目标站的反爬策略”。

5.2 代理生效但速度极慢怎么办

代理慢,通常有两个原因:代理本身响应慢,或者目标网站对代理 IP 的响应慢。我的排查顺序是:

  1. 用curl或 requests 单独测试这个代理的响应时间。
  2. 看是不是代理所在机房和网站服务器之间的链路太长。
  3. 如果一批代理都慢,很可能是代理源整体质量下降,换一个地理区域的代理。

尽量不要给请求的 timeout 设置太长,否则一个慢代理能拖死整个爬取过程。我的经验是单个请求超时控制在 5 秒以内,失败了就当这个代理不行,换下一个。

5.3 免费代理一堆失效怎么批量清洗

免费代理池需要定期清洗,尤其是从公开列表采集后,失效比例可能超过 70%。我的清洗方式是“多线程并发验证”,一次验证 50 个代理,每个代理设定 3 秒超时,5 分钟就能刷完几百个候选。验证通过的重点关注响应时间,只留下响应快的。

下面给出一个简单版的并发清洗脚本:

from concurrent.futures import ThreadPoolExecutor, as_completed def clean_proxy_list(proxy_list): valid_proxies = [] with ThreadPoolExecutor(max_workers=20) as executor: futures = {executor.submit(check_proxy, proxy): proxy for proxy in proxy_list} for future in as_completed(futures): result = future.result() if result is not None: valid_proxies.append((result, proxy_list[future])) valid_proxies.sort() return [proxy for _, proxy in valid_proxies]

这里要注意,并发验证时不要单线程跑,否则清洗效率低到你会怀疑人生。

5.4 日志与异常排查的调试技巧

爬虫是最需要日志记录的代码,因为你挂了重试的时候,过程是不可见的。我每次都会给失败请求打上详细信息,包括:

  • 目标 URL
  • 当前代理 IP
  • 当前 UA
  • 状态码或异常类型
  • 耗时

一旦被封,你能迅速定位到底是谁的问题。打印日志的格式大概是这样:

log_msg = f"[FAIL] URL={url} PROXY={proxy} UA={ua} STATUS={status} TIME={elapsed:.2f}s"

还有一个小技巧:在正式大规模采集前,先用单个页面单次请求跑一遍,确认能通,再放开并发。别一上来就暴力并发,把对方服务器惹恼了,也把自己 IP 搭进去。

提示:无论你再怎么优化 UA 和代理,爬虫本身仍然是在消耗目标站的服务器资源。采集前先看 robots.txt,控制好请求频率,不要为了“薅数据”把别人的服务搞崩。技术要用在合理的地方。

6. 最后几点实在话

6.1 别把对方网站爬挂

我见过不少新手,拿到一套代理池就“火力全开”,每秒发几十个请求,结果把目标站搞到响应超时。爬虫虽说是技术活,但也是个体力活。真正稳定的采集,应该是细水长流。我习惯的做法是:普通资讯站控制在 1-3 个请求/秒,配合中等规模的代理池,足够跑完全站。

6.2 定时任务与异常恢复

采集任务跑在服务器上,不可能每次都手动盯着。最好用循环调度或者定时任务框架,比如使用 Python 的 schedule 库,或者直接部署一个常驻脚本。每次启动脚本前,先做三件事:清洗一次代理池、清空昨天的日志、检查去重库里的 URL 数量,避免脚本重启后重复抓取大量数据。

6.3 一点经验收尾

这一套组合拳,我前前后后用了不下两年,踩过最多的不是反爬强,而是“想一次跑完”的急脾气。把 UA 轮换、代理池、会话管理、重试策略分开设计,每部分都做到“可替换”,这才是长期稳定采集的核心。我的经验就是:与其追求所谓的一次性破解,不如把基础设施打磨好,爬取速度和稳定性自然就上来了。希望这篇实战笔记能帮你少踩几个坑,有更好的方案也欢迎交流。

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

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

立即咨询