ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python爬虫稳过反爬:UA轮换与IP代理池实战方案

Python爬虫稳过反爬:UA轮换与IP代理池实战方案 搞爬虫最烦的是什么不是不会解析 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、SafariUA_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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.7, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Upgrade-Insecure-Requests: 1, Cache-Control: max-age0, }这里说几个细节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, headersbuild_headers(), timeout10) 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比如随便一个响应快的站点并记录三个指标是否连通能返回 200才算有效。响应时间越快越好超过 5 秒的直接弃用。连续稳定性同一个代理多测几次成功率高才有保留价值。下面给一个简单的验证方法import requests TEST_URL https://httpbin.org/ip VALID_TIMEOUT 5 def check_proxy(proxy): test_headers build_headers() proxies { http: fhttp://{proxy}, https: fhttp://{proxy}, } try: resp requests.get( TEST_URL, headerstest_headers, proxiesproxies, timeoutVALID_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, encodingutf-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: fhttp://{proxy}, https: fhttp://{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, retries3): session create_session(pool) adapter HTTPAdapter( max_retriesretries, pool_connections20, pool_maxsize20, ) session.mount(http://, adapter) session.mount(https://, adapter) return session注意max_retries只是针对连接错误的重试如果对方返回 403Retry 并不会自动帮你换代理。所以真正的“换代理重试”逻辑要靠外部循环来实现。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_attempts3): for attempt in range(max_attempts): self.session create_session(self.pool) try: resp self.session.get( url, timeout(3, 5), allow_redirectsTrue, ) 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(stripTrue) if soup.select_one(h1) else 未知标题 content soup.select_one(article) or soup.select_one(.article-content) text content.get_text(stripTrue) if content else return {title: title, content: text}这段代码里有几个关键点每次请求前调create_session(self.pool)保证随机 UA、随机代理。如果遇到 403/418就把当前代理拉黑换新的代理重试。失败后time.sleep(random.uniform(1, 2))模拟真人操作节奏。4.3 解析页面与翻页处理资讯网站的翻页逻辑很简单很多都是?page2这种路径。你可以循环生成 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_keyTrue) title Column(String, nullableFalse) content Column(Text, nullableFalse) url Column(String, uniqueTrue, nullableFalse) created_at Column(DateTime) engine create_engine(sqlite:///news.db) Base.metadata.create_all(engine) SessionLocal sessionmaker(bindengine)这里我特意给url加了 unique 约束这样就算程序跑重了数据库层也会帮你去掉重复数据。这个细节在长期采集任务里非常省心。5. 常见问题与排查技巧实录5.1 频繁出现 403/418/429 怎么判断这三个状态码经常让人傻傻分不清我简单总结一下状态码含义常见原因应对方式403拒绝访问UA 被识别、IP 被封、Referer 缺失换 UA、换代理、补请求头418我是茶壶常见于反爬机制故意返回用来混淆该代理/UA 已经不可靠立即切换429请求过多当前 IP 请求频率过高降低频率或换新代理如果抓到的页面全是 403优先检查是不是代理失效网络层问题往往比业务层问题更隐蔽。你可以先拿一个不带反爬的测试站验证代理池是否正常工作这样能把问题定位到是“代理问题”还是“目标站的反爬策略”。5.2 代理生效但速度极慢怎么办代理慢通常有两个原因代理本身响应慢或者目标网站对代理 IP 的响应慢。我的排查顺序是用curl或 requests 单独测试这个代理的响应时间。看是不是代理所在机房和网站服务器之间的链路太长。如果一批代理都慢很可能是代理源整体质量下降换一个地理区域的代理。尽量不要给请求的 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_workers20) 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 轮换、代理池、会话管理、重试策略分开设计每部分都做到“可替换”这才是长期稳定采集的核心。我的经验就是与其追求所谓的一次性破解不如把基础设施打磨好爬取速度和稳定性自然就上来了。希望这篇实战笔记能帮你少踩几个坑有更好的方案也欢迎交流。
返回列表