Tutorial · 示例页面

用 Python 写第一个 Web 爬虫

一份零构建、可离线打开的教程页面示例。正文中文,技术术语首次出现附英文;所有示例都真实可运行,目标站点为教学专用站 quotes.toscrape.com

难度中级 预计阅读约 20 分钟 章节6 章 字数约 6000 字 更新2026-08-15

你将学到什么

  • requests 发送请求(request)并读取响应(response)
  • 用 BeautifulSoup 解析(parse)HTML 并提取字段
  • 清洗数据并写入 CSV 文件
  • 处理分页(pagination)与基础反爬兜底
  • 把以上能力组织成一个完整、可运行的爬虫项目
开始学习

环境准备与项目结构

先把「枪」备好:安装 Python、装好 requests 与 BeautifulSoup,并建立清晰的目录骨架。

检查 Python 与 pip

爬虫本质上是「用程序发请求、再处理返回的内容」,而 Python 是完成这件事最顺手的选择。这里我们假设你已经写过一些 Python(见过变量、函数和循环),但还从没写过爬虫。

第一步是确认电脑上装了 Python。打开终端(terminal),输入下面这条命令,注意看第 1 行输出的版本号(重点):

bash
python3 --version
pip3 --version

如果看到类似 Python 3.11.6 的输出,说明环境就绪。本文全部示例要求 Python 3.8+。若命令提示找不到 python3,请先到 python.org 安装,再回来继续。

提示

强烈建议为教程项目创建虚拟环境(virtual environment,简称 venv),把依赖装进隔离空间,避免污染系统 Python。创建方式见下一小节。

安装依赖库

我们只用两个第三方库(library):requests 负责发 HTTP 请求,beautifulsoup4(常简写为 bs4)负责解析返回的 HTML。先用 venv 建环境,再用 pip 安装:

bash
mkdir crawler-tutorial && cd crawler-tutorial
python3 -m venv .venv
source .venv/bin/activate

激活环境后,命令行前缀会出现 (.venv)。接下来安装依赖:

bash
pip install requests beautifulsoup4

安装完成后可以验证版本,顺便把依赖清单(dependency list)固定下来,方便日后复现环境:

bash
python3 -c "import requests, bs4; print(requests.__version__, bs4.__version__)"
pip freeze > requirements.txt

搭建项目结构

别急着写代码。先想清楚这个爬虫要产出什么,把文件规划出来。一个极简但完整的爬虫项目通常长这样:

bash
crawler-tutorial/
├── .venv/             # 虚拟环境(不提交到 git)
├── requirements.txt   # 依赖清单
├── crawler.py         # 主脚本(先留空)
└── data/              # 输出目录(先建好)
    └── quotes.csv     # 之后爬下来的数据

目录就是项目的「地图」。等我们写到第 6 章的完整项目时,你会发现好结构能让你一眼找到:代码在哪、依赖在哪、输出在哪。

警告

爬虫有「边界」:请只在目标站点允许的前提下抓取。本教程使用的 quotes.toscrape.com 是官方教学站,专门开放给学习者练习。抓取任何其他站点前,请先读它的 robots.txt(我们会在第 5 章讲)。

Q1. 要安装解析 HTML 用的库,正确的命令是哪条?

Q2. 虚拟环境(venv)的主要作用是?

动手任务

创建虚拟环境并安装依赖,然后确认版本号能打印出来。

  • 能运行 python3 -c "import requests, bs4" 且不报错
  • 工作目录里有 requirements.txt 和空的 data/ 目录

发送 HTTP 请求

爬虫的第一步是「问网站要东西」。这一章我们用 requests 发一个真实请求,并学会读懂返回。

第一个 GET 请求

爬虫的所有动作都建立在 HTTP 协议上:你的程序扮演一个客户端(client),向服务器(server)发送请求(request),服务器把页面内容作为响应(response)还回来。其中最常见的请求是 GET——「把某个资源给我」。

在项目目录下新建 crawler.py,输入下面这段代码(注意第 3 行的 URL 是教学站首页):

python
import requests

url = "https://quotes.toscrape.com/"
resp = requests.get(url)
print(resp.status_code)

运行 python3 crawler.py,你应该看到 200。状态码(status code)200 表示「成功」——服务器找到了这个页面并正常返回。代码只有四行,但「发请求」这件事已经完成了。

提示

如果网络不通或站点不可达,requests 会抛出 requests.exceptions.ConnectionError。先检查网络,再检查 URL 是否拼写正确。这是新手最常见的第一个报错。

读懂响应:状态码与响应体

一个 HTTP 响应由三块组成:状态行(状态码 + 原因短语)、响应头(headers,描述元信息)和响应体(body,真正的页面内容)。我们把三样都打印出来看看:

python
print("状态码:", resp.status_code, resp.reason)
print("编码:", resp.encoding)
print("响应头 Content-Type:", resp.headers.get("Content-Type"))
print("响应体前 200 个字符:\n", resp.text[:200])

你应该会看到类似下面的输出(内容以实际为准):

bash
状态码: 200 OK
编码: ISO-8859-1
响应头 Content-Type: text/html; charset=utf-8
响应体前 200 个字符:

有两个点值得记住(重点):

  • resp.text 返回的是 解码后的字符串,可以直接搜索、切片、交给解析器。
  • resp.status_code 的常用取值:200 成功、404 页面不存在、403 禁止访问、429 请求太频繁。爬虫要养成「先看状态码,再碰内容」的习惯。
requests 与标准库 urllib 的对比
维度requestsurllib.request
学习成本低,API 直观中等,样板代码多
设置请求头headers={...} 传字典即可需构造 Request 对象
超时(timeout)内置参数,直接支持需手动处理
会话与重试内置 Session / 第三方配合几乎要全手写
中文教程生态主流,资料最多相对少

设置请求头 User-Agent

网站服务器会从请求头里读到「你是谁」。默认情况下 requests 会带上 python-requests/x.y 这样的标识(User-Agent)。很多站点会据此把爬虫挡在门外(403)。

解决办法是把 User-Agent 伪装成浏览器。大部分真实浏览器 UA 长得像下面第 3 行那样(重点):

python
headers = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/126.0 Safari/537.36",
}

resp = requests.get(url, headers=headers, timeout=10)
print("服务器看到的 UA:", resp.request.headers["User-Agent"])

注意第 8 行的 timeout=10:给每次请求设超时(timeout)是爬虫的底线素养——否则网络卡住时程序会一直挂在那里。

注意

伪装 UA 是为了「像普通用户一样访问」,不代表可以绕过站点的访问控制或授权限制。合理用法是:低频、节流、只取公开数据。

Q1. 状态码 200 表示什么?

Q2. 服务器主要靠哪个请求头识别「这是不是浏览器」?

动手任务

请求 quotes.toscrape.com,打印出状态码、响应头里的 Server 字段,以及响应体总长度(字符数)。

  • 能打印 200 状态码
  • 能打印 Server 头(如 nginx/1.25.3
  • 能打印响应体字符数(约 8000+)

解析 HTML

请求拿回来的是整张网页字符串。这一章学怎么用 BeautifulSoup 从中「挑出」我们想要的内容。

认识 BeautifulSoup

quotes.toscrape.com 首页每一行名言(quote)在 HTML 里都长这样(我们关心的是 class 为 textauthor 的元素):

html
<div class="quote">
  <span class="text">“The world as we have created it is a process of our thinking...”</span>
  <span>by <small class="author">Albert Einstein</small></span>
</div>

BeautifulSoup 会把这段 HTML 文本变成一棵可遍历的「树」,我们就能用名字或属性去树上找节点。基本用法三步:先创建解析对象(parser),再调用查找方法(find / find_all / select)。

python
from bs4 import BeautifulSoup

soup = BeautifulSoup(resp.text, "html.parser")

first_quote = soup.find("div", class_="quote")
print(first_quote.text[:120])

第 3 行:用 html.parser(Python 内置)把字符串解析成 soup 对象。第 5 行 find 返回第一个匹配的元素;注意参数是 class_(带下划线),因为 class 在 Python 里是关键字(keyword)。

find_all 与 CSS 选择器

首页一共有 10 条名言。把「找第一个」换成「找全部」,再配合循环,就拿到了全部数据。两种写法殊途同归:

python
# 写法一:find_all 按标签 + class
quotes = soup.find_all("div", class_="quote")
print("用 find_all 找到", len(quotes), "条")

# 写法二:select 用 CSS 选择器(效果相同)
quotes2 = soup.select("div.quote")
print("用 select 找到", len(quotes2), "条")

两种都返回一个列表。写法的取舍看个人习惯:find_all 参数直观;select 能表达更复杂的路径,比如「所有 div 里的 span」写成 soup.select("div span")

提示

写选择器之前,先在浏览器里「右键 → 检查」目标元素,确认它的标签名和 class。写对选择器,解析就成功了一半。

提取第一条 quote 的字段

现在把「一句话」拆成三个字段:名言文本、作者、标签。注意第 6 行,作者藏在 <small class="author"> 里(重点):

python
quote = quotes[0]

text = quote.find("span", class_="text").text
author = quote.find("small", class_="author").text
tags = [tag.text for tag in quote.find_all("a", class_="tag")]

print("名言:", text)
print("作者:", author)
print("标签:", tags)

输出大致是:

bash
名言: “The world as we have created it is a process of our thinking...”  # 截断示意
作者: Albert Einstein
标签: ['change', 'deep-thoughts', 'thinking', 'world']
BeautifulSoup 常用方法速查
方法 / 属性作用示例
find(tag, ...)返回第一个匹配节点soup.find("div", class_="quote")
find_all(tag, ...)返回全部匹配节点(列表)soup.find_all("span")
select(css)用 CSS 选择器查找soup.select("div.quote .text")
.text取节点内全部纯文本tag.text
["attr"]取属性值a["href"]

Q1. 哪个方法使用 CSS 选择器来查找元素?

Q2. 为什么 bs4 里写 class_ 而不是 class

动手任务

抓取首页并打印出全部 10 条名言的作者。

  • 能打印出 10 位作者(含重复作者也算)
  • 代码里同时用到了 find_allselect 各至少一次

数据清洗与存储

解析出来的是「差不多能用」的数据。这一章把它洗干净、结构化,再落盘成 CSV。

清洗文本

直接从网页拿到的文本往往带着杂质:名言首尾有多余空格或引号、作者旁边可能有换行、标签里混着空白。清洗(cleaning)的目标是让数据「可分析、可入库」。

这一站比较干净,我们主要做三件事:去首尾空白、去掉多余的引号字符、过滤空标签(重点在第 2、5 行):

python
def clean_text(s):
    """去掉首尾空白并剥掉中文/英文引号"""
    s = s.strip()
    s = s.strip("\u201c\u201d\u2018\u2019\"'")
    return s.strip()

raw = "“The world as we have created it...”"
print(clean_text(raw))

\u201c 是中文左双引号 \u201d 是右双引号 。Python 源码里写成转义序列是为了可读性和跨平台稳定(重点:转义序列永远比直接粘贴特殊字符可靠)。

写出 CSV

把清洗后的数据一行一行写进 CSV(逗号分隔值,comma-separated values)。CSV 是表格类数据最通用的交换格式,Excel、Pandas、数据库都能直接吃。

推荐用 csv.DictWriter:字段名写在表头,行数据用字典(dictionary)组织,一眼就能看出「这一列是什么」(重点在第 3 行):

python
import csv

rows = []                      # 每行一个 dict:{"text": ..., "author": ..., "tags": ...}
for q in quotes:
    rows.append({
        "text": clean_text(q.find("span", class_="text").text),
        "author": q.find("small", class_="author").text.strip(),
        "tags": ", ".join(t.text for t in q.find_all("a", class_="tag")),
    })

with open("data/quotes.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["text", "author", "tags"])
    writer.writeheader()
    writer.writerows(rows)

print("已写入", len(rows), "行")

跑完检查一下 data/quotes.csv,前两行应该长这样:

bash
text,author,tags
The world as we have created it is a process of our thinking...,Albert Einstein,"change, deep-thoughts, thinking, world"
It is our choices, Harry, that show what we truly are...,J.K. Rowling,"abilities, choices"
注意

写 CSV 时 encoding="utf-8"newline="" 缺一不可:前者保证中文不乱码,后者避免 Windows 下出现多余空行。这是中文爬虫数据落盘最常见的两个坑。

编码与追加

把第 4 章和第 5 章连起来想:第 5 章要抓 10 页,难道每页都重写一次文件?正确姿势是「只写一次表头,其余页用追加(append)模式」。下面这段在第 5 章会直接用:

python
def append_rows(filepath, rows):
    write_header = not Path(filepath).exists()
    with open(filepath, "a", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=["text", "author", "tags"])
        if write_header:
            writer.writeheader()
        writer.writerows(rows)

第 4 行判断文件是否已存在:文件不存在时写表头,存在时直接追加。这样「跑一次补一页」也不会弄坏表头。

文本清洗常用手法
目的做法示例
去首尾空白str.strip()" abc ".strip()"abc"
去指定字符str.strip(chars)"'abc'".strip("'")"abc"
合并空白re.sub(r"\s+", " ", s)多空格/换行 → 单个空格
字符串拼接", ".join(list)标签列表 → 逗号分隔字符串

Q1. 写 CSV 时为什么需要 newline=""

动手任务

把首页 10 条名言清洗后写入 data/quotes.csv

  • CSV 共 11 行(1 行表头 + 10 行数据)
  • 名言文本没有首尾引号,作者列无空白
  • 用文本编辑器或 Excel 打开无乱码

分页与反爬

首页只有 10 条,可教学站一共有 100 条。这一章解决「翻页抓全部」,以及怎么当一个不讨人厌的爬虫。

理解分页 URL

quotes.toscrape.com 把内容按页(page)切分:第 2 页的 URL 是 https://quotes.toscrape.com/page/2/,第 3 页是 .../page/3/。规则简单到可以写进一个 for 循环。

我们先手动验证一下第 2 页能正常请求:

python
import requests

for page in range(1, 4):
    url = f"https://quotes.toscrape.com/page/{page}/"
    resp = requests.get(url, headers=headers, timeout=10)
    print(f"第 {page} 页 -> {resp.status_code}, 长度 {len(resp.text)}")

第 4 行的 f-string(f-string)用 {page} 把页码拼进 URL。看到三个 200,就说明分页规律找对了。

循环抓取全部页面

教学站总共 10 页。把第 4 章的解析逻辑接进来,一个「抓全部」的循环就成型了。注意第 8 行:先看状态码再干活,避免把 404 页面也当成数据存下来(重点):

python
from bs4 import BeautifulSoup

all_rows = []
for page in range(1, 11):
    resp = requests.get(f"https://quotes.toscrape.com/page/{page}/",
                        headers=headers, timeout=10)
    if resp.status_code != 200:
        print(f"第 {page} 页失败: {resp.status_code}")
        continue

    soup = BeautifulSoup(resp.text, "html.parser")
    for q in soup.select("div.quote"):
        all_rows.append({
            "text": clean_text(q.select_one(".text").text),
            "author": q.select_one(".author").text.strip(),
            "tags": ", ".join(t.text for t in q.select("a.tag")),
        })

print("共抓到", len(all_rows), "条")

这一段把第 3、4 章的知识全部串起来了:选择器定位、清洗、结构化。跑完应该输出 共抓到 100 条

警告

现实世界的站点不会让你 for i in range(1, 100000) 白嫖。分页循环必须配合「何时停」的判断:比如下一页链接为空就退出,而不是写死页数。见下方「优雅停止」示例。

更稳的写法是跟着「下一页」按钮走,而不是硬编码页数:

python
url = "https://quotes.toscrape.com/"
while url:                       # 下一页为空时退出
    resp = requests.get(url, headers=headers, timeout=10)
    soup = BeautifulSoup(resp.text, "html.parser")

    next_link = soup.select_one("li.next a")
    url = "https://quotes.toscrape.com" + next_link["href"] if next_link else None
    print("抓取:", url)

第 4 行找「下一页」按钮:没有它就把 URL 置空,第 7 行 while url 自动结束循环。这样即使站点哪天加了页,也不用改代码。

延时、重试与节流

高频请求会占用服务器资源,触发反爬(anti-crawling)机制:返回 403/429,甚至封 IP。三个基础兜底:

  • 延时(delay):每页之间 time.sleep() 休息一下。
  • 重试(retry):请求失败或超时,退避后重来几次。
  • 节流(throttle):控制每秒请求数,整体压低频率。
python
import time

def get_with_retry(url, max_retries=3):
    for attempt in range(max_retries):
        try:
            resp = requests.get(url, headers=headers, timeout=10)
            if resp.status_code == 200:
                return resp
        except requests.RequestException:
            pass
        time.sleep(2 ** attempt)      # 指数退避:2s, 4s, 8s
    return None

resp = get_with_retry("https://quotes.toscrape.com/page/2/")
print("结果:", resp.status_code if resp else "多次重试仍失败")

第 7 行的指数退避(exponential backoff)是重试的黄金法则:第一次失败等 2 秒,第二次 4 秒,第三次 8 秒——给服务器喘息,也提高自己成功的概率。每次请求之间再补一个 time.sleep(1),就是最朴素的节流。

最后,别忘了爬虫的道德课。教学站的 robots.txt 你可以先读为敬:

bash
curl -s https://quotes.toscrape.com/robots.txt
# User-agent: *
# Allow: /page/*
# Disallow: /

看懂了吗?它允许抓 /page/*(我们的目标恰好在这里),禁止抓首页之外的部分。照着规则来,就是「规矩的爬虫」。

注意

robots.txt 是「君子协定」,不是法律。真正判断「能不能抓」还得看网站的条款(ToS)与内容版权。教学站允许练习,不代表所有站点都这样。

Q1. 为什么爬虫要在请求之间加 time.sleep()

Q2. 指数退避(exponential backoff)的含义是?

动手任务

抓取全部 10 页名言并追加写入 CSV,加上延时与重试。

  • 最终 data/quotes.csv 有 101 行(1 行表头 + 100 行数据)
  • 代码里包含 time.sleep() 与重试函数
  • 抓取过程中没有任何 403/429 状态码

完整项目实战与总结

把前五章碎片拼成一个像样的项目:带函数、带配置、带输出目录,跑一遍收获 100 条数据。

整理成项目

散装的脚本能跑,但不好维护。一个「拿得出手」的爬虫项目至少有四样:入口main())、可配置项(URL、延时、输出路径)、职责拆分的函数、以及清晰的输出。把配置集中放到文件顶部,是第 1 章说的「地图」落地的地方:

python
BASE_URL = "https://quotes.toscrape.com"
HEADERS = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..."}
DELAY   = 1.0     # 每页之间延时(秒)
MAX_RETRIES = 3   # 最大重试次数
OUT_CSV = "data/quotes.csv"

完整脚本 crawler.py

下面就是完整项目的主脚本,约 60 行。它把前五章的函数全部收编,结构一目了然:

python
import csv
import time
from pathlib import Path
import requests
from bs4 import BeautifulSoup

BASE_URL = "https://quotes.toscrape.com"
HEADERS = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                         "AppleWebKit/537.36 (KHTML, like Gecko) "
                         "Chrome/126.0 Safari/537.36"}
DELAY = 1.0
MAX_RETRIES = 3
OUT_CSV = "data/quotes.csv"
FIELD_NAMES = ["text", "author", "tags"]


def clean_text(s):
    """去掉首尾空白并剥掉中文/英文引号"""
    s = s.strip().strip("\u201c\u201d\u2018\u2019\"'")
    return s.strip()


def get_with_retry(url, max_retries=MAX_RETRIES):
    """带指数退避的请求,失败返回 None"""
    for attempt in range(max_retries):
        try:
            resp = requests.get(url, headers=HEADERS, timeout=10)
            if resp.status_code == 200:
                return resp
        except requests.RequestException:
            pass
        time.sleep(2 ** attempt)
    return None


def parse_quotes(html):
    """从一页 HTML 中提取名言列表"""
    soup = BeautifulSoup(html, "html.parser")
    rows = []
    for q in soup.select("div.quote"):
        rows.append({
            "text": clean_text(q.select_one(".text").text),
            "author": q.select_one(".author").text.strip(),
            "tags": ", ".join(t.text for t in q.select("a.tag")),
        })
    return rows


def append_rows(rows):
    """追加写入 CSV,文件不存在时先写表头"""
    write_header = not Path(OUT_CSV).exists()
    Path(OUT_CSV).parent.mkdir(parents=True, exist_ok=True)
    with open(OUT_CSV, "a", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=FIELD_NAMES)
        if write_header:
            writer.writeheader()
        writer.writerows(rows)


def crawl():
    """从第一页开始,跟着『下一页』一直抓到没有为止"""
    url = BASE_URL + "/page/1/"
    total = 0
    while url:
        resp = get_with_retry(url)
        if resp is None:
            print("跳过:", url)
            break

        rows = parse_quotes(resp.text)
        append_rows(rows)
        total += len(rows)
        print(f"已抓 {len(rows)} 条({url}),累计 {total}")

        soup = BeautifulSoup(resp.text, "html.parser")
        next_link = soup.select_one("li.next a")
        url = BASE_URL + next_link["href"] if next_link else None
        time.sleep(DELAY)
    print(f"完成,共 {total} 条,写入 {OUT_CSV}")


if __name__ == "__main__":
    crawl()

第 10 行起是「配置区」,crawl() 函数把抓取主循环写清楚:请求 → 解析 → 落盘 → 翻页 → 延时,一步不落。运行它:

bash
python3 crawler.py

输出会像这样逐页滚动:

bash
已抓 10 条(https://quotes.toscrape.com/page/1/),累计 10
已抓 10 条(https://quotes.toscrape.com/page/2/),累计 20
...
完成,共 100 条,写入 data/quotes.csv

「读的即是下载的」——上面的脚本与示例项目压缩包里的 crawler.py 完全一致。可以直接下载完整项目到本地跑:

下载示例项目(ZIP)
提示

解压后先 pip install -r requirements.txt,再 python3 crawler.py。需要 Python 3.8+,断网或站点维护时会优雅跳过而不是崩溃。

运行与总结

至此,你的第一个 Web 爬虫已经跑通了完整生命周期:请求 → 解析 → 清洗 → 存储 → 分页 → 反爬兜底。回看整个旅程,真正有用的不是这 100 条名言,而是三件可迁移的东西:

  • 方法论:任何抓取任务都是「先看结构 → 再写选择器 → 后加兜底」。
  • 工程习惯:配置集中、函数拆分、先看状态码、尊重 robots.txt。
  • 排错思路:403 查 UA、429 查频率、404 查 URL、乱码查编码——每一章踩过的坑都有对应的信号。
全篇术语速查
术语英文一句话解释
请求request客户端向服务器要资源的动作
响应response服务器返回的状态与内容
解析parse把 HTML 文本变成可查询的结构
分页pagination把内容切成多页返回的机制
反爬anti-crawling站点识别并限制爬虫的手段
指数退避exponential backoff重试间隔随次数指数级增长
虚拟环境virtual environment隔离项目依赖的 Python 环境

Q1. 完整项目里,分页循环最稳妥的结束方式是什么?

动手任务

运行完整脚本,然后验证结果。

  • 运行 python3 crawler.py 输出「完成,共 100 条」
  • data/quotes.csv 共 101 行,无重复数据
  • 删除 CSV 后重跑一次,仍然正常生成(验证表头逻辑)

常见问题 FAQ

目标站点反爬很严,怎么办?

先读 robots.txt 确认自己有没有越界;然后从「礼貌」入手:降低频率、加延时、设随机延时、换更真实的 UA、用 Session 保持连接。再不行才考虑代理池、分布式等重手段——但对绝大多数学习项目,前几条就够了。

抓下来的数据量太大,内存爆了怎么办?

不要让数据全部堆积在内存里。养成「抓一页、存一页」的习惯(本教程的 append_rows 就是这个思路)。海量数据时换用流式写入、SQLite 或数据库,而不是一次性 all_rows.append(...)

页面结构一改,解析就失败,怎么降低脆弱性?

选择器尽量「从稳不从全」:优先用语义明确的 class,少用依赖层级的位置选择器;关键字段加 try/except 兜底;把选择器集中到配置区方便一处修改。爬虫维护的常态就是「跟着改版走」。

会不会因为爬虫被封 IP?

低频率、尊重 robots.txt 的爬虫极少触发封禁。封 IP 通常源于高频请求、异常 UA、或对敏感资源的密集访问。请始终把延时开足、把范围控制在公开内容。

抓下来的数据可以商用吗?

看站点条款(ToS)与内容版权。教学站可随意练习,但很多站点的数据有使用限制。商用前请务必阅读目标站点的授权条款,必要时咨询法律意见——这是爬虫从业者的底线。

为什么用 requests + BeautifulSoup,而不是只用标准库?

标准库 urllib 能做到,但样板代码多、头处理繁琐、没有超时与重试的友好封装。requests 的 API 更直观,bs4 的解析器容错性也远好于手写正则。选型理由详见第 2 章对比表。

参考资源