《Python编程入门》17.2 网络爬虫基础与合规边界

从合规边界讲起:用 urllib.robotparser 解析 robots.txt、遵守服务条款与请求频率,再在本地 http.server 上真抓真解析,掌握 httpx 状态码与编码处理、BeautifulSoup 的 find 与 select、分页限速与指数退避,以及动态渲染与存储去重。

本节目标:先把「什么能抓、什么不能抓」的边界立清楚,再在本地测试站点上真抓真解析,掌握 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

不要凭感觉猜选择器。正确姿势是:

  1. 浏览器里 F12 打开开发者工具,用「选取元素」箭头点到目标。
  2. 在 Elements 面板右键节点 → Copy → Copy selector(或 Copy XPath)。
  3. 粘到 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 把这份原始数据变成能下结论的分析结果。

阅读导航:上一节:文件批处理与办公自动化 · 下一节:数据分析入门 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时