返回博客
网络爬虫技术
Suciu DanLast updated on Apr 30, 20265 min read

如何构建 Python 网络爬虫:从开始到扩展

如何构建 Python 网络爬虫:从开始到扩展
简而言之:Python 网页爬虫能够自动化完成在网站上追踪链接以发现和收集内容的繁琐工作。本指南将带您逐步学习如何使用 requests 和 BeautifulSoup 从零开始构建爬虫,随后进阶到使用 Scrapy 实现并发爬取、项目管道处理以及结构化数据导出。您还将学习如何负责任地进行爬取、轮换代理以避免被封锁,以及处理 JavaScript 渲染的页面。

Python 网络爬虫是一种通过追踪超链接自动浏览网站、发现新页面并收集其内容的程序。如果说网络抓取(web scraping)是针对单个页面提取特定数据点,那么网络爬行(web crawling)则是遍历整个网站(甚至多个网站)以首先找到这些页面。

Python 无疑是完成这项任务最受欢迎的语言。凭借其易读的语法、久经实战考验的 HTTP 库,以及一个字面意义上以网络蜘蛛命名的框架,Python 的生态系统让爬网变得触手可及,同时又不牺牲功能强度。无论您需要绘制电商网站上的每个产品页面地图、构建用于 SEO 分析的反向链接索引,还是将结构化数据输入机器学习管道,一个构建良好的爬虫都是驱动整个过程的引擎。

本教程涵盖了使用 Python 构建网络爬虫的完整生命周期:使用 requests,使用 BeautifulSoup 解析并提取链接,随后借助 Scrapy 爬虫、选择器和项目管道实现规模化。在此过程中,您将学会如何处理相对 URL 和 JSON API 等特殊情况,遵守 robots.txt 规则,控制请求频率,并避免被反机器人系统封锁。 每个章节都包含可运行的代码,您可以直接复制、调整并扩展这些代码以应用于自己的项目。完成本教程后,您将掌握从 20 行代码的原型到生产级爬取管道的完整实现路径。

什么是 Python 网络爬虫?为什么要构建它?

从本质上讲,Python 网络爬虫是一个自动化脚本:它从一个或多个种子 URL 开始,获取页面内容,提取其中发现的每个链接,然后对每个新 URL 重复这一循环。你可以将其视为一位有条不紊的访客,在决定接下来进入哪些房间之前,会先阅读大楼每一层的目录。

“爬取”与“抓取”的区别常令人们感到困惑。爬取是发现阶段:通过遍历链接图来查找页面。抓取是提取阶段:从已定位的页面中提取结构化字段(标题、价格、日期)。实际上,大多数项目都需要这两者,但它们是相互独立的任务,且对工具的要求不同。理解这一区别有助于您选择合适的工具并合理规划项目架构。

那么,为何要用 Python 构建一个呢?有几个具体原因:

  • SEO 审计与反向链接映射:爬取自身网站以查找死链、孤立页面或缺失的元标签。您还可以遍历博客、合作伙伴网站和新闻媒体,发现哪些网站链接到了您或您的竞争对手。
  • 机器学习与分析的数据采集:从数百个页面收集训练数据,并直接导入 pandas DataFrame、特征库或大型语言模型(LLM)训练管道。设计良好的爬虫生成的结构化输出可直接用于下游分析。
  • 价格与库存监控:每晚爬取产品分类页面,追踪数千个 SKU 的价格变动和库存水平。
  • 研究与归档:学术研究人员爬取论坛、政府数据库以及未提供批量下载 API 的公共数据集。
  • 内容聚合:新闻机构和市场调研公司通过爬取行业网站,构建精选资讯流和竞争情报仪表盘。

Python 的生态系统(包括 requests、BeautifulSoup、Scrapy 等众多工具)意味着您只需不到 30 行代码即可构建一个可运行的爬虫原型,随后无需更换编程语言,即可将同一逻辑扩展至数百万个页面。本指南正是围绕这一从原型到生产环境的完整路径展开。

Web 爬虫的底层工作原理

无论是十行代码的脚本还是分布式系统,每个 Python 网络爬虫都遵循相同的根本循环:

  1. 从种子 URL 开始。您提供一个或多个起始地址。这些地址会被放入队列(通常称为“前沿”)。
  2. 抓取页面。爬虫向队列中的下一个 URL 发送 HTTP GET 请求,并接收 HTML 响应。
  3. 解析 HTML。解析器(如 BeautifulSoup、lxml 或 Scrapy 选择器)读取文档,并将文档结构呈现为可遍历的树结构。
  4. 提取链接。解析器会从页面中提取所有 <a href="..."> 链接,以及页面中可发现的其他所有 URL。
  5. 过滤与去重。并非所有链接都值得追踪。爬虫会将每个 URL 与已访问 URL 集合进行比对,应用域名或路径过滤规则,并剔除重复项。此步骤还包括 URL 规范化:去除片段、排序查询参数,并将路径转换为小写,以便 example.com/Pageexample.com/page 不会被视为不同的 URL。
  6. 将新 URL 加入队列。通过筛选的链接将加入前沿队列。
  7. 重复此过程,直到队列清空或满足停止条件(最大深度、最大页面数、时间限制)。

这个循环看似简单,但真正的工程价值在于细节。如何处理返回 301 重定向至已访问 URL 的页面?当服务器响应缓慢且队列中积压了 500 个 URL 时该如何应对?如何避免爬取不同 URL 模式(如会话 ID、跟踪参数、日历小工具)下的相同内容?

一种简单的实现方式是逐个获取 URL,对于几十个页面来说这没问题。但一旦需要爬取数千个页面,你就需要并发处理(同时发送多个请求)、持久化队列以及针对临时故障的重试逻辑。 缺乏重试机制且按顺序抓取页面的简单爬虫,确实不适合生产级别的任务。这正是 Scrapy 设计要填补的空白:它为你提供了一个异步引擎、一个带有去重功能的内置调度器,以及针对循环每个阶段的中间件钩子。

理解这个循环绝非纸上谈兵。当你的爬虫出现异常(漏爬页面、重复访问同一 URL 或运行缓慢甚至停滞)时,问题几乎总是源于这七个步骤中的某一个。若能精准定位故障阶段,故障排查效率将大幅提升。

选择合适的 Python 爬虫工具

在编写任何代码之前,根据项目的规模和复杂度选择合适的库至关重要。以下是构建 Python 网络爬虫的实用决策矩阵:

评估标准

requests + BeautifulSoup

Scrapy

托管式 API 服务

配置时间

分钟

15-30 分钟(项目框架搭建)

分钟(API密钥)

并发

手动(线程/asyncio)

内置异步引擎

已为您处理

去重

由您构建

内置调度器过滤器

已为您处理

JS 渲染

不支持

需要插件(例如 scrapy-playwright)

通常已包含

数据导出

手动(写入文件)

用于 JSON/CSV 的 CLI 参数

因提供商而异

反机器人处理

自行实现(头部字段、代理)

中间件钩子

内置代理轮换、验证码破解

最适合

小型、一次性爬取

中大型、周期性爬取

具有严密机器人防御或JS渲染的网站

当您需要快速制作原型或仅需抓取少量页面的爬虫时,requests + BeautifulSoup 是首选组合。您能掌控每个细节,这对学习非常有利,但对扩展性却十分不利。采用这种方式构建的基础爬虫,若没有仔细的去重逻辑,往往会重复访问同一页面,或在追踪重复链接时陷入死循环。

Scrapy 是一个专为大规模网络爬取而设计的完整框架。它开箱即用,可处理并发、重试、去重和数据管道。其代价是学习曲线较陡峭,且项目结构具有强烈的设计倾向。但一旦掌握了蜘蛛/管道模式,构建新的爬虫就会变得异常迅速。

当爬取的难点不在于解析逻辑而在于基础设施(如代理轮换、破解验证码、渲染 JavaScript)时,使用托管 API 服务是明智之选。您无需自行维护这些技术栈,只需发送请求即可获取 HTML(或 JSON)响应。

选择满足您需求的最简单方案。您随时可以后续升级,本指南将向您展示如何逐步进阶。

配置 Python 环境

干净的环境可避免依赖冲突,并确保项目可重现。以下是 Python 网络爬虫项目的最小配置:

# Create and activate a virtual environment
python3 -m venv crawler-env
source crawler-env/bin/activate   # macOS / Linux
crawler-env\\Scripts\\activate      # Windows

# Install core libraries
pip install requests beautifulsoup4 lxml scrapy

您的项目文件夹结构应类似如下:

my-crawler/
├── crawler-env/
├── simple_crawler.py      # requests + BS4 version
├── scrapy_project/        # generated by scrapy startproject
│   ├── scrapy_project/
│   │   ├── spiders/
│   │   ├── items.py
│   │   ├── pipelines.py
│   │   └── settings.py
│   └── scrapy.cfg
└── requirements.txt

使用 pip freeze > requirements.txt ,确保克隆仓库的用户能获取相同版本。若计划使用无头浏览器处理 JavaScript 渲染的页面,请将 scrapy-playwright 也添加到安装列表中。

lxml 作为 BeautifulSoup 的解析后端被包含进来。它比 Python 的内置解析器快得多 html.parser ,且能更优雅地处理格式错误的 HTML,这在爬取那些标记显然非人类编写的页面时尤为重要。

安装完这些包后,你就可以开始编写代码了。下一节将从零开始构建一个完整的可运行爬虫。

使用 Requests 和 BeautifulSoup 构建基础 Python 网页爬虫

现在是时候编写实际代码了。这个首个爬虫的目标很简单:从一个种子 URL 开始,获取页面,查找其中的每个链接,然后在保持同一域名的条件下访问这些链接。它被刻意设计得非常精简,以便你在增加复杂度之前能看清各个组成部分。

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
import time

def crawl(seed_url, max_pages=20, delay=1):
    visited = set()
    queue = [seed_url]
    allowed_domain = urlparse(seed_url).netloc

    while queue and len(visited) < max_pages:
        url = queue.pop(0)
        if url in visited:
            continue

        try:
            response = requests.get(
                url,
                headers={"User-Agent": "MyCrawler/1.0 (contact@example.com)"},
                timeout=10,
            )
            response.raise_for_status()
        except requests.RequestException as e:
            print(f"Failed to fetch {url}: {e}")
            continue

        visited.add(url)
        print(f"Crawled: {url} ({response.status_code})")

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

        for anchor in soup.find_all("a", href=True):
            link = urljoin(url, anchor["href"])
            parsed = urlparse(link)
            # Strip fragments and stay on the same domain
            clean_link = parsed._replace(fragment="").geturl()
            if parsed.netloc == allowed_domain and clean_link not in visited:
                queue.append(clean_link)

        time.sleep(delay)  # Be polite

    print(f"Done. Visited {len(visited)} pages.")
    return visited

if __name__ == "__main__":
    crawl("https://example.com")

让我们逐一探讨这个 Python 网页爬虫中的关键设计:

  • visited set:这是你的去重机制。在抓取任何 URL 之前,需检查该 URL 是否已存在于集合中。若缺少此机制,爬虫在遇到含有循环导航链接的网站时将陷入无限循环。即便是小型网站,其导航菜单也可能形成循环。
  • urljoin: 将相对路径 /about 转换为绝对 URL,例如 example.com/about。这一点至关重要,因为大多数网站在导航和内部链接中都使用相对 href。
  • 域名过滤urlparse 检查机制可确保爬虫仅在单一域名内运行。若无此机制,一个外部链接就可能导致爬虫在整个互联网中无休止地漫游。这正是有针对性的爬取与失控爬取之间的区别。
  • 片段剥离_replace(fragment="") 调用会从 URL 中移除 #section URL中的锚点。这些锚点指向同一页面的不同位置,而非不同页面,若将其视为独立URL将导致冗余抓取。
  • max_pages 请求上限:一种安全防护机制,在开发阶段尤为重要。您肯定不希望在调试解析器时,意外地发出数千个请求。
  • time.sleep(delay):一项基本的礼貌措施。即使请求间隔仅一秒,也会对目标服务器的负载产生显著影响。
  • 自定义 User-Agent:通过描述性标头(包括联系信息)标识您的爬虫,既符合道德规范,也切实可行。网站通常不太可能屏蔽那些诚实自我标识的爬虫。
  • 错误处理try/except 块用于捕获连接超时、DNS 故障和 HTTP 错误(通过 raise_for_status)。生产环境中的爬虫必须具备此功能;进程崩溃意味着进度丢失,且可能导致数据不完整。

该爬虫采用同步模式,即每次仅抓取一个页面。对于 20 个页面来说这完全没问题,但若面对 2,000 个页面,速度将慢得令人痛苦。我们将在本指南后续内容中转向 Scrapy 时解决这一限制。

链接的提取与过滤

基础爬虫中的链接提取逻辑虽然可行,但现实中的网页往往杂乱无章。锚点标签可能指向 PDF 文件、mailto 地址、JavaScript 空调用、仅含片段的链接,以及同一页面的不同查询字符串版本。一个更智能的过滤器能帮你避免在垃圾 URL 上浪费请求,并确保爬取过程保持专注。

from urllib.parse import urljoin, urlparse

IGNORED_EXTENSIONS = {".pdf", ".jpg", ".png", ".gif", ".zip", ".exe", ".mp4"}

def extract_links(soup, base_url, allowed_domain):
    links = set()
    for anchor in soup.find_all("a", href=True):
        raw = anchor["href"]

        # Skip non-HTTP schemes
        if raw.startswith(("mailto:", "javascript:", "tel:", "#")):
            continue

        full_url = urljoin(base_url, raw)
        parsed = urlparse(full_url)

        # Strip fragments for deduplication
        clean = parsed._replace(fragment="").geturl()

        # Domain filter
        if parsed.netloc != allowed_domain:
            continue

        # Extension filter
        if any(clean.lower().endswith(ext) for ext in IGNORED_EXTENSIONS):
            continue

        links.add(clean)
    return links

此函数可处理使用 Python 爬取网站时最常见的边界情况:它能解析相对 URL、去除片段标识符(即 #section 不改变实际页面内容的部分),忽略非HTTP协议,并跳过二进制文件扩展名。最终生成一组干净的同域URL,可直接加入爬取队列。

若需更强大的重复 URL 检测能力,建议通过按字母顺序排序来规范化查询参数。两个仅在参数顺序上不同的 URL(?a=1&b=2 vs. ?b=2&a=1)通常返回相同的内容,将它们视为不同URL会浪费带宽。您还可以通过检查HTML中的 <link rel="canonical"> 标签,该标签会告知您某条内容的首选 URL。

另一项有用的技术是 URL 模式检测。如果您发现爬虫生成了数千个符合特定模式的 URL(例如 /calendar?date=2024-01-01, /calendar?date=2024-01-02等模式,您可添加正则表达式拒绝列表,在这些路径进入队列前直接将其拦截。

处理相对 URL 和边界情况

相对 URL 是初次编写 Python 网络爬虫时最常见的错误来源。位于 example.com/blog/ 可能包含如下链接: ../about, ./post-1,甚至 //cdn.example.com/image.png。Python的 urllib.parse.urljoin 能正确处理所有这些情况,这也是它出现在迄今为止所有代码示例中的原因。

除了相对路径外,还需注意以下边界情况:

  • 重定向链:301 或 302 重定向意味着最终 URL 与您请求的 URL 不同。在将页面添加到已访问集合时,请使用 response.url (而非原始请求 URL)来更新已访问集合,否则你将因不同地址而重复爬取同一页面。
  • 软 404:某些网站返回 200 状态码,但返回的是通用的“页面未找到”正文。如果你正在提取数据,请在将页面视为有效之前检查内容标记(如产品标题)。
  • URL编码字符%20 与字面空格, %2F vs. /。请在去重前对这些字符进行标准化处理,以免将编码和未编码的变体视为独立的 URL。
  • 无限URL模式:日历小工具、路径中的会话ID或筛选器组合可能生成数量无限且外观各异的URL,但它们提供的内容却大同小异。请设置最大爬取深度或使用URL模式检测来打破这种循环。

提前处理这些问题可节省后续数小时的调试时间。相比因格式错误的 URL 而直接崩溃的爬虫,那些因尾部斜杠而默默重复计数页面或遗漏内容的爬虫更难修复。

爬取和解析 JSON API

并非所有网站都以 HTML 形式提供数据。许多现代 Web 应用程序从内部 API 加载内容,这些 API 返回 JSON 数据,而非将数据直接嵌入页面标记中。如果您在浏览器的开发者工具中检查网络请求(“网络”选项卡,按 XHR/Fetch 过滤),通常会发现一些端点,它们直接提供结构化数据,无需进行任何 HTML 解析。

以下是爬取分页 JSON API 的典型模式:

import requests
import json
import time

def crawl_json_api(base_url, max_pages=10, delay=1):
    all_items = []
    page = 1

    while page <= max_pages:
        response = requests.get(
            base_url,
            params={"page": page, "per_page": 50},
            headers={"Accept": "application/json"},
            timeout=10,
        )
        response.raise_for_status()
        data = response.json()

        items = data.get("results", [])
        if not items:
            break  # No more data

        all_items.extend(items)
        print(f"Page {page}: fetched {len(items)} items")

        # Check for explicit pagination metadata
        if not data.get("has_next", True):
            break

        page += 1
        time.sleep(delay)

    return all_items

# Example usage
items = crawl_json_api("https://api.example.com/products")
print(f"Total items collected: {len(items)}")

该方法与 HTML 爬取几乎完全相同,但有两个关键区别。首先,你可以完全跳过 HTML 解析步骤,因为 response.json() 会直接返回原生的 Python 字典。其次,分页通常是显式的:API 要么返回一个 next URL、一个 has_next 标志,或者通过递增 page 参数直至返回的结果数组为空。

当 API 需要身份验证(API 密钥或会话令牌)时,请通过请求头而非查询参数传递凭据,以避免凭据泄露到服务器日志中。并且务必检查速率限制头部(X-RateLimit-Remaining, Retry-After)。遵守这些头部既是一种礼貌,也十分实用,因为若忽略它们,服务器将切断你的请求。

这种方法与 pandas 等工具配合得很好。一旦从 JSON 爬取中获得了一组字典,只需一次 pd.DataFrame(items) 调用即可。随后,您可以使用 pandas 对收集到的数据进行清洗、过滤和分析,或直接基于这些数据训练机器学习模型。

借助 Scrapy 实现扩展

当您的爬取需求超出同步 requests 循环的处理能力,Scrapy 便是顺理成章的下一步选择。这是一个功能齐全的 Python 框架,专为大规模网络爬取而设计,它能处理复杂的基础架构问题(并发、重试、去重和限流),让你可以专注于解析逻辑。

Scrapy 的架构由五个协同工作的核心组件构成:

  • 引擎:中央协调器。它将请求传递给下载器,并将响应传递给爬虫,从而协调整个爬取周期。
  • 调度器:管理请求队列,并利用指纹识别技术自动去除重复 URL。您无需手动构建 visited 数据集。
  • 下载器(Downloader):利用 Twisted 的事件循环异步发送 HTTP 请求,这意味着数百个请求可以同时进行,且无需承担线程开销。
  • 爬虫(Spiders):您的代码。每个爬虫类定义了从哪些 URL 开始爬取以及如何解析每个响应。这是您领域特定逻辑的所在。
  • 数据处理管道:用于清理、验证和存储蜘蛛提取的数据的后处理阶段。您可以串联多个管道以处理不同的需求。

这种架构的强大之处在于每个组件都是可插拔的。需要在每个请求中添加自定义头部?编写一个下载器中间件。想过滤掉字段缺失的条目?添加一个验证管道。需要将结果导入数据库而非 JSON 文件?将默认导出器替换为自定义管道。

与单线程的 requests 循环相比,Scrapy 处理网页的速度显著更快。该框架能在遵守您定义的速率限制的同时,处理跨多个域名的并发请求。此外,它默认会遵守 robots.txt 规则(通过 ROBOTSTXT_OBEY 设置),而这是许多自制的爬虫常忽略的实现。

相应的代价是复杂性。Scrapy 拥有特定的项目结构、一定的学习曲线,以及专属术语(如蜘蛛、项目、管道、中间件)。但一旦掌握了这种模式,你就能以惊人的速度构建生产级别的爬虫。本指南的后续内容将向你展示具体方法。

创建 Scrapy 项目和您的第一个 Spider

让我们搭建一个真正的 Scrapy 项目。在虚拟环境中打开终端并运行:

scrapy startproject bookstore
cd bookstore
scrapy genspider books books.toscrape.com

这将生成完整的目录树: settings.py, items.py, pipelines.py,以及一个 spiders/ 包含您新 books.py 蜘蛛。 genspider 命令会预先为蜘蛛填充正确的 allowed_domains 以及一个初始 start_urls 。打开该蜘蛛文件,用可运行的解析器替换默认模板:

import scrapy

class BooksSpider(scrapy.Spider):
    name = "books"
    allowed_domains = ["books.toscrape.com"]
    start_urls = ["https://books.toscrape.com/"]

    def parse(self, response):
        for book in response.css("article.product_pod"):
            yield {
                "title": book.css("h3 a::attr(title)").get(),
                "price": book.css(".price_color::text").get(),
                "availability": book.css(
                    ".instock.availability::text"
                ).getall()[-1].strip(),
            }

        # Follow the "next" pagination link
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

运行蜘蛛程序:

scrapy crawl books -o books.json

这一条命令即可启动引擎,抓取所有分页列表页面,提取图书数据,并将结果写入 JSON 文件。无需手动循环,无需已访问集合,也无需文件写入的冗余代码。这就是基于正规框架构建的 Python 网络爬虫的强大之处。

关于这个蜘蛛,有几点值得注意:

  • allowed_domains 将爬虫限制在目标站点内。解析过程中发现的任何域外链接都会被静默忽略,从而防止蜘蛛漫游到外部站点。
  • response.css() 在响应正文中使用 CSS 选择器。Scrapy 仅解析一次 HTML 并缓存解析后的树结构,因此对同一响应调用多个选择器的开销很低。
  • yield 而非 return:Scrapy 爬虫是生成器。您只需返回项目(字典或 Item 对象)和请求,引擎会自动决定何时以及如何调度它们,并为您管理并发。
  • response.follow() 内部处理相对 URL(无需 urljoin )并通过调度器的指纹过滤器自动对已访问过的 URL 进行去重。

这仅需约 20 行代码,便是一个完整且可运行的网络爬虫。其余所有内容(HTTP 处理、调度、并发下载、导出)均由 Scrapy 框架管理。在此基础上,你可以通过更深层次的链接追踪、额外的解析回调以及管道处理来扩展该爬虫。

跨页链接追踪

分页是构建 Python 网络爬虫时最常见的模式之一。大多数列表网站会将结果分页显示,您的爬虫需要跟随这些“下一页”链接,直到链接耗尽。上文中的 Scrapy 爬虫已经展示了基本方法,但让我们来看一个更强大的版本,它使用 Scrapy 的 LinkExtractor.

import scrapy
from scrapy.linkextractors import LinkExtractor

class DeepCrawlSpider(scrapy.Spider):
    name = "deepcrawl"
    start_urls = ["https://example.com/catalog"]
    allowed_domains = ["example.com"]

    link_extractor = LinkExtractor(
        allow=r"/catalog/",
        deny=[r"/login", r"/cart", r"/account"],
    )

    def parse(self, response):
        # Extract data from the current page
        for product in response.css(".product-card"):
            yield {
                "name": product.css("h2::text").get(),
                "url": response.urljoin(product.css("a::attr(href)").get()),
                "price": product.css(".price::text").get(),
            }

        # Follow all matching links found on the page
        for link in self.link_extractor.extract_links(response):
            yield scrapy.Request(link.url, callback=self.parse)

LinkExtractor 是 Scrapy 用于根据正则表达式模式从页面中提取链接的工具。 allow 参数仅保留匹配 /catalog/,而 deny 则会过滤掉登录、购物车和账户页面,以免浪费请求。这比在 parse 方法中手动编写 URL 检查要易于维护得多,尤其是在排除模式数量增加时。

对于使用“加载更多”按钮而非传统分页链接的网站,通常可以在“网络”选项卡中找到底层 API 端点。通过递增页面或偏移量参数来手动构建下一个请求,这与本指南前面讨论的 JSON API 爬取模式完全相同。

一个常见的错误是忘记设置深度限制。Scrapy 的 DEPTH_LIMIT 设置限制了爬虫从种子 URL 开始最多跟随多少次链接跳转。若不设置此限制,在大型网站上运行的蜘蛛可能会在您察觉之前就排队数百万个 URL。开发阶段请从保守的限制(3-5)开始,待蜘蛛运行稳定且您对过滤逻辑有信心后再逐步增加。

另一种有用的技巧是结合使用 CrawlSpider (Scrapy 的内置类)与 Rule 对象相结合。这种方法允许你以声明式方式定义链接追踪规则,从而将导航逻辑与数据提取逻辑分离。这使得复杂的多层级爬取更易于理解。

数据提取中的XPath与CSS选择器

Scrapy 同时支持使用 CSS 选择器和 XPath 表达式来解析 HTML,你可以在同一个蜘蛛中自由混合使用它们。了解何时使用哪种方法可以节省时间,并保持选择器的可读性。

功能

CSS 选择器

XPath

语法

前端开发者熟悉

XML查询语言

文本提取

::text 伪元素

text() 函数

属性访问

::attr(href)

@href

父元素遍历

不支持

..ancestor::

条件逻辑

有限 (:nth-child, :not)

丰富(contains(), starts-with()(布尔运算符)

可读性

通常更简洁

冗长但更具表现力

当您需要根据类名、ID 或简单层级定位元素时,请使用 CSS 选择器。它们更简短、更易读,且足以满足大多数数据提取任务的需求:

# CSS: get all product titles
titles = response.css("h3.product-title::text").getall()

# CSS: get the href of every link inside a nav element
nav_links = response.css("nav a::attr(href)").getall()

当需要向上遍历 DOM 树、匹配部分文本内容,或应用 CSS 无法表达的条件逻辑时,请使用 XPath

# XPath: find links whose visible text contains "Next"
next_link = response.xpath('//a[contains(text(), "Next")]/@href').get()

# XPath: get the parent div of a specific span
parent = response.xpath('//span[@class="price"]/..').get()

# XPath: select items where the price is not empty
priced_items = response.xpath(
    '//div[@class="product"][.//span[@class="price" and text()]]'
).getall()

实际上,大多数 Scrapy 爬虫约 80% 的数据提取工作使用 CSS 选择器,仅在 CSS 无法胜任的剩余特殊情况下才切换到 XPath。Scrapy 会在执行前将 CSS 选择器内部转换为 XPath,因此这两种方法在性能上并无差异。针对每项具体的提取任务,请选择能更清晰表达您意图的那种。

一个实用技巧:在调试选择器时,使用 scrapy shell "https://target-url.com" 打开交互式会话。您无需运行完整的爬虫程序,即可在实时页面上测试 CSS 和 XPath 表达式,这能显著加快开发速度。

将爬取的数据导出为 JSON 和 CSV

Scrapy 内置的 Feed 导出功能无需任何自定义代码即可处理最常见的输出格式。您已经看到了基本的导出命令:

# Export to JSON
scrapy crawl books -o output.json

# Export to CSV
scrapy crawl books -o output.csv

# Export to JSON Lines (one JSON object per line, better for large datasets)
scrapy crawl books -o output.jsonl

`-f` -o 标志会在文件已存在时追加内容,这在重复运行时可能会导致 JSON 格式错误。请改用 -O (大写 O,Scrapy 2.3+ 版本可用) 进行覆盖。

若需更精细地控制数据导出流程,请在 settings.py:

FEEDS = {
    "data/books.json": {
        "format": "json",
        "encoding": "utf-8",
        "indent": 2,
        "overwrite": True,
    },
    "data/books.csv": {
        "format": "csv",
        "fields": ["title", "price", "availability"],
    },
}

The FEEDS 字典可让您同时输出多种格式、控制 CSV 中的字段顺序并设置编码。当不同用户需要不同格式时,此功能尤为有用:您的分析团队需要包含特定列的 CSV,API 用户需要用于流式摄取的 JSON Lines,而您的归档系统则需要格式优化的 JSON 快照。

JSON Lines 格式(.jsonl) 在大规模爬取中值得特别关注。与将所有内容封装在单个数组中的标准 JSON 不同,JSON Lines 每行写入一个完整的 JSON 对象。这意味着您可以逐行流式处理文件,无需重新解析整个文件即可追加新记录,并且如果爬取中途崩溃,还能恢复部分结果。

如果您需要将数据推送到数据库、消息队列或云存储桶,请完全跳过文件导出器,直接编写自定义项管道。这种方法能让您完全掌控验证、转换和存储逻辑。

使用 Scrapy 项管道清理数据

原始爬取数据几乎总是杂乱无章的。价格数据可能带有尾随空格,标题中可能包含多余的换行符,有些页面返回的记录也不完整。Scrapy 的项管道系统允许你在数据提取与导出之间对每个项进行处理,从而确保输出结果的一致性和有效性。

以下是一个处理三种最常见清理任务的管道:

from scrapy.exceptions import DropItem

class CleaningPipeline:
    def __init__(self):
        self.seen_titles = set()

    def process_item(self, item, spider):
        # 1. Strip whitespace from all string fields
        for field in item:
            if isinstance(item[field], str):
                item[field] = item[field].strip()

        # 2. Validate required fields
        if not item.get("title"):
            raise DropItem(f"Missing title: {item}")

        # 3. Drop duplicates based on title
        if item["title"] in self.seen_titles:
            raise DropItem(f"Duplicate: {item['title']}")
        self.seen_titles.add(item["title"])

        return item

要激活该管道,请将其注册在 settings.py:

ITEM_PIPELINES = {
    "bookstore.pipelines.CleaningPipeline": 300,
}

整数 (300) 代表优先级。数值越小执行越早,因此您可以串联多个管道:将清理管道设为 300,验证管道设为 400,数据库写入管道设为 500。每个管道接收项数据并进行处理,处理后要么返回修改后的项(将其传递给下一个管道),要么抛出 DropItem 来将其完全丢弃。

抛出 DropItem 会将项从输出中移除并记录一条消息。这比事后过滤更干净,因为被丢弃的项永远不会到达导出器或数据库。您可以监控爬取的丢弃率,以便尽早发现解析问题。

对于需要从数十种不同页面类型中提取数据的项目,建议定义正式的 Scrapy Item(或基于 dataclass 的项加载器),而非使用普通字典。Item 能够强制执行数据模式、提供默认值,并配合 Scrapy 的项加载器处理器进行字段级转换,例如 MapCompose(str.strip, str.lower)。这在团队项目中尤为重要,因为多个开发者会基于相同的数据模型编写爬虫。

负责任的爬网:Robots.txt、速率限制与道德规范

一个无视网站规则的 Python 网络爬虫最终会被封禁,在某些司法管辖区,这甚至可能引发法律风险。负责任的爬取不仅是基本礼仪,更是任何需要长期稳定运行的爬虫必须满足的实际要求。

遵守 robots.txt

robots.txt 文件位于每个网站的根目录下(例如 https://example.com/robots.txt),用于告知爬虫哪些路径禁止访问以及应以何种速率发起请求。以下是使用 Python 标准库进行程序化解析的方法:

from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url("https://example.com/robots.txt")
rp.read()

if rp.can_fetch("*", "https://example.com/private/data"):
    print("Allowed")
else:
    print("Blocked by robots.txt")

crawl_delay = rp.crawl_delay("*")
print(f"Recommended delay: {crawl_delay} seconds")

ROBOTSTXT_OBEY = True (默认设置)。若您使用 requests 和 BeautifulSoup,则需要在每次抓取前自行实现此检查。

配置爬行延迟与限流

即使 robots.txt 未指定爬行延迟,向服务器发送数百个并发请求也会迅速导致 IP 被封禁。在 Scrapy 中,有三个设置用于控制爬行速度:

# settings.py
DOWNLOAD_DELAY = 1                      # seconds between requests
CONCURRENT_REQUESTS_PER_DOMAIN = 8      # max parallel requests to one domain
AUTOTHROTTLE_ENABLED = True             # dynamically adjusts delay based on load
AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0   # target number of parallel requests

AUTOTHROTTLE 特别有用,因为它会根据服务器响应时间自动调整。如果服务器响应迅速,Scrapy 会加快速度;如果响应时间激增(表明服务器负载过重),它会减缓速度。这在吞吐量和礼貌性之间取得了平衡,而无需您猜测正确的固定延迟值。

道德准则

除了技术设置外,请遵循以下原则:

  • 使用包含联系信息的描述性 User-Agent 字符串来标识您的爬虫。
  • 除非获得明确许可,否则请勿抓取受登录墙或付费墙保护的页面。
  • 开发期间请在本地缓存响应,以免每次测试运行都访问生产服务器。
  • 即使在您所在司法管辖区不具有法律约束力,也请将《机器人排除协议》作为基本准则予以遵守。

当您扩大规模时,请记住:网站并非为处理数百个同时进行的机器人请求而设计的。服务器过载会影响真实用户,而负责任的爬取行为能确保您明天访问同一网站时,不会发现自己的 IP 地址被列入封禁名单。

避免被封:用户代理、代理服务器与反爬虫策略

即便是“彬彬有礼”的爬虫也会被封禁。网站部署的反机器人系统会检测特定模式:来自同一 IP 的重复请求、缺失或通用 User-Agent 头,以及人类用户不可能产生的请求时间间隔。以下是让您的 Python 网络爬虫更具抗封能力的技巧。

User-Agent 轮换

大多数 HTTP 库的默认 User-Agent 会将其识别为机器人。基于此标头进行过滤的网站会立即拒绝您的请求。请设置一个逼真的、类似浏览器的标头:

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/124.0.0.0 Safari/537.36"
}
response = requests.get(url, headers=headers, timeout=10)

对于较长的爬取会话,请轮换使用一组 User-Agent 字符串,以避免每次请求都暴露相同的指纹。在 Scrapy 中, scrapy-fake-useragent 中间件会自动处理这种轮换。

代理轮换

基于 IP 的速率限制是最常见的封锁机制。如果所有请求都来自单一地址,网站会立即识别出这种模式。通过轮换代理路由流量,可以将请求分散到多个 IP 上,使每个请求看起来像来自独立的访问者。

住宅代理尤其有效,因为它们使用分配给真实家庭的 IP 地址,使其几乎无法与普通用户流量区分开来。数据中心 IP 虽然速度更快、成本更低,但更容易被反机器人系统识别指纹并批量封锁。

识别被封锁的迹象

在投入反制措施之前,请先学会识别以下症状:

  • HTTP 403 或 429:明确的访问拒绝或速率限制响应。
  • 重定向至验证码页面:服务器要求您证明自己是真人。
  • 空白或占位符 HTML:页面虽已加载,但无实质内容,仅显示骨架或“请稍候”提示。
  • 响应时间突然激增:服务器正在故意拖慢你的请求(一种称为“陷阱”的技术)。

当检测到阻塞时,请先退避再重试。指数退避(先等待 1 秒,然后 2 秒,接着 4 秒,再 8 秒)是一个合理的默认策略。Scrapy 的重试中间件会自动处理瞬时故障,但持续性阻塞通常需要调整策略:使用不同 IP、降低请求速率,或者针对仅向真实浏览器提供内容的网站添加渲染层。

反机器人防御是一场军备竞赛。一旦涉及 JavaScript 验证、浏览器指纹识别和 CAPTCHA,简单的爬虫脚本就会陷入僵局。务实的做法通常是将这种复杂性交由专门的服务来处理,而不是自己维护代理池和浏览器自动化基础设施。

处理 JavaScript 渲染的页面

越来越多的网站依赖客户端 JavaScript 来渲染内容。当您使用 requests获取此类页面时,您会得到一个包含空 <div> 容器和一堆 JavaScript 代码的 HTML 框架。实际数据需在浏览器环境中执行完脚本后才会加载,这意味着传统的基于 HTTP 的爬虫无法获取任何有用的信息。

在 Python 中构建网络爬虫时,您有三种主要方法来应对这一问题:

1. 查找底层 API。在考虑使用无头浏览器之前,请先打开浏览器的开发者工具并查看“网络”标签页。许多单页应用程序(SPA)是从 JSON API 获取数据的,您可以直接调用该 API,从而完全绕过渲染问题。如果可行,这是最快且最节省资源的方法。

2. 使用无头浏览器。Playwright 和 Puppeteer 等工具允许您通过代码控制真实的(无头)Chrome 或 Firefox 实例。浏览器会执行 JavaScript,等待内容渲染完成,随后您即可从完全加载的 DOM 中提取数据。Scrapy 通过 scrapy-playwright 插件与 Playwright 集成,该插件允许您有选择地将特定请求标记为需要浏览器渲染,同时将其余请求保持为快速、轻量的 HTTP 调用:

# In a Scrapy spider, mark a request for Playwright rendering
yield scrapy.Request(
    url,
    meta={"playwright": True, "playwright_page_methods": [
        {"method": "wait_for_selector", "args": [".product-list"]},
    ]},
    callback=self.parse_products,
)

3. 使用托管渲染服务。如果您不想运行和维护无头浏览器基础设施(这会消耗大量内存和 CPU),托管 API 服务可以为您处理渲染工作。它们会返回完全加载的 HTML,因此您可以使用现有的 BeautifulSoup 或 Scrapy 选择器进行解析。

正确的选择取决于数据量和复杂度。对于几百个 JavaScript 密集型页面,本地无头浏览器完全可以胜任。但对于数千个带有反机器人保护措施的跨站点页面,管理浏览器实例、处理内存泄漏以及从崩溃中恢复所产生的运维开销会迅速累积。

使用托管 API 简化复杂的爬取任务

在某个阶段,构建 Python 网络爬虫最困难的部分不再是解析逻辑,而是其他所有环节:维护代理池、破解验证码、轮换浏览器指纹,以及应对每周更新防御机制的反机器人系统。当基础设施负担超过数据提取工作时,将这一层工作外包出去,专注于代码真正关心的核心——数据,便显得合情合理。

托管 API 服务位于您的爬虫与目标网站之间。您只需发送包含目标 URL 的请求,该服务便会在后台处理代理轮换、JavaScript 渲染、重试以及反机器人对策。返回的结果是干净的 HTML(或结构化 JSON),您可以使用现有的 BeautifulSoup 或 Scrapy 代码进行解析。您的爬取逻辑无需改变,仅需调整数据获取层。

在以下情况下,这种方法尤为实用:

  • 您正在爬取那些采用激进机器人检测机制的网站,这类网站会在几分钟内封禁数据中心的 IP 地址。
  • 您需要大规模的 JavaScript 渲染,但不想管理大量无头浏览器实例及其相关的内存和 CPU 成本。
  • 您的团队更希望将工程时间投入数据分析和管道开发,而非代理基础设施的维护。
  • 您需要爬取众多目标网站,且每个网站都有其独特的反机器人防护机制,使得通用的本地解决方案难以实施。

权衡点在于成本。您需要按成功请求付费,而非自行运维基础设施。对于目标网站防御强度较低的大规模、长期爬取任务,自建方案在经济上更具优势。但对于涉及受机器人防护网站的大多数项目而言,节省的开发时间足以抵消按请求付费的成本。

关键要点

  • 从简单开始,再有计划地扩展。一个基本的 requests + BeautifulSoup 爬虫足以应对小型任务和学习需求。当需要并发处理、自动去重以及结构化数据管道时,再转向 Scrapy。
  • 去重是不可或缺的。使用经过适当规范化的已访问 URL 集合(或让 Scrapy 的调度器处理),以防止无限循环和带宽浪费。
  • 每次爬取都要负责任。遵守 robots.txt 规则,配置爬取延迟,使用 AUTOTHROTTLE,并通过描述性 User-Agent 标识您的机器人。这既能保护目标网站,也能维护您自身的 IP 声誉。
  • 有意识地处理 JavaScript。首先检查底层 API,必要时使用无头浏览器,若需大规模渲染 JS,请考虑使用托管服务。
  • 在爬取过程中清理数据,而非事后处理。Scrapy 的项管道(item pipelines)允许您在数据进入导出文件或数据库之前,对其进行验证、去重和转换。

常见问题

网络爬取与网络抓取有何区别?

爬取是发现过程:自动化程序通过页面间的超链接追踪,以绘制网站结构并查找 URL。抓取则是提取步骤:从已定位的页面中提取特定数据字段(如价格、标题、日期)。大多数实际项目会结合这两者,但它们解决的问题不同,且通常需要采用不同的工具和策略。

使用 Python 爬取网站是否合法?

这取决于管辖区域、网站的服务条款以及您收集的数据类型。在美国,2022年hiQ诉LinkedIn案的裁决确认,访问公开数据并不违反《计算机欺诈与滥用法案》。然而,服务条款的限制、版权法以及GDPR等隐私法规仍可能适用。在进行大规模爬取前,尤其是用于商业用途时,请务必咨询法律顾问。

爬取JavaScript密集型网站时该如何处理?

首先检查浏览器“网络”标签页中的 XHR/Fetch 请求,以确认是否存在底层 API。如果数据仅在客户端渲染后才可用,请使用 Playwright 或 Puppeteer 等无头浏览器来执行 JavaScript 并提取完全渲染后的 DOM。对于大规模的 JS 爬取,托管渲染服务可以处理浏览器的协调工作,这样您就不需要自己维护该基础设施。

如何防止 Python 爬虫被封禁?

轮换 User-Agent 字符串,使用住宅代理将请求分散到多个 IP 地址,在请求之间添加随机延迟,并遵守 robots.txt 中的 crawl-delay 指令。密切监控响应状态码:403 或 429 响应的激增意味着网站已检测到您的流量模式。暂停爬取并降低并发量,通常比试图强行突破封锁更有效。

在什么情况下应该使用 Scrapy 而不是 requests 和 BeautifulSoup?

当您的爬取涉及数百个以上页面、需要并发请求、需要内置去重功能,或能从结构化数据管道和导出中获益时,请使用 Scrapy。对于针对少量页面、快速且仅需执行一次的脚本,requests 和 BeautifulSoup 的配置更快,调试也更简单。如果您的项目规模超出单个脚本文件的范围,Scrapy 的架构将使您无需重复开发这些功能。

结论

构建 Python 网络爬虫是一个渐进的过程,而非一步到位。您通常从几行代码开始,使用 requests 和 BeautifulSoup 编写几行代码,理解“抓取-解析-提取”循环。在此基础上,你转向 Scrapy 以获得并发处理、自动去重、选择器灵活性,以及基于管道的数据清理功能,而无需自己编写这些基础架构。

无论规模大小,基本原则始终如一:尊重被爬取的网站,积极去重,优雅地处理错误,并在存储前清理数据。当目标网站通过验证码、IP封禁或仅JavaScript渲染进行防御时,你有明确的决策路径:首先检查是否存在底层API,中等规模任务使用无头浏览器,而重度任务则依赖托管服务。

如果您发现自己花在代理轮换、验证码破解和反机器人应对措施上的时间,比实际数据处理的时间还要多,WebScrapingAPI 可以为您处理这一基础设施层。 它通过单一接口管理代理、JavaScript 渲染和重试机制,因此您的 Scrapy 爬虫或 BeautifulSoup 脚本只需极少代码修改即可持续运行。这样,您就能专注于数据所传达的信息,而非如何突破访问限制。

关于作者
Suciu Dan, 联合创始人 @ WebScrapingAPI
Suciu Dan联合创始人

Suciu Dan 是 WebScrapingAPI 的联合创始人,他撰写了关于 Python 网页抓取、Ruby 网页抓取以及代理基础设施的实用指南,这些指南专为开发者而设计。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。