爬虫到底是什么

第一次听到「爬虫」两个字,很多人会联想到黑客或灰色产业。真实情况恰好相反:爬虫(Web Scraper)本质就是把浏览器的动作自动化——用程序发出 HTTP 请求、拿回 HTML 或 JSON、再从里面提取你需要的那部分数据。它没有魔法,只有「发请求、解析、存下来」这三件事。

举个最常见的例子:你想整理某个站点的公开文章目录,手动复制粘贴一百次会累到手酸,而一段几十行的 Python 脚本几秒钟就能跑完,还把结果直接写成 CSV。这就是爬虫的价值:把重复的浏览与记录工作交给程序。

但能力越大,边界越要清楚。下面几条务必要放在写代码之前:

  • 先看 robots.txt:站点用它可以声明哪些路径不允许被自动采集,遵守它是最基本的礼仪。
  • 控制频率:请求之间留出间隔,不要用高并发把对方站点打垮。
  • 只抓公开数据:涉及登录态、隐私、付费内容的数据不要碰。
  • 注明身份:在 User-Agent 里写清用途与联系方式,方便站点管理员联系你。
爬虫的正当性来自「公开数据的合理使用」和「不给对方造成负担」,而不是技术手段本身。把频率和身份这两件事做好,九成的麻烦都不会发生。
本文核心知识点
  • requests发送 HTTP 请求的库,负责把页面内容拿回来。
  • BeautifulSoup把 HTML 解析成可查询的树结构,负责提取数据。
  • 选择器CSS 选择器与 find / find_all 是定位元素的两把钥匙。
  • 合规与限速遵守 robots.txt、控制频率、失败重试,才能长期稳定采集。
Python 爬虫示意:网页卡片、放大镜、采集漏斗、链条与数据库圆柱体组成的蓝色扁平插画
从网页到结构化数据:定位元素、抽取内容、清洗入库,是爬虫的三段式工作流。

准备工作:requests 与解析器

需要的库只有两个,外加一个可选的解析器。建议在虚拟环境里安装(不熟悉虚拟环境的话,可以先看同频道的《Python 开发环境搭建与虚拟环境管理实战》):

python -m venv .venv
.venv\Scripts\activate            # Windows;macOS / Linux 用 source .venv/bin/activate

pip install requests beautifulsoup4 lxml

# 验证是否装好
python -c "import requests, bs4; print(requests.__version__, bs4.__version__)"

三个组件各司其职:requests 负责与服务器通信,beautifulsoup4 负责把 HTML 变成 Python 对象,lxml 是解析引擎,速度比标准库的 html.parser 快数倍。如果安装 lxml 时遇到编译问题,退回内置解析器也能跑,只是慢一点。

组件职责备注
requests发请求、管理会话与重试也可以用 httpx,接口风格相近
beautifulsoup4解析 HTML、提供查找 API需与某个解析器搭配使用
lxml高性能解析引擎编译失败时可用 html.parser 代替

装好后先跑一个最小例子确认环境没问题,再进入下一步。这一步不要省——环境带来的报错和代码逻辑的报错是两回事,先把环境排干净,后面排查会轻松很多。

第一个请求:GET 与响应处理

发一个 GET 请求只要一行,但真正健壮的代码要把超时、状态码、编码都处理好:

import requests

resp = requests.get(
    "https://httpbin.org/html",
    headers={"User-Agent": "JijiyunCodeBot/1.0 (+https://jijiyun.example/bot)"},
    timeout=10,
)
resp.raise_for_status()                 # 4xx / 5xx 直接抛异常,避免拿坏数据继续跑
resp.encoding = resp.apparent_encoding  # 让 requests 猜一个更准的编码

print(resp.status_code)
print(resp.headers.get("Content-Type"))
print(resp.text[:200])

几个容易被忽略的点:

  • timeout 必写:不写超时,一个卡住的请求就能让脚本永久挂起。
  • raise_for_status:把 HTTP 错误变成异常,出错时早失败、早暴露。
  • 编码:中文站点常见 GBK / GB2312,若响应头没声明编码,resp.text 可能乱码,用 apparent_encoding 兜底更稳。
  • Session:需要保持 Cookie 或复用连接时,用 requests.Session() 代替裸 requests.get。

还有一个实践细节:请求头里的 User-Agent 不要照抄浏览器的完整串,也不要留空。写一个能表明身份、带用途说明的 UA,既符合礼仪,也方便对方在你抓出问题时找到你,而不是直接封掉。

BeautifulSoup:把 HTML 变成可查询的树

拿到 HTML 字符串后,交给 BeautifulSoup 解析。它把文档转成一棵可以「按标签名、按属性、按选择器」查询的树:

from bs4 import BeautifulSoup

soup = BeautifulSoup(resp.text, "lxml")

h1 = soup.find("h1")
print(h1.get_text(strip=True))

# CSS 选择器:找出所有带 href 的链接
for a in soup.select("a[href]"):
    print(a["href"], a.get_text(strip=True))

# 按 class 找元素:find_all 返回列表
for li in soup.find_all("li", class_="item"):
    print(li.get_text(" ", strip=True))

两套 API 记住就够用了:find / find_all 适合结构已知、层级简单的场景,select 则直接写 CSS 选择器,表达力更强。抽取文本统一用 get_text(strip=True) 去掉首尾空白;取属性用字典下标(a["href"]),属性名带连字符时用 a.get("data-id") 更安全,不会因为属性缺失直接抛异常。

方法返回典型用法
find("h1")单个元素或 None取首个匹配,需判空
find_all("li", class_="item")列表批量取同类项
select(".card a")列表用 CSS 选择器精确定位
get_text(strip=True)字符串取纯文本、去首尾空白
a["href"]属性值取链接等属性

先理解 DOM 的层级再写选择器,比盲目试错快得多。打开浏览器开发者工具,右键目标元素选「检查」,就能看到它的标签、class 与父子关系,照着写 select 一次就中。

把页面抽成结构化数据

真正的爬虫要产出的是结构化数据,而不是一堆字符串。做法是遍历列表容器,为每条记录组装一个字典:

import csv
from urllib.parse import urljoin
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "lxml")
base = "https://example.com"

rows = []
for item in soup.select(".repo-item"):
    link = item.select_one("a.name")
    rows.append({
        "name": link.get_text(strip=True),
        "stars": item.select_one(".stars").get_text(strip=True),
        "url": urljoin(base, link["href"]),     # 相对链接补成绝对地址
    })

with open("repos.csv", "w", newline="", encoding="utf-8-sig") as f:
    writer = csv.DictWriter(f, fieldnames=["name", "stars", "url"])
    writer.writeheader()
    writer.writerows(rows)

print("共写入", len(rows), "条")

清洗环节决定了数据能不能直接用,常见动作有:

  • 去空白:get_text(strip=True) 之后再用 " ".join(text.split()) 压掉多余空格与换行。
  • 统一类型:把 "1.2k" 这类字符串按规则还原成整数 1200,方便后续排序与统计。
  • 补全链接:相对路径用 urljoin 拼成绝对地址,后续才能直接访问。
  • 去重:用集合或数据库唯一键过滤重复条目,避免翻页时把同一篇抓两遍。

要特别注意:选择器与页面的 DOM 结构是强耦合的。站点改版一次,.repo-item 这类 class 可能就失效了——所以要把选择器集中写在一处,方便统一适配,而不是散落在几十个函数里。

分页、限速与重试

单页只是开始,真实任务总要翻页。翻页的关键不是循环本身,而是别把对方站点当成免费的 API:

import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()
retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retry))

def fetch(url):
    resp = session.get(url, timeout=10,
                       headers={"User-Agent": "JijiyunCodeBot/1.0"})
    resp.raise_for_status()
    return resp.text

for page in range(1, 6):
    html = fetch("https://example.com/list?page=%d" % page)
    # 交给解析层处理
    time.sleep(1)        # 每次请求之间停 1 秒,给站点留余量
关注点建议做法
频率单线程每请求间隔 0.5 到 2 秒,高峰期更保守
重试仅对 429 / 5xx 与网络异常重试,用指数退避(backoff_factor)
并发需要提速时用少量线程,并加信号量限制同时在飞的请求数
缓存调试阶段把响应存到本地,避免反复请求同一页

退避(backoff)的意义在于:当对方返回 429 或 503,说明它已经吃不消了,这时正确的反应是等更久再试,而不是立刻重发。指数退避能把「越重试越糟」的雪崩变成一条平缓的恢复曲线。