
做过几年爬虫的朋友大概都有这种体会真正卡住你的往往不是解析逻辑写不出来而是你根本不知道对面服务器想要什么、又回了你什么。请求头、响应头、HTTP 与 HTTPS 的差异、状态码这四样东西合起来其实是一套“对话规则”——你按对方的规矩开口对方才愿意把数据递给你。我见过太多人一上来就requests.get(url)一把梭跑通两个站点后开始怀疑人生有的返回 403有的返回一坨乱码有的干脆给你一个空壳 HTML真正的数据藏在后面的接口里。这些问题九成以上都能在报文层面找到答案而不是靠反复改代码碰运气。这篇内容就是把爬虫视角下的 Web 基础重新捋一遍请求头每个字段到底在告诉服务器什么、响应头里哪些字段决定了你拿到的是乱码还是正常文本、HTTP 和 HTTPS 在报文层面的真实差别在哪、状态码背后对应的是哪种处理策略。我会把每个知识点都落到可直接抄的代码和排查路径上而不是停在概念解释。适合刚写完第一个爬虫、开始频繁撞墙的同学也适合写了很久但一直靠“抄 headers”过日子的老手——很多人抄了几年 headers其实没弄明白自己抄的每一行是干什么的一旦站点改版就彻底抓瞎。1. 先把这条“对话链”看清楚爬虫到底在跟谁说话1.1 一次请求从头到尾发生了什么很多人对爬虫的心智模型是“我发一个网址它给我一段 HTML”。这个模型太粗了粗到你没法定位问题。真实的过程是这样的你的程序先做域名解析拿到 IP然后跟对方的 443 或 80 端口建立 TCP 连接如果是 HTTPS 还要多做一轮 TLS 握手协商密钥握手完成后才把一段符合 HTTP 规范的纯文本HTTP/1.1 及以下或者二进制帧HTTP/2发过去。这段文本分成三部分请求行、请求头、请求体。服务器读完回你一段结构对称的东西状态行、响应头、响应体。关键在于响应体只是整段回话的最后一部分前面那一堆头部字段才是服务器在“解释”这段回话。它告诉你这段内容是什么类型、多长、用什么字符集编码、要不要缓存、下次该带什么凭证再来。你只取resp.text而忽略resp.headers等于别人给你寄了个包裹你只拆了里面的东西把贴在箱子上的运单扔了——而运单上写着“易碎”“冷藏”“到付”全是关键信息。我在实际项目里养成的一个习惯是新站点第一次请求先把resp.status_code、resp.headers、resp.text[:500]三个一起打出来看。这五秒钟的动作能省掉后面半小时的瞎猜。1.2 请求行、请求头、请求体的分工请求行只有三段方法、路径、协议版本比如GET /api/list?page2 HTTP/1.1。方法决定了这次交互的语义——GET 表示“我要读”POST 表示“我要提交”HEAD 表示“我只要头不要体”OPTIONS 常用于跨域预检。路径里带查询串的部分就是常说的 query string。请求头是一堆Key: Value每个字段都在回答服务器心里的一个疑问你是谁User-Agent、你从哪儿来Referer、你带着什么凭证Cookie、Authorization、你能接受什么格式Accept、你能接受什么压缩方式Accept-Encoding、你提交的内容是什么格式Content-Type。服务器端的风控、路由、缓存、内容协商全都是读这些字段来做判断的。请求体则是 POST、PUT 这类方法才有的东西表单、JSON、文件上传都放这儿。GET 请求理论上不该带体虽然协议没完全禁止但很多中间层会直接丢掉。提示请求头字段名大小写不敏感但值通常敏感。尤其是自定义的签名头多一个空格都可能校验失败。1.3 为什么新手最容易在这四块翻车翻车点特别集中第一类是 User-Agent 用了库的默认值Python requests 默认的 UA 是一串明显的程序标识很多站点一眼就拦。第二类是没带 Referer某些站点的详情页接口会校验来源页没带就直接 403。第三类是 Cookie 过期了还在用或者多个请求之间该共享的会话没共享。第四类是状态码没判断resp.text拿到的其实是错误页的 HTML解析器一顿操作解析出零条数据你以为是选择器写错了。这四类问题全部属于“报文层面”的问题跟你的解析逻辑一点关系都没有。把这一层吃透你的调试效率会有一个质变。2. HTTP 与 HTTPS报文长得一样路上走的路不一样2.1 HTTP 报文的结构长什么样先说清楚一件事HTTP 是明文协议。你发出去的请求头、请求体在网络上传输的时候就是可读的文本。这也是为什么早期很多教学演示会用一些简单的调试工具直接看到完整报文。一个典型的请求报文长这样GET /search?qpython HTTP/1.1 Host: example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Accept: text/html,application/xhtmlxml,application/xml;q0.9 Accept-Language: zh-CN,zh;q0.9,en;q0.8 Accept-Encoding: gzip, deflate, br Connection: keep-alive Cookie: sessionidabc123; langzh响应报文对称HTTP/1.1 200 OK Date: Tue, 12 Mar 2024 08:31:22 GMT Server: nginx Content-Type: text/html; charsetutf-8 Content-Length: 15320 Content-Encoding: gzip Cache-Control: max-age60 Set-Cookie: trace_id9f2c; Path/ !DOCTYPE html html...看出门道没有首行是元信息头部是元信息只有最后那段才是数据本身。爬虫的绝大多数调试工作都发生在“首行 头部”这两块。HTTP/2 之后报文变成了二进制帧人眼没法直接读但语义完全一致字段还是那些字段只是传输格式换了。这也是为什么你看开发者工具里依然是漂亮的一行行 header——工具帮你解出来了。2.2 HTTPS 加密的到底是哪一段HTTPS 不是一套新协议它就是在 HTTP 下面垫了一层 TLS。垫了这层之后有几个变化你必须知道第一请求行、请求头、请求体全部被加密。这意味着中间的任何一个节点都看不到你带了什么 Cookie、请求了什么路径只能看到你连的是哪个域名SNI和大概的流量大小。第二加密用的是混合方案。握手阶段用非对称加密常见是 ECDHE协商出一个临时的对称密钥后面的数据传输全用对称加密AES 或 ChaCha20。之所以这么设计是因为非对称加密算起来太慢只适合做密钥交换对称加密快适合搬大量数据。这是工程上的取舍不是理论上的花活。第三身份靠证书验证。服务器出示证书证书由受信任的签发机构背书客户端验证证书链、域名匹配、有效期。这一套下来你才能确认对面确实是那个域名的持有者。对爬虫来说最直接的体感是所有主流站点都上了 HTTPS你请求的时候得让底层的 HTTP 客户端信任对方的证书。Python 的 requests 默认会做严格校验遇到自签名证书或者证书链不完整的老站点会直接抛SSLError。2.3 证书校验这个开关什么时候该动什么时候不该动requests.get(url, verifyFalse)是很多人学会的第一招“救命符”。我明确说这个开关在调试自己内部环境的服务时可以用在正式抓取线上数据时不要用。关掉校验意味着你无法确认对面是谁中间任何一个环节都可以冒充数据完整性和来源可信度全部归零。而且 requests 关掉校验后会刷一堆警告很多人顺手urllib3.disable_warnings()把警告也关了等于把唯一的提示也掐了。正确的处理方式通常是补全证书链或者用verify/path/to/ca-bundle.pem指定一套可信根证书。至于超时、重试、连接复用这些才是更值得花时间的地方。import requests session requests.Session() session.verify True # 保持校验 session.timeout (5, 15) # 连接 5 秒读取 15 秒 session.headers.update({ 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, Accept-Language: zh-CN,zh;q0.9, })注意timeout在 Session 上设置单个请求也可以覆盖。不设超时是新手最常见的生产事故来源——某个请求卡住整个线程池被拖死。2.4 开发者工具里怎么观察这条链路浏览器开发者工具的 Network 面板是免费的、最好用的报文观察器。打开之后勾选 Preserve log刷新页面你能看到每一个请求的完整信息Request Headers 里有浏览器自动加的字段Response Headers 是服务器回的Payload 是请求体Preview 和 Response 是响应体。重点看三个地方一是请求方法和你以为的是否一致二是 Response Headers 里的 Content-Type三是 Status 那一列的颜色和数值。有个细节很多人不知道在 Network 面板里右键某个请求可以“Copy as cURL”把整个请求连同 Cookie、UA、Referer 一起复制出来。这是复现请求最快的方式把它粘到终端里跑一遍如果能通说明问题出在你的代码没带全头部如果不通说明还有别的因素比如时间戳签名、来源 IP 限制。3. 请求头你在向服务器做自我介绍3.1 User-Agent最该先对齐的一个字段User-Agent 是服务器判断“来的是浏览器还是程序”的第一依据。它本身是一段格式松散的字符串包含浏览器内核、版本、操作系统信息。历史上它承担了大量兼容性协商的职责所以现在看起来又长又乱但服务器端的风控规则极其依赖它。一个常见的误区是随便抄一个 UA 就完事。实际上不同类型的请求需要的 UA 可能不一样。比如你要抓的是移动端页面用桌面 UA 过去可能被重定向到 PC 版反过来你要抓的是 PC 版接口用移动 UA 又可能拿到不一样的数据结构。更稳妥的做法是和你模拟的浏览器保持一致——如果你在开发者工具里看到的是 Chrome 122 的 UA代码里就用同一个别自己拼一个不存在的版本号。实测下来只要 UA 和后续其他头部Accept、Accept-Language、Sec-CH-UA 系列自洽绝大部分站点的第一层过滤就能过。反过来UA 写着 Chrome 但 Accept 头是 requests 默认的*/*这种不自洽在风控系统眼里非常显眼。3.2 Referer 与 Origin来源校验的两把尺子Referer 告诉服务器“我是从哪个页面点过来的”。很多站点的详情接口会校验它——你直接请求详情接口服务器发现你没有来源页判定为异常返回 403 或者一个空数据。补上Referer: https://example.com/list?page1通常就能解决。Origin 主要出现在跨域场景和 POST 请求里格式是协议://域名[:端口]不带路径。它在 CORS 预检和部分站点的写操作校验里很关键。两者的区别值得记一下字段是否带路径典型出现场景爬虫里什么时候要补Referer带完整路径页面跳转、资源加载详情接口校验来源页时Origin只到域名跨域请求、POST/PUT接口做了跨域或写操作校验时补这两个字段的时候别随手写成站点首页。有些站点的校验逻辑是“Referer 必须包含某个路径前缀”写成首页反而不通过。多看两眼开发者工具里真实请求带的是什么。3.3 Cookie会话状态的载体Cookie 是服务器通过Set-Cookie下发的键值对浏览器之后每次同域请求都会自动带上。爬虫里最常踩的坑有三个第一会话没有共享。你用requests.get逐条请求每次都是全新的会话服务器第一次下发的 Cookie 你根本没保留第二次请求自然带不上。正确姿势是用requests.Session()它内部维护一个 CookieJar自动收自动带。第二Cookie 有时效和绑定。很多站点的 Cookie 是有有效期的还有的和首次访问的 IP、UA 做了弱绑定。你从浏览器复制的 Cookie 拿到别的网络环境下用可能直接就失效了。第三只复制了部分 Cookie。开发者工具里 Cookie 面板显示的可能是解析后的视图最好直接从 Request Headers 里整段复制原始 Cookie 字符串。import requests s requests.Session() s.headers.update({ 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, Referer: https://example.com/list, }) # 第一步先访问首页让服务器下发会话 Cookie s.get(https://example.com/, timeout10) # 后续请求自动携带 r s.get(https://example.com/api/items?page1, timeout10) print(r.status_code, len(r.text))提示调试阶段可以手动塞 Cookie但长期运行的采集任务一定要走“先登录/先访问、再自动维持会话”的路径手打的 Cookie 撑不过半天。3.4 Accept 系列内容协商的四件套Accept、Accept-Language、Accept-Encoding、Accept-Charset现在基本不用了合起来是内容协商。服务器读这几个字段决定回你什么格式、什么语言、什么压缩方式。Accept-Encoding特别值得说。它写了gzip, deflate, br服务器可能就用 brBrotli压缩响应体。requests 本身对 gzip 和 deflate 是自动解压的但对 Brotli 需要额外装brotli或brotlicffi库否则你拿到的是一堆二进制乱码而且不报错就是解不出来。我见过有人排查了一下午编码问题最后发现是没装 brotli。Accept-Language影响的不只是界面语言有些站点的接口会按这个字段返回不同的地区数据、货币单位、甚至不同的价格。做数据采集时如果发现拿到的数值跟页面上对不上先看看这个字段。Accept的值建议直接抄浏览器里真实请求的那个长串。手写成*/*虽然也能过但某些做内容协商比较严格的接口会返回 406 或者降级内容。3.5 Content-Type请求体的说明书这个字段只在有请求体的时候才重要但极其重要。表单提交是application/x-www-form-urlencodedJSON 接口是application/json文件上传是multipart/form-data; boundary...。写错了服务器解析不出参数通常返回 400 或者干脆返回一个“参数缺失”的 JSON。用 requests 的时候data传字典会自动按表单编码json传字典会自动设成application/json并序列化。但如果你手动传字符串就得自己把 Content-Type 设对。我习惯是明确写出来不依赖库的推断因为有些老接口对 Content-Type 的边界情况很敏感比如它要求必须带; charsetUTF-8。3.6 那些看起来像乱码的自定义头现代站点的请求头里经常出现一串X-开头的字段或者名字看着像随机字符串的头值可能是一段 Base64 或者十六进制。这类东西通常是前端生成的签名、追踪 ID、防重放随机数。它们的特点是每次请求都不一样且通常由一段 JS 逻辑算出来。遇到这类字段硬猜是没用的。合理的路径是先在开发者工具的 Initiators 里找到发起这个请求的 JS 文件搜索字段名看它的生成逻辑。有些只是简单的时间戳拼接有些涉及复杂的混淆。如果逻辑过于复杂可以评估这个接口是否值得投入——很多时候页面本身的数据足够用不一定非要走那个接口。需要强调的是任何情况下都应该先看对方站点公开的使用条款和 robots 协议确认允许抓取的范围控制请求频率别给别人服务器造成压力。这是所有技术手段的前提。4. 响应头服务器的回话里藏着答案4.1 Content-Type 与字符集乱码问题的总根源resp.text出来一堆问号或者方块八成是编码判断错了。原因是 requests 判断编码的顺序是先看响应头 Content-Type 里有没有charset有就用它没有的话对于 text 类型会按 RFC 默认用 ISO-8859-1对 JSON 会用 UTF-8 猜。ISO-8859-1 这个默认值坑了无数人尤其是国内很多站点响应头里根本不带 charset但页面实际是 GBK 或者 UTF-8。解决办法很直接显式设置编码。import re r requests.get(url, timeout10) ctype r.headers.get(Content-Type, ) m re.search(rcharset([\w\-]), ctype, re.I) if m: r.encoding m.group(1) else: # 头部没写字符集交给内容探测 r.encoding r.apparent_encoding print(r.encoding, r.text[:200])apparent_encoding基于 chardet 之类的探测库对中文内容的判断准确率还不错但它要扫描全文大文件会慢。更稳的做法是优先用头部声明的字符集其次看 HTML 的meta charset最后才用探测。三层兜底下来乱码基本绝迹。4.2 Content-Length 与 Transfer-Encoding身体长度的两种说法Content-Length直接告诉你响应体有多少字节。Transfer-Encoding: chunked则意味着长度未知服务器分块推送每块前面带一个十六进制长度。这个知识对爬虫的直接价值有两个。一是判断响应是否完整你预期拿到 200KB 的页面结果只收到 30KB 就断了大概率是网络中断或者被服务端掐了这时候应该重试而不是拿残缺内容去解析。二是流式下载大文件时可以边收边写避免一次性把几百兆塞进内存。import requests with requests.get(url, streamTrue, timeout30) as r: r.raise_for_status() total int(r.headers.get(Content-Length, 0)) done 0 with open(data.bin, wb) as f: for chunk in r.iter_content(chunk_size64 * 1024): if not chunk: continue f.write(chunk) done len(chunk) if total: print(f\r{done / total:.1%}, end)提示开启了streamTrue之后必须显式消费或关闭响应否则连接不会释放跑大批量任务时会出现连接数耗尽的假象。4.3 Set-Cookie会话的起点响应头里的Set-Cookie每出现一次就是一条 Cookie 指令属性包括Path、Domain、Expires、Max-Age、HttpOnly、Secure、SameSite。用 Session 的时候这些都由库自动处理但你需要知道HttpOnly的含义它表示这个 Cookie 不能被页面脚本读取。这对爬虫的意义是——如果你打算用浏览器自动化方案去取 CookieHttpOnly的那些就取不到得从网络层拿。还有一种情况是服务器一次性下发多条 Set-Cookie它们同名不同 Path。手动维护 Cookie 字符串时很容易漏掉导致某些接口带上错的 Cookie。4.4 Location 与状态码的配合重定向的判断3xx 状态码配Location头表示“你要的东西在别处”。requests 默认会自动跟随重定向allow_redirectsTrue跟随时你会看到最终页面的内容中间的跳转过程被吞掉了。这在调试时很误导人——你以为请求的是 A 地址实际拿到的是跳转三次之后的 C 地址的内容。关掉自动跟随你能清楚看到跳转链条r requests.get(https://example.com/old-path, allow_redirectsFalse, timeout10) print(r.status_code) # 301 / 302 / 307 / 308 print(r.headers.get(Location)) # 目标地址需要留意的细节301 和 308 是永久重定向语义上可以被缓存302 和 307 是临时重定向307 和 308 会保留原始请求方法和请求体而 301 和 302 在历史实现中很多客户端会把 POST 改成 GET。如果你在做带请求体的接口调试这个差别会直接导致结果不一致。4.5 缓存与限流相关的头读懂服务器的态度Cache-Control、ETag、Last-Modified、Expires这一组是缓存控制。对爬虫的用处在于如果你带了If-None-Match或If-Modified-Since去请求服务器判断内容没变会回 304 且不带响应体省流量也省时间。做增量采集的时候这一招非常划算。另一组更值得关注的是限流相关的头。很多接口会在响应头里返回类似X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset的字段直接告诉你配额还剩多少、什么时候重置。看到这类头就应该把它接进调度逻辑里而不是等撞到 429 才被动退避。Retry-After也是同类出现在 429 或 503 响应里值是秒数或者一个 HTTP 时间明确告诉你多久之后再来。这是服务器给出的明确信号照着做比你自己瞎猜退避时间靠谱得多。5. 状态码把服务器的情绪翻译成人话5.1 五类状态码的划分逻辑状态码是三位数第一位定义了类别区间含义爬虫里怎么理解1xx信息性很少直接遇到协议升级、继续发送时出现2xx成功拿到了但要继续看响应体是不是真的是你要的数据3xx重定向东西在别处看 Location注意跟随策略4xx客户端错误你的请求有问题没权限、路径错、参数缺、请求太快5xx服务端错误对方的问题可以重试但别死磕这个划分最大的价值是决定了要不要重试。4xx 类里除了 408 和 429其他基本都是重试无意义的——你 403 了重试一百次还是 403。5xx 和 429 才是值得等一等再来的。5.2 爬虫最常撞上的那十几个码下面这张表是我这些年攒下来的按遇到频率排状态码常见成因处理策略200正常返回检查响应体内容是否符合预期别只看码204成功但无内容接口设计如此不要当作失败重试206部分内容断点续传场景检查 Range 头301/308永久重定向更新你的目标地址长期任务要落库302/307临时重定向跟随即可注意 307 保留请求方法304内容未修改增量采集的正常结果400参数缺失或格式错检查请求体和 Content-Type401未认证Cookie/Token 缺失或过期403无权限或被风控拦截检查 UA、Referer、Cookie也可能是频率过高404路径不存在检查 URL 拼接注意尾斜杠405方法不允许接口只接受 POST你在用 GET408请求超时可重试通常是网络问题413请求体过大分片上传429请求过于频繁按 Retry-After 退避降并发500服务端内部错误可重试但要有次数上限502/504网关错误/超时上游服务挂了退避后重试503服务不可用可能是维护或过载看 Retry-After这里要特别拎出 403 说。403 是个“万能锅”同一个状态码背后可能是完全不同的原因可能是 UA 不对、可能是缺 Referer、可能是 Cookie 过期、可能是请求频率太高触发了临时封禁、也可能是这个接口本身需要某些签名参数。看到 403 别急着换 IP先做排除法把浏览器里能成功的那个请求用“Copy as cURL”完整复制出来跑一遍如果通就逐项对比你的代码少了哪个头如果不通再考虑频率和时间窗的问题。5.3 状态码驱动的重试与退避写法把状态码接进重试逻辑是让爬虫从“能跑”变成“能稳定跑”的分水岭。核心原则只对可恢复的错误重试退避时间指数增长有明确上限并尊重 Retry-After。import time import requests RETRYABLE {408, 429, 500, 502, 503, 504} def fetch(session, url, max_retries4, **kwargs): for attempt in range(max_retries): try: r session.get(url, timeout(5, 20), **kwargs) except (requests.ConnectionError, requests.Timeout): time.sleep(2 ** attempt) continue if r.status_code in RETRYABLE: retry_after r.headers.get(Retry-After) wait int(retry_after) if (retry_after or ).isdigit() else 2 ** attempt time.sleep(min(wait, 60)) continue return r return None这段逻辑里有三个细节值得展开。第一timeout用了元组形式区分连接超时和读取超时前者短后者长因为连接建立通常很快读取慢是因为服务端在生成内容。第二Retry-After优先于自己算的指数退避服务器说了算。第三min(wait, 60)加了上限避免某次异常值把任务挂死一小时。注意重试次数一定要有上限并且要区分“这个 URL 重试失败”和“整个任务失败”。前者记录下来跳过后者才终止。我见过因为一个坏 URL 无限重试把整批任务卡死的案例。6. 实操里最常卡住的几个问题与排查路径6.1 一张速查表顶半小时瞎猜现象最可能的原因第一步怎么查200 但响应体是空壳 HTML数据由 JS 渲染看 Network 里有没有 XHR 请求中文全是乱码字符集判断错打印 Content-Type 看 charset二进制乱码且报解码错Brotli 压缩未解安装 brotli 库或改 Accept-Encoding403头部不全或频率高用 Copy as cURL 完整复现对比401凭证缺失或过期检查 Cookie 时效、Token 刷新302 跳登录页会话失效看 Location 是否指向登录429触发限流看 Retry-After降并发请求偶发超时连接池或对端限速加超时、加连接复用、降 QPS数据条数比页面少分页参数没跟上看请求里的 page/offset 变化同一 URL 两次结果不同有个随机参数在起作用对比两次请求头找变化字段6.2 拿到空壳页面先分清是渲染问题还是权限问题空壳页面有两种。一种是“HTML 结构完整但没有数据”典型特征是页面上有骨架屏的 div数据靠 XHR 拉。这种在 Network 面板筛选 Fetch/XHR刷新之后能看到真正的数据接口直接请求接口就行比解析 HTML 稳得多。另一种是“连正常 HTML 结构都不对”可能返回的是一个验证页或者一段提示文本。这时候看响应头里的 Content-Type 和响应体前几百个字符别急着上解析器。还有个容易忽略的点resp.text有时候看起来是空的实际上是内容被压缩了但解码失败长度为 0 或者一堆乱码。这时候打印resp.content[:200]看原始字节比看 text 更直观。6.3 编码问题的三层兜底前面提过我再完整写一遍处理顺序第一层看响应头Content-Type里的 charset第二层看 HTML 里的meta charset...或者meta http-equivContent-Type第三层才用apparent_encoding探测。这三层串起来能覆盖绝大多数情况。import re import requests def smart_text(r): ctype r.headers.get(Content-Type, ) m re.search(rcharset([\w\-]), ctype, re.I) if m: r.encoding m.group(1) else: head r.content[:2048].decode(ascii, errorsignore) m2 re.search(rcharset[\]?([\w\-]), head, re.I) r.encoding m2.group(1) if m2 else r.apparent_encoding return r.text注意r.content[:2048]这里用 ascii 加 ignore 解码是为了在不知道真实编码的情况下安全地取出 meta 标签附近的内容不会抛异常。这个小技巧在混合编码的站点上特别有用。6.4 频率控制别把重试当成解决方案所有 429 和部分 403 的根源都是频率。我见过最常见的错误做法是撞到 429加一层重试然后把并发从 20 提到 50——本末倒置。正确的思路是主动控制节奏单域名并发数压到个位数请求之间加随机间隔不是固定间隔固定间隔的流量特征太明显遇到限流信号立刻降速而不是硬扛。同时做好本地记录失败了记下来隔一段时间单独补跑而不是在主流程里死循环。请求间隔的做法我一般是这样基础间隔 0.8 秒加 0 到 0.6 秒的随机抖动。实测下来这个节奏对大部分中小型站点比较友好也不会把任务拖得太慢。具体数值当然要看站点规模和对方给出的限流信号没有通用答案。7. 我自己长期用下来的一套模板绕了一圈最后落到可复用的东西上。我现在的爬虫项目第一件事就是把 Session 初始化封装好头部、超时、重试、编码处理全都在里面业务代码只管拿数据。import re import time import random import requests class Fetcher: def __init__(self, refererNone, cookieNone): self.s requests.Session() self.s.headers.update({ 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Connection: keep-alive, }) if referer: self.s.headers[Referer] referer if cookie: self.s.headers[Cookie] cookie self.last 0.0 def _pace(self): gap 0.8 random.random() * 0.6 elapsed time.time() - self.last if elapsed gap: time.sleep(gap - elapsed) self.last time.time() def get(self, url, retries3, **kw): self._pace() for i in range(retries): try: r self.s.get(url, timeout(5, 20), **kw) except (requests.ConnectionError, requests.Timeout): time.sleep(2 ** i) continue if r.status_code in (408, 429, 500, 502, 503, 504) and i retries - 1: ra r.headers.get(Retry-After, ) time.sleep(int(ra) if ra.isdigit() else 2 ** i) continue return r return None staticmethod def text(r): if r is None: return m re.search(rcharset([\w\-]), r.headers.get(Content-Type, ), re.I) r.encoding m.group(1) if m else r.apparent_encoding return r.text这个类没什么花哨的东西但每一个取舍都有原因。Accept-Encoding我故意只写了 gzip 和 deflate不写 br因为我懒得在每个环境里都确认 brotli 装了没有——少一个压缩方式换来的是更少的诡异乱码我觉得划算。_pace每次请求前调用把节奏控制收在一个地方不会出现某段代码忘了加 sleep 的情况。重试逻辑里把 4xx 的常见码排除在外避免做无用功。实际用下来这套模板能覆盖七八成常规站点的采集需求。剩下那两三成要么是接口带签名要么是需要处理复杂的登录态那就得针对性分析没有银弹。最后分享一个我觉得最值钱的经验遇到问题先看报文再改代码。绝大多数爬虫故障的答案就躺在那几十行 header 里你把请求头和响应头完整打出来对比一遍问题往往自己就浮出来了。我踩过的坑里真正需要改解析逻辑的不到两成剩下八成全是头部字段、编码、重定向、状态码这些“基础”问题。基础打得扎实后面写什么采集任务都顺。