本节目标:先把「什么能抓、什么不能抓」的边界立清楚,再在本地测试站点上真抓真解析,掌握 HTTP 抓取、HTML 解析、分页限速与退避重试的最小闭环。
适用版本:Python 3.12+(实测 3.14.6)
17.2 网络爬虫基础与合规边界
12.3 节我们学过 HTTP 客户端,13 章学过异步并发。把这两样用到「自动读取网页」上,就是爬虫。但爬虫和技术教程里其他主题有个根本不同:它的对象是别人家的服务器。所以本节刻意把「合规」放在最前面——先确定边界,再谈技术。本节所有抓取都打在本地 http.server 上,不打任何真实网站。
17.2.1 动手之前:先立边界
| 事项 | 该怎么做 |
|---|---|
robots.txt | 抓之前先读,Disallow 的路径一律不碰 |
| 服务条款(ToS) | 明确禁止抓取的站点,别抓 |
| 身份标识 | User-Agent 里写清自己是谁、怎么联系 |
| 请求频率 | 加 time.sleep 限速,别把对方服务器打垮 |
Retry-After | 收到 429/503 时按响应头给的秒数等待 |
| 个人数据 / 版权 | 别采个人隐私,别整站复制受版权保护的内容 |
| 反爬与登录墙 | 不绕过验证码、不模拟登录拿非公开数据 |
| 官方 API | 有 API 就用 API,别硬爬页面 |
一条底线:技术可行不等于合法合规。批量抓取前先问自己三个问题——对方允许吗?我拿这数据干什么?会不会伤到对方?想不清楚就别抓。
17.2.2 用 urllib.robotparser 读 robots.txt
robots.txt 是站点声明的抓取规则,标准库 urllib.robotparser 就能解析。它不但能判断某路径是否可抓,还能读出 Crawl-delay:
from urllib.robotparser import RobotFileParser
rp = RobotFileParser()
rp.set_url(f"{base}/robots.txt")
rp.read()
print(rp.can_fetch("my-scraper", f"{base}/page1.html")) # True
print(rp.can_fetch("my-scraper", f"{base}/admin/secret")) # False
print(rp.crawl_delay("my-scraper")) # 1
我们测试站点的 robots.txt 长这样:
User-agent: *
Disallow: /admin/
Allow: /
User-agent: my-scraper
Disallow: /admin/
Crawl-delay: 1
注意 RobotFileParser 会按 User-Agent 精确匹配:我们以 my-scraper 的身份去问,就命中第二条规则,拿到 Crawl-delay: 1。抓取循环里应当把这个延迟落实成 time.sleep。
17.2.3 起一个本地测试站点
为了「真跑又不打外网」,用标准库 http.server 在本地起一个站点,端口交给系统随机分配:
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from functools import partial
import threading
srv = ThreadingHTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(DOC)))
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{port}"
站点里放三个分页 page1.html ~ page3.html,每页三张商品卡片,结构如下(便于演示选择器):
<ul class="products">
<li class="product" data-sku="P101">
<h2 class="name">商品 P101</h2>
<span class="price">¥101.00</span>
<a class="detail" href="/item/P101">详情</a>
</li>
</ul>
<a class="next" href="/page2.html">下一页</a>
17.2.4 HTTP 抓取的最小闭环
一次抓取要处理四件事:请求、状态码、编码、解析。
import httpx
r = httpx.get(f"{base}/page1.html", headers={"User-Agent": "my-scraper/1.0"}, timeout=5, trust_env=False)
print(r.status_code, "|", r.encoding, "|", r.headers["content-type"])
200 | utf-8 | text/html
这里有个本机实测踩到的坑:httpx 默认 trust_env=True,会读取系统/环境里的代理设置。本机系统代理指向 proxy.nioint.com:8080,导致对 127.0.0.1 的请求被代理拦下、返回 503 错误页。抓本机或不想走代理时,务必显式传 trust_env=False。
状态码要先判再解析:2xx 才算成功,3xx 交给 httpx 自动跟随(默认最多 20 跳),4xx/5xx 不能拿去喂给解析器——否则你解析到的可能是一张「503 错误页」。编码则见 17.2.5。
17.2.5 find / find_all / select 与解析器差异
拿到 HTML 后用 BeautifulSoup 解析。find 返回第一个匹配,find_all 返回全部,select 用 CSS 选择器:
from bs4 import BeautifulSoup
soup = BeautifulSoup(r.text, "html.parser")
print(soup.title.text) # 商品列表 - 第1页
print(len(soup.find_all("li", class_="product"))) # 3
print(len(soup.select(".products > li.product"))) # 3
first = soup.select_one("li.product")
print(first.select_one("h2.name").text, first["data-sku"], first.select_one("a.detail")["href"])
# 商品 P101 P101 /item/P101
print([c["data-sku"] for c in soup.select("li.product")]) # ['P101', 'P102', 'P103']
find("li", class_="product") 与 select_one("li.product") 等价,但 CSS 选择器在层级、属性、伪类上表达力更强,且与浏览器 DevTools 的 Copy selector 完全一致,所以实战优先用 select。
解析器方面:html.parser 是标准库自带、对残缺 HTML 极宽容(浏览器会自动补全未闭合标签,它也能):
dirty = "<ul><li>一<li>二<li>三</ul>"
print(len(BeautifulSoup(dirty, "html.parser").find_all("li"))) # 3
lxml 是 C 实现,速度快、容错也好,但本机未预装,本节不跑;在解析大文件或脏页面多的场景,装 lxml 并写 BeautifulSoup(html, "lxml") 是常见选择。
编码判定同样重要。若响应头没带 charset,httpx 默认按 utf-8 解,遇到 GBK 页面就乱码:
r = httpx.get(f"{base}/gbk.html", trust_env=False)
print(r.encoding) # utf-8(默认猜测)
r.encoding = "gbk" # 必须在读 .text 之前设置
print(r.text) # 中文页 ... 价格:199 元
注意 httpx 不允许在访问过 .text 之后再改 encoding(会抛 ValueError)。稳妥做法是直接用 r.content.decode("gbk"),或交给 bs4:BeautifulSoup(r.content, "html.parser", from_encoding="gbk")。
17.2.6 选择器怎么定位:先开 DevTools
不要凭感觉猜选择器。正确姿势是:
- 浏览器里
F12打开开发者工具,用「选取元素」箭头点到目标。 - 在 Elements 面板右键节点 →
Copy→Copy selector(或Copy XPath)。 - 粘到 Python 里用
soup.select(...)验证,只取稳定的语义类名/属性,别依赖自动生成的哈希类名。
定位时优先选语义稳定的锚点:data-sku 这类业务属性、class="price" 这类可读类名,都比 div > div:nth-child(3) > span 这种位置选择器耐用——页面改版时后者最先碎。
17.2.7 分页、限速与指数退避
分页就是「抓到 a.next 就继续,没有就停」,每一跳之间限速:
all_items, url, page = [], f"{base}/page1.html", 1
while url:
time.sleep(random.uniform(0.05, 0.12)) # 演示用小间隔;真实站点 1s+
resp = httpx.get(url, headers={"User-Agent": "my-scraper/1.0"}, timeout=5, trust_env=False)
s = BeautifulSoup(resp.text, "html.parser")
all_items += [c["data-sku"] for c in s.select("li.product")]
nxt = s.select_one("a.next")
url = f"{base}{nxt['href']}" if nxt and nxt.name == "a" else None
page += 1
第1页 200 抓到 3 条 -> ['P101', 'P102', 'P103']
第2页 200 抓到 3 条 -> ['P201', 'P202', 'P203']
第3页 200 抓到 3 条 -> ['P301', 'P302', 'P303']
合计: 9 条
加一点随机抖动(random.uniform)比固定间隔更像正常访问,也能避免所有请求「整点齐发」。遇到 5xx / 429 则要指数退避:失败一次等 0.1s,再失败等 0.2s、0.4s……
def fetch_with_retry(url, max_retries=4):
for attempt in range(max_retries):
resp = httpx.get(url, timeout=5, trust_env=False)
if resp.status_code < 500:
return resp
time.sleep(0.1 * (2 ** attempt)) # 0.1, 0.2, 0.4, ...
return resp
第1次 503,等待 0.1s 后重试
第2次 503,等待 0.2s 后重试
最终状态码: 200
如果响应头带 Retry-After,就按它给的值等,别自己拍脑袋。
17.2.8 动态渲染的页面为什么抓不到
httpx / requests 拿到的只是服务器返回的初始 HTML。如果页面靠 JavaScript 在浏览器里渲染数据(单页应用、懒加载),初始 HTML 里根本没有商品列表——soup.select("li.product") 会是空。
要抓这类页面得驱动真实浏览器:Playwright(pip install playwright && playwright install chromium)或 Selenium。它们的用法是启动无头浏览器、等元素出现、再从渲染后的 DOM 取数据。本节未预装这两个库,故不跑;知道「静态页用 httpx,动态页要上浏览器自动化」这个分界即可。更省事的办法是抓它背后的 JSON API——DevTools 的 Network 面板筛 Fetch/XHR 常能直接找到数据接口。
17.2.9 抓取结果的存储与去重
抓下来的数据先落盘再处理,格式按体量选:
| 数据量 | 方案 |
|---|---|
| 小 | JSONL(每行一条,可追加) |
| 中 | SQLite |
| 大 | Parquet / PostgreSQL |
去重靠业务主键(如 data-sku),而不是整行文本——同一商品价格变了也还是同一商品:
import json
seen, out = set(), []
for sku, price in all_items:
if sku in seen:
continue
seen.add(sku)
out.append({"sku": sku, "price": price})
with open("items.jsonl", "w", encoding="utf-8") as f:
for rec in out:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print("去重后:", len(out), "条")
去重后: 9 条
17.2.10 合规检查清单
| 允许 | 需谨慎 | 禁止 |
|---|---|---|
抓公开、robots.txt 允许的页面 | 抓有明确 ToS 但未明文禁止的内容 | 绕过验证码 / 登录墙拿非公开数据 |
遵守 Crawl-delay 限速 | 高频抓取(需先获授权) | 压垮对方服务器(DoS 式并发) |
| 用官方 API | 再分发第三方内容 | 采集个人隐私、整站复制版权内容 |
| 学习 / 研究 / 自用 | 商业用途(先谈授权) | 用假 UA 伪装成普通用户规避封锁 |
小结
- 先合规再动手:读
robots.txt、看 ToS、带真实User-Agent、限速、尊重Retry-After,不绕过反爬与登录墙。 urllib.robotparser解析robots.txt,can_fetch判断路径、crawl_delay拿延迟,按 UA 精确匹配。- HTTP 抓取闭环 = 请求(带 UA、超时)+ 判状态码 + 定编码 + 解析。
httpx默认走系统代理,抓本机要trust_env=False。 bs4用select配 CSS 选择器,与 DevTools 的Copy selector一致;选择器优先用语义稳定的类名/属性。html.parser容错好,lxml更快(本机未装)。- 分页跟
a.next走,加随机抖动限速,失败用指数退避重试。 - 动态渲染页面
httpx抓不到,要 Playwright/Selenium 或改抓 JSON API;结果落 JSONL/SQLite/Parquet,按业务主键去重。
抓回来的数据往往是表格状的——下一节我们就用 pandas 与 polars 把这份原始数据变成能下结论的分析结果。
阅读导航:上一节:文件批处理与办公自动化 · 下一节:数据分析入门 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。