ARTICLE DETAIL

资讯详情

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

Scrapy中间件实战:自定义请求头与代理池的闭环设计

Scrapy中间件实战:自定义请求头与代理池的闭环设计 先说一个我踩过的真实场景做一个行业数据采集项目最初图省事直接在Spider里给每个Request手动塞headers、手动换代理。前两周一切正常直到某天早上醒来任务积压了几万条日志里密密麻麻全是403和Connection reset。那时候我才意识到Scrapy中间件不是进阶优化项而是正经爬虫工程的刚性需求。这篇文章就围绕自定义请求头和代理池这两个DownloaderMiddleware把我实际用过的实现、踩过的坑、以及上生产前要调的参数一次说清楚。内容适合刚把Scrapy基本流程跑通、准备上量或进生产环境的朋友也适合那些项目跑着跑着突然被目标站点点名的人。1. 为什么这两个功能必须放在中间件链路里处理先梳理一个最简单的请求生命周期。Engine从Scheduler拿到一个Request交给Downloader之前会逐个执行DOWNLOADER_MIDDLEWARES里注册的所有中间件下载完成拿到Response后同样要再走一遍中间的process_response链。中间件和Spider中间件最大的区别在于它夹在请求发出前和响应回来后这个网络节点两侧能改的、能拦的、能重发的都在这条链上。1.1 process_request / process_response / process_exception 的返回值语义DownloaderMiddleware里真正干活的方法就三个返回值不同行为完全不同process_request返回None请求继续往下游中间件走最终到达Downloader发出process_request返回Response请求压根不会发出直接用这个响应继续流程process_request返回Request原请求被丢弃新Request重新进入调度process_response返回Response继续往上传给Spiderprocess_response返回Request这个响应被丢弃新请求重新排队下载process_exception捕获到异常后同样可以返回Request触发重试或者返回Response继续流程。很多新手不知道process_request还能返回Request这个特性在代理失效重试时非常关键后面代理池那一节会反复用到。1.2 改headers和代理为什么不能只写在Spider里直接在Spider里给Request设置headers当然能生效但有两层问题。第一层当项目里有几十个Spider、上百个解析规则时每个地方都要维护一份headers逻辑迟早会出现某个Spider漏改、某个Spider改错的情况第二层headers的生成策略往往要依赖上下文比如当前用了哪个代理、这个session之前用过哪个User-Agent这些状态放在Spider里很难统一管理。代理更是如此。proxy是通过Request.meta[proxy]传给下载器的理论上你能在每个Request里手动指定但一旦代理失效需要重试、需要换下一个IP手动方案根本接不住这种动态变化。只有放在中间件里才能同时做到请求发出前注入和响应回来后感知结果。1.3 常见的误区把UA写死在DEFAULT_REQUEST_HEADERS有个偷懒方案是直接在settings.py里配置DEFAULT_REQUEST_HEADERS { User-Agent: Mozilla/5.0 (...) Chrome/122.0 Safari/537.36, Accept: text/html,..., }这种做法本身没错但它是一个固定值。当你的请求量破千、并发上来之后目标站点只要按User-Agent做聚合统计很快就能把这个UA标记为异常流量。在中间件里维护一个UA池、加一点轮换和频率控制才能从根本上解决千篇一律的问题。2. 自定义请求头中间件的落地从随机UA到浏览器级一致性2.1 自己维护UA池比网上那些fake-useragent依赖更靠谱很多教程推荐用fake-useragent库用法确实一行搞定from fake_useragent import UserAgent ua UserAgent().random但fake-useragent内部依赖在线接口拉取最新UA数据实际生产环境里遇到过两个问题一是接口限流跑着跑着抛异常二是接口本身不稳定偶发超时直接拖慢整个爬取流程。我现在倾向于在项目里维护一份离线UA池可以根据目标站点版本手工维护比如UA_POOL [ 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, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/122.0.0.0 Safari/537.36, ]一份50条左右的池子够用了。关键不是数量多而是覆盖主流浏览器版本和操作系统组合。2.2 核心细节同一个session不能每个请求都换UA这是我在实际项目中踩过的最深的坑。最开始我写了个简单的随机UA中间件每个process_request都从池子里随机挑一个结果UA是够随机了但后端指纹系统很快就发现了异常同一个IP在2分钟内UA从Chrome 122跳到Firefox 123、系统从Windows跳到Mac这在真实用户行为里几乎不可能发生等于主动挂了个我是脚本的牌子。正确做法是加频率控制。默认一个时间段内保持同一个UA也可以在做任务切换、换出口IP时才轮换。下面是我常用的实现import random import time class RandomUserAgentMiddleware: def __init__(self, ua_pool, change_interval): self.ua_pool ua_pool self.change_interval change_interval self._current_ua None self._last_changed 0 classmethod def from_crawler(cls, crawler): return cls( ua_poolcrawler.settings.getlist(USER_AGENT_POOL), change_intervalcrawler.settings.getfloat(UA_CHANGE_INTERVAL, 300), ) def process_request(self, request, spider): if not request.headers.get(User-Agent): request.headers[User-Agent] self._get_ua() def _get_ua(self): now time.time() if self._current_ua is None or now - self._last_changed self.change_interval: self._current_ua random.choice(self.ua_pool) self._last_changed now return self._current_uaUA_CHANGE_INTERVAL默认300秒也就是5分钟换一次。如果你的爬虫每个任务周期比较长也可以改成每次切换代理后强制换UA这个逻辑后面结合代理池实现会更好。2.3 只改UA远远不够headers要一起换才叫伪装很多人改完UA就以为完事了结果请求头里还留着Python默认的Accept、Accept-Encoding这在目标站点的请求日志里一眼就能看出来。一个真实浏览器发出去的请求头部字段一般长这样字段典型值说明Accepttext/html,application/xhtmlxml,...浏览器会带上多种MIME类型Accept-Languagezh-CN,zh;q0.9,en;q0.8带q权重值Accept-Encodinggzip, deflate, br压缩格式要匹配Referer来源页面URL从哪个页面跳转过来的Sec-Fetch-DestdocumentChrome安全上下文请求标记Sec-Fetch-Modenavigate导航请求Sec-Fetch-Sitesame-origin / cross-site同源还是跨站Sec-Ch-UaChromium;v122, ...Chrome客户端提示头Upgrade-Insecure-Requests1HTTPS升级标识我给这个中间件起名叫BrowserHeadersMiddleware它负责补全这些字段同时保留请求里已有的自定义值请求里已经带了的header不覆盖这样可以兼顾下游Spider的特殊需求。class BrowserHeadersMiddleware: DEFAULT_HEADERS { Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: none, } def process_request(self, request, spider): for key, value in self.DEFAULT_HEADERS.items(): if not request.headers.get(key): request.headers[key] value这里的Sec-Fetch-*是Chrome从76版本开始加的请求来源标识Python的requests库默认不生成所以带上这组头反而更像真浏览器。顺带提一句Sec-Fetch-Site要根据来源判断如果是从别的站点跳转进来的要改成cross-site。2.4 Referer的动态策略列表页和详情页的衔接业务上常见的结构是先爬列表页再进详情页。此时详情页请求的Referer应该指向它来源的那个列表页。如果你的项目里有这种层级关系建议不要把所有请求的Referer都写死成首页而是通过Request.meta往下传def start_requests(self): list_url https://example.com/list?page1 yield scrapy.Request(list_url, meta{current_page: True}) def parse_list(self, response): for item in response.css(a.detail::attr(href)).getall(): yield scrapy.Request( response.urljoin(item), meta{referer: response.url}, callbackself.parse_detail, )中间件里再做一个兜底补全def process_request(self, request, spider): referer request.meta.get(referer) if referer and not request.headers.get(Referer): request.headers[Referer] referer这样详情页请求就天然带着来源页URL避免列表页到详情页的跳转链断裂被风控拦截。3. 代理池的落地从随机取IP到坏了自动换的闭环设计3.1 先理清代理的匿名度层级代理IP池的实现首先要搞清楚你手里的是哪类代理匿名度目标服务器能看到什么适用场景透明代理能看到你的真实IP也会标记为代理基本不推荐普通匿名看不到真实IP但会标记Via/ X-Forwarded-For部分场景可用高匿代理看不到真实IP也不标记为代理爬虫首选选代理的时候别贪便宜透明代理等于把自己真实IP暴露给目标站点匿名度不够的代理在风控严格的站点上大概率一两轮就被识别。3.2 代理池的基本接口get_proxy和report_fail代理池的核心不只是一份IP列表而是两个动作拿IP、报告IP使用结果。我常用的内存版实现如下import queue import threading class InMemoryProxyPool: def __init__(self, proxies): self._queue queue.Queue() for proxy in proxies: self._queue.put(proxy) self._lock threading.Lock() self._fail_counts {} def get_proxy(self): try: proxy self._queue.get_nowait() self._queue.put(proxy) return proxy except queue.Empty: return None def report_fail(self, proxy): if not proxy: return with self._lock: self._fail_counts[proxy] self._fail_counts.get(proxy, 0) 1 if self._fail_counts[proxy] 3: self._fail_counts.pop(proxy)这里get_proxy用了取出后再放回队尾的方式简单实现轮转report_fail里累加失败次数连续失败3次就暂时从池子里剔除。生产环境规模上来后建议用Redis存代理池LPOP/RPUSH实现轮转、SETNX记录失败次数原理一样。3.3 ProxyMiddleware的完整实现代理中间件最核心的逻辑是三个请求发出前分配代理、响应回来后判断结果、异常时触发重试。class ProxyMiddleware: def __init__(self, proxy_pool): self.proxy_pool proxy_pool classmethod def from_crawler(cls, crawler): proxies crawler.settings.getlist(PROXY_POOL) return cls(InMemoryProxyPool(proxies)) def process_request(self, request, spider): if proxy in request.meta: return proxy self.proxy_pool.get_proxy() if not proxy: spider.logger.warning(proxy pool is empty) return request.meta[proxy] proxy request.meta.setdefault(proxy_retry_times, 0) def process_response(self, request, response, spider): if response.status in (403, 418, 429): spider.logger.warning( proxy %s got status %s: %s, request.meta.get(proxy), response.status, response.url ) self.proxy_pool.report_fail(request.meta.get(proxy)) return self._retry(request, spider) return response def process_exception(self, request, exception, spider): if self._is_proxy_exception(exception): spider.logger.warning( proxy %s failed: %r, request.meta.get(proxy), exception ) self.proxy_pool.report_fail(request.meta.get(proxy)) return self._retry(request, spider) return None def _retry(self, request, spider): retry_times request.meta.get(proxy_retry_times, 0) 1 if retry_times 3: spider.logger.error(proxy retry exceeded: %s, request.url) return None new_request request.copy() new_request.meta[proxy_retry_times] retry_times new_request.meta.pop(proxy, None) new_request.priority request.priority - 1 new_request.dont_filter True return new_request def _is_proxy_exception(self, exception): name exception.__class__.__name__ return any(key in name for key in (ProxyError, Timeout, Connection, RemoteDisconnected))process_request里有个判断如果meta里已经有proxy就尊重它。这个设计很重要因为某些场景下你想让某个请求固定走指定出口IP比如登录后要维持会话此时不能强制覆盖。3.4 process_response里要做的事区分目标封了和代理挂了代理池最常见的问题就是拿到一个状态码403后下意识觉得是代理挂了然后换下一个代理重试。但403往往分两类一类是代理出口IP本身被目标站拉黑这类确实该换IP另一类是请求指纹太假、目标站点触发反爬这时候换十个IP也没用。所以process_response里不能只看状态码还要看响应体内容。BAN_TEXT_MARKERS (captcha, verify, security check, 访问验证, 滑动验证) def _looks_like_ban(self, response): if response.status in (403, 418, 429): return True snippets response.text[:2000].lower() return any(marker.lower() in snippets for marker in BAN_TEXT_MARKERS)如果判断是目标封了重试策略就要谨慎通常我会把重试次数限制到1-2次并在日志里标注这是反爬升级而不是代理质量问题方便单独处理。3.5 代理的验证与补充别拿业务请求当探针内存代理池只能解决取IP、换IP的基本问题真正生产环境里的代理池还要有个验证器。我的做法是单独起一个后台线程定时用代理池里的IP请求一个稳定页面比如目标站点首页检测响应时间和状态码。这样做的好处是把代理健康检查和业务流量隔离开业务请求用来产出数据验证请求用来维护池子水质两者混在一起会导致一个坏代理反复出现在业务链路上。验证逻辑也很简单对每个代理执行一次HTTP GET如果连续失败3次就移出池子如果恢复则重新放回。4. 重试与异常闭环避免重试风暴和死循环的三个关键点4.1 process_exception不能漏写很多中间件示例里只有process_request和process_response这是不够的。代理链路经常出现TCP层异常比如ConnectTimeout、ProxyError、ConnectionResetByPeer这些异常不会走进process_response而是直接进process_exception。如果不在这里写清理逻辑坏代理会被反复使用直到系统彻底卡死。上面的ProxyMiddleware示例里已经包含了process_exception它会标记当前代理失效并通过_retry方法返回一个新Request。这里要注意一点process_exception返回Request之后这个Request会重新进入调度队列经过所有中间件的process_request所以旧的proxy必须pop掉否则会一直用同一个坏代理。4.2 控制重试的优先级和次数防止队头阻塞重试请求返回给Scheduler时如果不做任何处理它和普通请求在同一条队列里排队。失败越多的请求积压越多会挤压正常任务最后整个引擎陷入疯狂重试但毫无产出的状态。我的做法是每重试一次把新Request的priority减1。Scrapy里priority数值越大越先被执行所以priority-1意味着失败请求的优先级越来越低不会堵住正常任务。new_request.priority request.priority - 1配合最大重试次数3次既保证坏代理不会无限重试又保证重试请求不会喧宾夺主。4.3 重试请求和去重机制的纠缠Scrapy默认的Scheduler会对新入队的Request做去重判断。重试生成的Request和原始Request的指纹相同如果原始Request还在待执行队列里重试Request就会被去重丢弃结果就是你看到日志里一直在重试但实际请求根本没发出去。标准解法是在重试Request上设置dont_filterTrue让它绕过去重器直接入队。这也是4.3节示例代码里那一行new_request.dont_filter True存在的意义。4.4 用StatsCollector做可观测性当代理池规模上百时必须把失败信息汇总到Scrapy的StatsCollector里否则排查问题时只能翻海量日志。中间件的from_crawler方法里拿到crawler.stats然后在关键节点增加计数crawler.stats.inc_value(proxy/request_count) crawler.stats.inc_value(proxy/fail_count) crawler.stats.inc_value(proxy/retry_count)crawl结束后scrapy stats输出里就能直接看到代理失败率这个指标对判断代理池整体健康度非常关键。5. 动态页面场景这套中间件和Playwright、iframe怎么配合现在的站点大量使用动态渲染响应里返回的是空壳HTML真正内容在JS执行后才出现。这类场景要上scrapy-playwright但不少人对这两者的关系有误解以为playwright也是一个DownloaderMiddleware实际上它底层走的是DownloadHandler而不需要你手动把它注册进DOWNLOADER_MIDDLEWARES。5.1 我的自定义中间件对Playwright请求仍然生效下载中间件的作用范围是所有经过Downloader的Request包括meta里标记playwrightTrue的请求。也就是说前面两节写的RandomUserAgentMiddleware和ProxyMiddleware在动态渲染场景下依然能工作。需要留意的是headers的传递路径。scrapy-playwright会读取两个地方Request.headers以及meta里的playwright_headers。如果你的某些header只想用于浏览器首次导航不想污染子资源请求可以把它们放在playwright_headers里。比如yield scrapy.Request( url, meta{ playwright: True, playwright_headers: { Accept-Language: zh-CN,zh;q0.9, }, }, )5.2 大坑浏览器context的UA和请求头UA不一致中间件设置Request.headers里的User-Agent和Playwright浏览器context实际使用的UA是两个独立的东西。Server在收到浏览器请求时看到的PlayerUA很可能来自浏览器的启动参数而不是你的Request headers。如果两者不一致等于告诉目标服务器我在请求层假装Chrome但内核其实是Chromium。解决办法是在创建Playwright context时就主动指定UAmeta{ playwright: True, playwright_context_kwargs: { user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, }, }这样浏览器发出的所有子请求都会带上同一个UA。5.3 iframe动态内容的处理要点很多登录后的数据面板结构是iframe嵌套。Playwright访问iframe里内容的标准姿势是遍历page.frames然后对目标frame做定位for frame in page.frames: if dashboard in frame.url: yield frame.locator(table tbody tr).all_text_contents()这里有个和代理池强相关的细节iframe可能是跨域的浏览器会为它单独发起网络请求。如果你把iframe的URL取出来丢给一个新的Scrapy Request去抓这个新请求会重新进入代理池很可能分配到另一个出口IP导致主页面走A IP、iframe内容走B IP一旦目标站点做会话关联登录态就废了。正确的做法是把这个iframe请求也塞进主页面同一个代理request.meta[proxy] main_page_proxy这比每次取一个新代理更符合浏览器真实行为。5.4 Cookie同步中间件不管但你得管Playwright context自己的Cookie和Scrapy的CookieJar是隔离的这也是动态页面场景里最常见的一个看起来中间件没生效的问题。浏览器上登录一个站点后Cookie只在context里Scrapy后续的普通Request不会自动带上。如果后续请求想复用浏览器登录态需要手动把context的cookies导出并写进Scrapy的cookie jar。类似这样cookies await context.cookies() for cookie in cookies: self.cookiejar.set_cookie(cookie)反过来也一样你在Scrapy里手动构造的Cookie要想让Playwright带上需要调用context.add_cookies。6. 上生产之前几个参数和一次完整的排查记录6.1 我的常用中间件配置与顺序DOWNLOADER_MIDDLEWARES的顺序用数字控制数字越小越先执行。实际项目中我建议这样配DOWNLOADER_MIDDLEWARES { myproject.middlewares.RandomUserAgentMiddleware: 400, myproject.middlewares.BrowserHeadersMiddleware: 450, scrapy.downloadermiddlewares.retry.RetryMiddleware: 550, myproject.middlewares.ProxyMiddleware: 600, }UA和浏览器头中间件必须放在靠前的位置因为它们负责化妆RetryMiddleware放在中间它处理的是HTTP错误码ProxyMiddleware放在后面这样当RetryMiddleware决定重试时ProxyMiddleware还能在process_request阶段为新Request重新分配代理。其它关键参数DOWNLOAD_TIMEOUT 15 CONCURRENT_REQUESTS 16 CONCURRENT_REQUESTS_PER_DOMAIN 4 RETRY_TIMES 3 RETRY_HTTP_CODES [500, 502, 503, 504, 522, 524, 408, 429]DOWNLOAD_TIMEOUT设太短代理容易误判失效太长又拖慢整体速度15秒是我在多数站点上试下来比较稳的值。6.2 一次403问题从出现到定位的完整排查链路记录一次真实经历某个数据源突然大面积403日志里的错误分布非常规律所有出口IP都有并非个别代理失效。我看了一眼UA中间件日志UA切换频率正常又看了TLS层问题是依赖库里默认的HTTP/1.1连接方式在TLS ClientHello指纹上和标准浏览器差异太大部分反爬系统靠这个就能识别脚本客户端。换用更高匿名的代理之后情况明显缓解但真正解决还是靠统一TLS指纹和真实浏览器保持一致。遇到403我建议按这个顺序排查先确认是不是所有IP一起挂还是只有个别IP挂再看返回页面里有没有验证码、滑块标识再回头检查headers和UA是否和浏览器一致如果都没问题才值得怀疑是出口IP污点导致。把排查顺序固定下来能省掉大量盲目换代理的时间。6.3 代理池熔断与恢复的小建议代理池全挂的情况我一定见过比如供应商线路故障、计费到期。全挂时最怕的是所有请求拼命重试不仅没产出还会对目标站点造成额外压力。我的经验是设定一个熔断窗口如果在30秒内代理失败率超过70%就暂停新请求的代理分配让后台验证器单独去跑健康检查等可用代理恢复到一定数量再放量。代码上就是在ProxyMiddleware里加一个开关状态验证器恢复后自动关闭熔断。6.4 最后分享一个小技巧新写的中间件上线前先拿一个测试Spider加DEBUG日志跑一遍观察日志里每个请求走到了哪一层、哪一层返回了Request或Response确认链路符合预期后再放量。很多人一上来就直接全量跑结果中间件里一个dont_filter没设就像我前面说的重试请求被去重器吃掉整个任务在空转排查起来非常痛苦。中间件这个东西平时安安静静地躺在请求链路上只有出了问题你才会意识到它的存在感。把自定义请求头和代理池这两个中间件设计成能感知结果、能动态调整、能自我恢复的完整闭环比在Spider里写一万行防御逻辑都管用。
返回列表