跳到正文
写一个有礼貌的小爬虫:能抓和该抓是两件事
写一个有礼貌的小爬虫:能抓和该抓是两件事

写一个有礼貌的小爬虫:能抓和该抓是两件事

Python 阶段收尾写个小爬虫。这篇不讲 requests 和 BeautifulSoup 怎么用,讲我对「爬虫的水平不体现在抓得多猛,而在多有分寸」的判断——能抓不代表该抓。

速读#

这篇标题里有 requests 和 BeautifulSoup,但重点不是 HTML 怎么解析,而是“能抓”和“该抓”的边界。爬虫水平不体现在抓得多猛,而体现在频率、身份、timeout、robots.txt 这些分寸上;克制同时保护对方和自己。

水平在分寸,不在抓得猛#

把请求发得又多又快,技术上没有任何门槛——几行并发代码就能把频率推到对方明显吃不消的量级。真正难的是分寸感:对方的页面是对方花钱花算力供出来的资源,我的并发、频率、身份,是否在该用的范围内。

「能抓到更多」在技术上永远成立;「该不该抓到这个程度」是另一个问句。把两个问句分开,是这篇要立的判断。

我坚持的几条#

import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
USER_AGENT = "s1oopx-learner/1.0 (+mailto:you@example.com)"
with requests.Session() as session:
session.headers["User-Agent"] = USER_AGENT
retry = Retry(
total=3,
backoff_factor=1,
status_forcelist=(429, 502, 503, 504),
allowed_methods={"GET", "HEAD"},
respect_retry_after_header=True,
)
session.mount("https://", HTTPAdapter(max_retries=retry))
resp = session.get(url, timeout=(3.05, 15)) # 连接超时、读取超时
resp.raise_for_status()
if "text/html" not in resp.headers.get("Content-Type", ""):
raise ValueError("expected an HTML response")
time.sleep(1) # 再请求同一站点前限速
该做的原因
看 robots.txt把站点给自动客户端的最低限度信号纳入决策
设置连接与读取 timeout建连和响应卡住时都能退出
做每站点限速控制持续频率,而不只是单次并发
带可识别 User-Agent说明用途并提供有效联系方式

示例里的邮箱必须换成真实可联系地址。timeout 和限速既保护对方,也保护自己。Requests 的 timeout 不是整个请求的绝对总时长:元组分别限制连接阶段,以及底层套接字在多久收不到新数据后放弃;流式响应仍可能持续更久。固定 sleep(1) 适合单进程小脚本,多 worker 时则要共享每个目标站点的速率限制,否则每个进程都“睡一秒”,合起来仍可能很猛。

robots.txt 值得单独说一句:它不是访问控制,也不是抓取许可,只是站点给自动客户端的公开规则。遵守它不能替代服务条款、官方 API 规则、版权、个人信息和适用法律判断;robots 没禁止,也不代表任何用途都被允许。User-Agent 同理:写真实用途和可联系信息,而不是伪装浏览器去绕过封禁、验证码或登录边界。遇到明确拒绝就停下来,优先申请 API、数据导出或书面许可。

失败是常态,不是意外#

写爬虫很快撞上一个事实:网络请求不是本地函数调用,超时、临时 5xx、限流和页面结构变化都会发生。程序应该明确记录失败并继续处理其他任务,而不是静默吞掉或无限重试。

resp = session.get(url, timeout=(3.05, 15))
resp.raise_for_status() # 4xx / 5xx 显式抛出来,别静默往下解析

不加这行,拿到一个 404 页面接着往下 BeautifulSoup,解析出一堆空值还不知道为什么——错误要在发生的那一层暴露,别让它伪装成数据问题漏到下一层。

raise_for_status() 也只看状态码。站点可能用 200 返回登录页、验证码或错误模板,所以解析前还要检查最终 URL、Content-Type 和页面中应存在的稳定标记;解析后校验必填字段数量,结构突然归零时先停,不把空结果覆盖进已有数据。

失败之后怎么办也有分寸:只对幂等请求和明确的临时错误做有限重试,优先遵守服务器的 Retry-After,再使用有上限的指数退避;多个 worker 同时运行时,还应在站点级调度中加入随机抖动,避免一起重试。400、401、403、404 通常不是“多试几次就好”,POST 等有副作用操作更不能默认重放。上面的 RetryHTTPAdapter 已经复用 Requests 自带的 urllib3 能力,不需要再手写一个容易无限循环的重试器。

重试用尽后把 URL、状态码、尝试次数和时间记下来,稍后人工判断;不要把响应正文整份写日志,其中可能含个人信息或令牌。

能抓 ≠ 该抓#

  • 公开 ≠ 允许:页面公开不代表授权你高频抓
  • 能复现登录态绕过 ≠ 该绕过:已越过「公开数据」边界
  • 能收集 ≠ 该保存:只保留完成目的所需字段,并设置删除期限

我没把「能抓到」当目标函数。这条线画在哪,取决于我愿不愿意把这套做法讲出来给对方看——讲不出口的,基本就不该做。

别把礼貌绝对化到不敢抓#

在条款、用途和频率都允许的前提下抓取公开数据,是正常的自动化方式。守的是范围、频率、身份、数据最小化和退出机制,不是因为风险存在就停止一切自动访问;涉及商业使用、个人信息或大规模再分发时,应先做具体的合规判断,而不是从一篇技术文章推导法律结论。

后来用 Flask 调外部 API,基本就是这套排列组合:带 UA、带 timeout、控制频率。

有一次技术上几百 QPS 能跑,我仍选单线程加 2 秒 sleep——不急,没必要把别人服务器打爆。这个选择不是技术限制,是边界感。 后来延伸到运维:能开的端口不代表该开,能放的权不代表该放。

版权许可

CC BY-NC-SA 4.0 本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

相关文章

s1oopX

登录 s1oopX