ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从Requests到Scrapy的Python爬虫实战:工程化与反爬应对指南

从Requests到Scrapy的Python爬虫实战:工程化与反爬应对指南 做Python开发这些年我收到过最多的请求就是帮我爬个网站。饭局上朋友让我查王者荣耀战绩私聊里有人问某鱼直播的热榜能不能存档工作群里前端同事拿着接口文档来问这个数据后端不开放我要不要自己写个脚本抓——你会发现爬虫几乎是Python领域里最容易让人产生我也能做冲动的方向但也是最容易在半路翻车的方向。这篇实战指南我打算把从基础到高级工具应用这条路重新走一遍。不是给你堆一堆库的API文档而是按照我实际做爬虫项目的思考顺序来拆从最底层的HTTP请求原理到requests怎么用得地道再到解析三板斧怎么选、动态页面怎么破、并发和分布式怎么设计最后是高阶工具链和反爬的工程化应对。适合两类人刚学完Python语法、想用爬虫练手的新手以及已经写过简单脚本、但遇到封IP、验证码、数据量大就卡壳的进阶学习者。读完之后你至少能建立一套完整的爬虫工程化思维——知道每一步在干什么、为什么这么干、出问题时去哪里排查。1. 爬虫工程的分层拆解先想清楚再写代码爬虫这名字听起来像顺着网线抓数据实际上它是一整套系统工程。我在指导新人时最常说的一句话是别急着requests.get先回答四个问题——你要的数据在哪、以什么形式存在、目标站点什么脾气、数据拿下来放哪里。这四个问题对应着爬虫的四层结构。第一层是数据采集层负责发起网络请求拿到响应内容涉及HTTP协议、请求头伪装、会话保持、重试机制第二层是数据解析层把拿到的HTML、JSON、二进制流转化成结构化数据涉及正则、XPath、CSS选择器、JSONPath第三层是数据存储层决定结果落到CSV、Excel、SQLite、MySQL还是消息队列第四层是调度管理层负责请求队列、去重、并发控制、失败重试、代理池管理。为什么要做这样的分层因为每一层的问题性质完全不同混在一起写只会让代码越来越臃肿。我见过一个很典型的例子——有人把代理切换的逻辑写死在解析函数里结果换一个站点结构解析重写一遍代理逻辑也要跟着改维护成本直接翻倍。分层之后的好处是请求层只需要关心能不能拿到数据解析层只需要关心拿到数据后怎么提取任何一层的改动不影响其他层。还有一个新手极容易忽略的点爬虫本质上是在模拟客户端而不是在写请求-响应的玩具脚本。目标站点的服务器只认两样东西——你的身份标识和你的行为模式。身份标识靠请求头特别是User-Agent和Cookie行为模式靠频率和顺序。所以从一开始就要把请求头设置、访问间隔这些不像技术、胜似技术的东西纳入设计。后面我会用一节专门讲请求头的工程化处理这里先建立认知框架。确定技术栈的时候我的建议也很直接遵循够用就好复杂度按需升级的原则。数据量只有几百条、目标站没有反爬requests加BeautifulSoup完全够用要走全站、需要断点续爬引入Scrapy并发要求高、请求量大到单机扛不住才上分布式方案。不要一上来就搞Celery加Redis加Scrapy全家桶架构的复杂度只会成为你的负担。2. 从requests到请求头工程把伪装这件事做到位requests是Python爬虫的基石库它的地位就像炒菜用的锅——你可以用更好用的工具但谁都绕不开它。不过大多数教程只教会了requests.get()带个headers参数却很少讲清楚背后的HTTP语义导致很多人换了个站点就抓瞎。2.1 HTTP请求的底层逻辑你发的每个参数都有含义一个HTTP请求由三部分构成请求行方法加路径加协议版本、请求头键值对集合、请求体POST/PUT等方法的载荷。服务器拿到请求后通过请求头来判断你是谁、你想干什么。常见的几个字段作用如下User-Agent标识客户端类型和版本。有些站点会对陌生UA直接拒绝尤其要注意有些页面针对搜索引擎爬虫和普通浏览器返回不同内容。Referer告诉服务器你从哪个页面跳转过来。很多做图片防盗链的站点会校验这个字段比如某些图库。Cookie服务端把会话标识存在这里用来识别登录态。爬虫里很多明明浏览器能打开、脚本拿不到数据的诡异问题追到最后都是Cookie缺失或过期。Accept-Language标识你接受的自然语言。有些国际站点会依据这个字段返回不同语言版本处理不好会拿到一堆看不懂的乱码页面。X-Requested-With很多前端框架比如jQuery在发Ajax请求时会带上这个头值为XMLHttpRequest。服务器端经常通过判断这个头来决定是返回完整页面还是返回JSON片段。新手常见的错误是只设置UA一个字段。遇到简单的站点确实只设置UA就够了但遇到稍微认真一点的站点服务器会同时校验Referer和Cookie的一致性。我在爬一个资讯类站点时就踩过这个坑——单独设置UA返回正常页面但加上Referer后立刻触发验证码后来才发现对方在根据请求来源判断这到底是不是页面内的合法请求。2.2 requests的Session对象别小看它的状态保持功能requests.get()每次调用都是独立请求不会自动保持Cookie而requests.Session()会持久化Cookie和连接池。两者在爬虫场景下的差异非常明显如果目标站需要先访问首页拿到会话Cookie再带着Cookie去请求接口用Session就能自动完成用独立请求就得手动提取、拼接过程繁琐还容易出错。代码上的区别就一行import requests session requests.Session() # 在session层面设置默认请求头所有用session发起请求都会带上 session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, }) resp session.get(https://example.com/data, timeout10)Session还有连接池复用能力。对同一主机的多次请求复用连接能显著减少TCP握手带来的时间开销。做大规模爬取时这个性能差异会被放大——一个是几百毫秒的握手延迟一个是毫秒级的数据传输延迟积累到上万次请求就非常可观了。2.3 超时、重试与异常处理爬虫的保险丝没有超时和重试的爬虫等于裸奔。默认情况下requests会一直等待服务器响应如果对方承受不住压力挂起你的爬虫也会跟着卡死而且失败原因还特别难排查。我给每个请求都会设置三件套import requests from requests.adapters import HTTPAdapter session requests.Session() # 为session挂载重试适配器遇到连接错误或5xx时最多重试3次 adapter HTTPAdapter(max_retries3) session.mount(http://, adapter) session.mount(https://, adapter) try: resp session.get( https://example.com/data, timeout(3, 10), # 连接超时3秒读取超时10秒 ) resp.raise_for_status() except requests.exceptions.Timeout as e: print(请求超时可能网络波动或服务压力大) except requests.exceptions.ConnectionError as e: print(连接失败检查网络或代理) except requests.exceptions.HTTPError as e: print(fHTTP错误{e})timeout参数用元组形式是我比较推荐的做法——拆成连接超时和读取超时两个维度。连接超时表示建立TCP连接的最长等待时间读取超时表示每两个数据包之间的最长等待时间。拆分的好处是网络慢但连接正常的场景会被读取超时拦下而不是空等一个巨大的整体超时。关于重试我想说一个容易踩的坑对POST请求自动重试可能导致数据重复提交。requests的重试适配器默认对所有方法重试但写操作重试要谨慎。遇到幂等性的场景还好如果接口会对每次提交生成新数据重试就会产生重复记录。我习惯在业务逻辑层手动控制POST重试次数网络层只对GET做自动重试。2.4 请求头伪装不是堆字段保持请求的一致性说完基础进入经验核心。我发现很多教程教伪装只教加个UA但真正的伪装逻辑是模拟真实浏览器的完整请求上下文。什么意思你在浏览器里访问一个页面浏览器会同时发送十几个请求头字段这些字段之间存在隐含关联。比如你用Chrome内核的UA却带着Safari特有的Sec-Fetch-Mode字段组合严谨的服务器会从这些矛盾中嗅出异样。我处理请求头的习惯是直接用浏览器开发者工具复制整段Request Headers现在Chrome右键复制为cURL格式更方便放到代码里原样保持不做删减。这不是无脑操作——只是为了从源头保证字段的完整性和一致性。常见的安全产品会校验几个容易被忽略的字段比如sec-ch-ua客户端品牌信息、Accept-Encoding压缩算法、Connection连接方式。这些字段单个看没什么组合起来就构成这个请求来自真实浏览器的特征。再说Cookie。爬虫项目的生命周期里Cookie失效是不可避免的但很多人的处理方式是等出错了再手动跑去浏览器复制。我在项目里通常会把Cookie读取独立成一个函数从本地配置文件或数据库中读取失效时能够快速替换而不是满代码库找硬编码的Cookie字符串。一个简单做法import os import json def load_cookie(): 从统一配置读取cookie避免在代码里散落硬编码Cookie cookie_path config/cookie.json with open(cookie_path, r, encodingutf-8) as f: data json.load(f) return {item[name]: item[value] for item in data[cookie]} def save_cookie(cookie_dict): 更新cookie到统一配置方便失效时快速更换 cookie_path config/cookie.json data {cookie: [{name: k, value: v} for k, v in cookie_dict.items()]} with open(cookie_path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)Cookie集中管理的好处等你遇到换一个Cookie要重启整个爬虫的场景会深有体会。此外配置里建议同时维护一套登录流程的脚本化方案——等Cookie快过期时自动重新登录获取而不是永远靠手动维护。3. 数据解析的三板斧正则、XPath、CSS选择器怎么选拿到HTML响应只是第一步真正的技术含量在于从一堆标签结构中精准提取目标数据。解析方式选错轻则代码写着难受重则解析效率低到影响整体爬取速度。我一般把解析工具分成三条路线正则、XPath/CSS、JSON解析对接口三者各有适用场景。3.1 优先JSON专项解析接口数据才是王道先说一个许多教程没强调的观点拿到HTML之前先想想页面数据是不是来自接口。现代Web应用大量使用Ajax下面的HTML往往只是壳真正的数据是通过XHR请求返回的JSON。很多新手在HTML里翻半天找不到自己需要的数据问题就在这里——数据根本不在里面。排查方法非常简单打开浏览器开发者工具的Network面板刷新页面过滤XHR或Fetch请求看响应内容。如果发现某个接口直接返回JSON且字段完整就直接用resp.json()来解析完全绕开HTML解析的繁琐流程。比如查王者战绩这类需求只要能在接口层拿到数据解析效率比从HTML抠快一个数量级。我管这个叫链路前置——在越靠近服务器的位置拿数据越省力。3.2 正则适合小而美但注意它的代价正则表达式在文本提取领域的地位依然不可替代尤其是处理JSON片段、脚本变量赋值这类非HTML结构时。比如页面里有个变量叫var videoInfo {...}你要提取里面某个字段用XPath反而麻烦正则一个var videoInfo (.*?);就能搞定。正则在爬虫解析里的典型应用场景有三个从内联JSON提取数据、从URL提取参数、清洗文本中夹带的杂质。但它对HTML的处理能力很弱——HTML标签结构允许嵌套和属性变化正则的上下文无关文法能力处理不了这种复杂结构。强行用正则会得到一堆充满bug的表达式。而且正则还有一个被忽视的问题可读性和可维护性。两周后回看自己写的一长串转义字符真的会怀疑人生。我的建议是正则适合在数据形态稳定的场景做精细提取整个页面的结构化解析还是交给XPath或CSS选择器。3.3 XPath与CSS选择器lxml和BeautifulSoup的配合逻辑lxml是我个人最推荐的HTML解析库性能强、API简洁对XPath的支持非常完善。BeautifulSoup虽然名声在外但底层解析速度比lxml慢不少当页面很大、请求量又高时这个差异会被放大——我实测过一个500KB的页面lxml的解析耗时大约是BeautifulSoup的十分之一。用XPath做结构化解析核心思路是找到锚点节点再基于锚点做相对定位。比如from lxml import etree html div classproduct-list div classitem>from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context( user_agentMozilla/5.0 ..., ) page context.new_page() page.goto(https://example.com, timeout30000) # 等待某个元素出现而不是盲目sleep page.wait_for_selector(.product-item, timeout10000) # 直接拿渲染后的HTML html page.content() browser.close()核心操作是wait_for_selector——它会等待目标元素出现在DOM中再继续执行比固定time.sleep(3)的容错高得多。网络慢时sleep不够导致元素没渲染网络快时又白白多等好几秒而等待选择器则快慢自适应这是被很多人低估的细节。4. 并发与架构进阶从能跑到能扛的跃迁当你的爬虫从爬一个页面变成爬一个站点性能瓶颈会立刻浮现单线程串行请求一小时爬几千个页面到头了再加点重试和解析时间速度更是堪忧。这个阶段你需要考虑并发再往后则要考虑分布式和框架化。4.1 多线程、协程还是异步Python的GIL决定了多线程在CPU密集型任务上无法并行但在IO密集型场景爬虫就是典型的IO密集下多线程依然有效——网络请求的等待时间都花在IO上线程切换是主动让出。但要论资源利用率和吞吐量协程才是爬虫的最佳选择。协程的单线程事件循环模型配合aiohttp做异步HTTP请求能把等待时间压缩到极小同一时间发起的请求数量远高于多线程方案。一个典型的异步爬虫结构如下import asyncio import aiohttp async def fetch_one(session, url, semaphore): async with semaphore: try: async with session.get(url, timeout10) as resp: return await resp.text() except aiohttp.ClientError as e: print(f请求失败: {url} - {e}) return None async def main(urls): semaphore asyncio.Semaphore(20) # 限制最大并发数防止打爆对方服务 async with aiohttp.ClientSession() as session: tasks [fetch_one(session, url, semaphore) for url in urls] results await asyncio.gather(*tasks) return results这里有个新手常犯的错误并发数不是越大越好。网络带宽、目标网站承受能力、自身内存占用都是约束条件。我一般建议同时活跃请求数控制在20-50之间既能获得足够高的吞吐又不至于因为过度并发导致目标站反爬升级或自己被识别为攻击流量。4.2 Scrapy框架为什么生产环境我更推荐它自己写异步爬虫能学到很多东西但真要做一个中大型采集项目我还是推荐直接用Scrapy。这不是说Scrapy有多万能而是它在工程化层面把爬虫开发者需要踩的坑提前填平了。Scrapy的核心组件包括引擎Engine、调度器Scheduler、下载器Downloader、爬虫Spider、管道Pipeline、中间件Middleware。初次接触会觉得很复杂但它解决的都是实际问题去重通过请求的指纹判断URL是否爬过自带Bloom Filter思路的RFPDupeFilter不用自己写set逻辑。请求调度支持优先级、深度优先/广度优先、延迟设置调度器帮你管理海量请求。中间件机制UA切换、代理切换、Cookie同步都有现成的扩展点我见过很多人花一天时间自己实现的功能在Scrapy里只是写一个中间件类而已。Pipeline流水线数据清洗、格式转换、存储入库可以拆成多个阶段异步执行对数据的处理顺序不用手动编排。一个典型的Scrapy爬虫文件骨架import scrapy class BookSpider(scrapy.Spider): name book_spider def start_requests(self): for page in range(1, 11): yield scrapy.Request( urlfhttps://example.com/books?page{page}, callbackself.parse_list, ) def parse_list(self, response): # 用response.css或response.xpath提取数据 for item in response.css(.book-item): yield { title: item.css(.title::text).get(), price: item.css(.price::text).get().strip(), }Scrapy里最值得琢磨的其实是download_delay参数。它会在请求之间插入间隔既是对目标站的保护也是对自己IP的保护。我看到很多人一上来就把并发拉到100、delay设成0结果半小时后被目标站的防火墙封了整个IP段这种事故在爬虫圈太常见了。设置合理延迟的反而是长跑选手。4.3 分布式爬虫和存储选型数据量大了怎么办单机Scrapy爬到一定规模也会到天花板——CPU可能不是瓶颈但单机的网络连接数、文件描述符上限、内存占用都是限制。这时候分布式架构就该登场了。主流的方案是Scrapy Redis用Redis存储待爬取的请求队列多个Scrapy实例共享这个队列谁空闲谁领取任务。配合scrapy-redis这个扩展改造起来非常简单# settings.py中只需改动几行 SCHEDULER scrapy.core.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter SCHEDULER_QUEUE_CLASS scrapy_redis.queue.SpiderQueue核心思想是把请求去重和请求调度这两件事从单机内存挪到Redis这样所有爬虫节点看到的都是同一个任务池。设计上要注意给每个节点分配独立的下载延迟和UA特征避免所有节点行为模式一模一样反而更容易被反爬识别。存储层选型取决于数据形态和查询需求。数据量在万级以内CSV或SQLite足够要在服务端做复杂查询MySQL或PostgreSQL合适如果数据量到达千万级别且要做聚合分析ClickHouse这类列式存储才派得上用场。一个常被忽略的点是爬虫的数据清洗应当在Pipeline里完成而不是入库之后再做。数据库不是万能的数据清洗工具先清洗再入库能省掉很多麻烦。5. 反爬的工程化应对从见招拆招到策略预案反爬手段千变万化但归结起来只有几大类请求头校验、访问频率限制、行为模式检测、验证码、数据加密。每一类都有自己的应对思路但要记住一个核心原则——反爬对抗的本质是匹配目标站的商业风险等级。对方付出多大成本做防护你就需要付出相应的成本去应对没有银弹。5.1 频率限制与IP封锁的处理几乎所有爬虫项目都会遇到403 Forbidden或429 Too Many Requests这是服务器在明确告诉你你的请求频率太高了。有些站不做复杂验证单纯靠IP限频有些站则会在限频前先返回警告头。应对频率限制的基本操作是控制并发、增加下载延迟、配置代理池。代理池这块我想多说几句——很多人以为配置了代理就万事大吉其实代理池的质量远远比数量重要。免费代理的稳定性极差频繁失效反而会增加请求耗时和重试开销付费代理池也分共享版和独享版做高价值采集项目时选择独享代理更稳妥。代理池的管理是个持续过程。我的做法是维护一个中间件每次请求前从池中取一个代理请求成功后记录该代理的成功次数失败则扣除信用分分数过低自动踢出池子。这样经过一段时间的积累池子里剩下的都是在目标网站上成功率高的代理效率显著提升。5.2 验证码与登录态能避则避避不开再识别验证码是爬虫最头疼的问题之一。但我发现很多团队的思路有问题一遇到验证码就想着用OCR去识别而忽略了有没有可能压根不触发验证码。统计下来大多数验证码触发的根源是频率异常——普通用户一小时最多翻几十页你一分钟请求几百次不弹验证码才怪。所以我的建议是先调整请求策略把频率降到接近真实用户的水平观察验证码是否消失。如果还有再考虑打码平台或本地OCR。打码平台的接入本身很简单麻烦的是成本和延迟控制——同一验证码多次识别失败会拖延整个请求流程需要设置超时放弃和失败重试。登录态的处理思路前面已经有Cookie管理的基础这里补充一个细节很多站点有登录之后看全部数据的机制但爬虫部署在服务器上没有浏览器环境Cookie会很快过期。我的做法是提前写好自动化登录脚本定期通过Playwright模拟登录把新Cookie回写配置实现无人工干预的登录态保鲜。5.3 JS加密与字体反爬高阶对抗的逻辑JS加密和字体反爬是这几年很常见的反爬手段。JS加密的作用是即使你找到了接口接口返回的数据也是加密的必须在浏览器环境里执行JS才能还原。字体反爬则是通过自定义字体文件将页面中的文字映射改成乱码正常浏览器能渲染出来爬虫拿到的源码却是一堆没意义的偏移。针对JS加密常规思路是断点调试找到加密方法然后用Python复现或用PyMiniRacer、Node.js子进程执行同样的JS逻辑。这个方向对前端功底有一定要求也是爬虫工程师深入进阶的分水岭。我的经验是先尝试用Playwright直接执行页面里的JS上下文获取解密后的数据这比逆向加密算法快得多适合加密逻辑对性能压力不大的场景。字体反爬的破解思路则是下载页面引用的woff/ttf字体文件解析字体表映射。这里有个比较实际的小技巧很多站点只用一套自定义字体字体文件本身变化不频繁把解析结果缓存下来几百个页面只在第一次请求时需要做映射后面的请求直接复用能省很多计算时间。5.4 合规红线爬虫能做不等于可以滥用最后这一点是我特别想向每一个爬虫开发者强调的。技术能力从来不等于使用权限爬虫采集数据要守住几条基本线第一尊重robots.txt。它是网站在爬虫协议层面对爬取范围的声明。不意味着违反它就会立刻收到法律警告但它是判断行为是否合规的重要参考。第二控制频率和规模。正常采集要像正常访问一样温和不搞撞库式抓取。第三不采集个人信息和受版权保护的内容。用户隐私数据、付费内容、未公开发表的内容都不应该出现在爬虫目标里。第四不做商业化滥用。爬取竞品数据用于商业决策在多数情况下有合规风险。我在实际项目中收到过开发者私信说自己只爬完全公开、无登录限制的数据但仍被对方发了律师函——原因是他爬取的数据被用于商业分析对方主张数据权益。所以我的判断标准是不仅要看数据是否公开还要看数据来源、爬取方式和后续用途。在这个前提下遵守目标站点的服务条款配合Table of Contents和Privacy Policy理解双方权责比任何技术手段都重要。做爬虫这些年我最大的体会是真正的技术壁垒从来不在能不能爬到而在能不能稳定、高效、合规地长期运行。很多人初学爬虫时追求花哨的工具和框架但如果不能把requests的请求头、协程的并发控制、中间件的扩展逻辑这些基本功吃透再高级的工具也只是空中楼阁。从最基础的底层原理开始打磨等你能把一个看似简单的采集任务做到长期稳定运行才是真正从会写爬虫迈向了懂爬虫工程。
RELATED READING

延伸阅读

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