返回博客

如何使用 Python 和 Scrapeless 抓取多个网页

Alex Johnson
Alex Johnson

Senior Web Scraping Engineer

12-Oct-2026

摘要:

  • 多页面抓取需要输入台账和有界队列。 保留每个规范化 URL 的处理结果,而不是把循环结束当作“已覆盖全部页面”的证明。
  • URL 去重和记录去重解决的是不同问题。 重复的输入不应再次发起请求,而重复的内容则需要记录身份规则来处理。
  • 空页面需要正面来源证据。 仅仅缺少选择器并不能证明来源中不存在任何记录。
  • 导出检查应重新打开文件。 比较 CSV 与 JSONL 中的记录标识符,并将已完成、为空、失败的页面与队列进行核对。

抓取多页数据会把任务从一次提取变成一次收集过程。该过程需要知道被要求访问哪些页面、接受了哪些页面,以及已写入记录来自哪些页面。

因此,学习如何从多个网页抓取数据要从范围和记账开始。Python 循环只是获取机制。本教程构建一个有界 URL 队列,验证每个来源,保留页面结果,对记录去重,并检查导出的文件。

公开演示使用 Quotes to Scrape,通过直接 HTTP 路径获取。相同程序中包含一条有文档说明的 Scrapeless Web Unlocker 获取分支,它需要你的账户密钥,并始终标注为前置条件。当来源需要时,交互式分页则属于另一套浏览器工作流。

流水线概览

该流水线把一份已批准的 URL 列表转换成对账后的页面结果和通过验收的数据文件。将这些工件单独保存,以免失败被隐藏在一个空导出中。

阶段 操作 工件
范围 定义允许的 URL 和必需字段 输入列表和来源契约
队列 规范化并对 URL 输入去重 有界唯一队列
获取 获得来源 HTML 捕获身份和响应结果
提取 验证并解析每个页面 通过验收的记录或失败原因
去重 比较记录身份 带有来源链接的唯一记录
导出 写入并重新打开 CSV 和 JSONL 文件和对账摘要

保持来源操作与应用模式(schema)区分开来。同一来源可能产生多条记录、零条记录或不可用的响应,因此页面计数和记录计数需要使用不同的名称。

选择 URL 列表或来源分页

已存储的 URL 列表适用于来源范围已知的任务。分页适用于从来源自身的导航中发现下一个被允许页面的任务。

对于列表,要同时保留原始输入和规范化后的队列。对于分页,要保存当前页面、观察到的下一步链接或交互、已访问集合以及页面预算。不要假设任意的页码参数就与站点的实际导航相匹配。

分页工作流对比 解释了不同的推进与停止条件。本教程使用有限列表,因此每个尝试过的页面都能直接与某个输入进行对账。

规范化 URL 而不抹掉其含义

URL 规范化应当移除对获取无关的差异,同时保留会影响来源的参数。在本例中,只删除仅含片段的重复项,而保留路径和查询部分。

URI 组件与引用解析区分了片段与路径及查询。不要仅因为两个 URL 看上去相似就删除查询参数;它们可能选择不同的记录或过滤条件。

程序还将输入限制在已批准的演示主机上。真正的采集器应使用其来源清单,而不是接受任意新发现的目标。

前提条件与安装

示例需要在项目环境中安装带有 requests 和 beautifulsoup4 的 Python。检查环境时使用的是 Requests 2.34.2 和 Beautiful Soup 4.15.0;在复现时请记录你自己的项目版本。

直连模式使用公共演示页面,不需要 Scrapeless API 凭证。unlocker 模式需要 SCRAPELESS_API_KEY、对 Web Unlocker 的账户访问权限,以及采集所选来源的许可。
Web Unlocker 是以响应为导向的数据获取产品。其当前渲染配置使用 v2 请求端点和嵌套的 jsRender 字段。API 分支请求 HTML,然后将其传递给与直接模式相同的源特定解析器。

在执行前先审查源范围。示例在一个简短的固定列表上顺序运行;它不是无限制的爬虫,也不是在未经许可的情况下提升请求量的模板。

运行完整的有界采集器

采集器会对输入 URL 去重,记录每个排队页面的结果,并仅导出已接受的记录。其源契约与已检查的 Quotes to Scrape 布局匹配。

演示会导出作者姓名、标签、记录哈希值和源 URL。它在内部使用引文文本来构建内容标识,但不会在导出数据集中重现完整引文。

注意:直接模式是针对公开演示页面执行的。unlocker 分支需要真实的 Scrapeless API 密钥和账号访问权限,因此未被执行。其请求字段已根据当前文档进行检查;直接模式的结果不被视为 Web Unlocker 的性能表现。

python Copy
import csv
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import requests
from bs4 import BeautifulSoup

INPUT = [
    "https://quotes.toscrape.com/page/1/",
    "https://quotes.toscrape.com/page/2/",
    "https://quotes.toscrape.com/page/2/#top",
    "https://quotes.toscrape.com/page/99/",
    "https://quotes.toscrape.com/missing/"
]
MODE = os.environ.get("FETCH_MODE", "direct")
if MODE not in {"direct", "unlocker"}:
    raise ValueError("Unsupported acquisition mode")

def normalize(url):
    parts = urlsplit(url)
    if parts.scheme != "https" or parts.hostname != "quotes.toscrape.com":
        raise ValueError("URL is outside the permitted demo scope")
    return urlunsplit((parts.scheme, parts.netloc.lower(),
                      parts.path or "/", parts.query, ""))

queue = list(dict.fromkeys(normalize(url) for url in INPUT))
pages = []
records = {}
duplicate_records = 0
with requests.Session() as client:
    for url in queue:
        page = {"requested_url": url}
        try:
            if MODE == "unlocker":
                response = client.post(
                    "https://api.scrapeless.com/api/v2/unlocker/request",
                    headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"]},
                    json={"actor": "unlocker.webunlocker",
                          "proxy": {"country": "ANY"},
                          "input": {"url": url, "jsRender": {
                              "enabled": True, "response": {"type": "html"}}}},
                    timeout=60
                )
                response.raise_for_status()
                envelope = response.json()
                html = envelope.get("data")
                if envelope.get("code") != 200 or not isinstance(html, str):
                    raise ValueError("Expected a successful HTML envelope")
                page["final_url"] = None
            else:
                response = client.get(url, timeout=30)
                response.raise_for_status()
                html = response.text
                page["final_url"] = response.url
            soup = BeautifulSoup(html, "html.parser")
            heading = soup.select_one("h1 a")
            grids = soup.select("div.container > div.row > div.col-md-8")
            if not heading or heading.get_text(strip=True) != "Quotes to Scrape" or not grids:
                raise ValueError("Source identity does not match the demo contract")
            grid = grids[-1]
            cards = grid.select("div.quote")
            if not cards and (grid.select_one("ul.pager") is None or
                              "No quotes found!" not in grid.get_text(" ", strip=True)):
                raise ValueError("Empty state is not confirmed")
            extracted = []
            for card in cards:
                text_node = card.select_one("span.text")
                author_node = card.select_one("small.author")
                if not text_node or not author_node:
                    raise ValueError("Required source field missing")
                text = text_node.get_text(strip=True)
                author = author_node.get_text(strip=True)
                if not text or not author:
                    raise ValueError("Required source field empty")
                digest = hashlib.sha256((text + "\n" + author).encode()).hexdigest()
                extracted.append({"record_id": digest, "author": author,
                                  "tags": [x.get_text(strip=True) for x in card.select("a.tag")],
                                  "source_urls": [url]})
            for record in extracted:
                digest = record["record_id"]
                if digest in records:
                    duplicate_records += 1
                    records[digest]["source_urls"].append(url)
                else:
                    records[digest] = record
            page.update(status="completed" if cards else "empty", records=len(cards),
                        captured_at=datetime.now(timezone.utc).isoformat())
        except (requests.RequestException, ValueError, KeyError) as error:
            page.update(status="failed", reason=type(error).__name__)
        pages.append(page)

rows = list(records.values())
with Path("records.jsonl").open("w", encoding="utf-8") as output:
    for row in rows:
        output.write(json.dumps(row, ensure_ascii=False) + "\n")
with Path("records.csv").open("w", encoding="utf-8", newline="") as output:
    writer = csv.DictWriter(output, fieldnames=["record_id", "author", "tags", "source_urls"])
    writer.writeheader()
    for row in rows:
        writer.writerow({**row, "tags": json.dumps(row["tags"], ensure_ascii=False),
                         "source_urls": json.dumps(row["source_urls"])})
with Path("records.csv").open(encoding="utf-8", newline="") as source:
    csv_rows = list(csv.DictReader(source))
jsonl_rows = [json.loads(line) for line in Path("records.jsonl").read_text().splitlines()]
assert [r["record_id"] for r in csv_rows] == [r["record_id"] for r in jsonl_rows]
summary = {"mode": MODE, "input_urls": len(INPUT), "duplicate_inputs": len(INPUT)-len(queue),
           "queued_pages": len(queue), "completed": sum(p["status"]=="completed" for p in pages),
           "empty": sum(p["status"]=="empty" for p in pages),
           "failed": sum(p["status"]=="failed" for p in pages),
           "unique_records": len(rows), "duplicate_records": duplicate_records,
           "csv_rows": len(csv_rows), "jsonl_rows": len(jsonl_rows)}
assert summary["completed"] + summary["empty"] + summary["failed"] == len(queue)
Path("pages.json").write_text(json.dumps(pages, indent=2), encoding="utf-8")
Path("summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(json.dumps(summary))

将程序保存为 multi_page.py 并在项目目录中运行。若没有模式覆盖,则使用直接获取。仅在完成该分支的凭据和源许可前提后,才选择 FETCH_MODE=unlocker。

程序针对每个唯一排队 URL 仅保留一个页面结果。它会在解析卡片前检查预期的源标题,并且在接受空页面之前,需要观察到预期的无结果消息和导航容器。

开始使用 Scrapeless 抓取数据

用 Scrapeless 提升你的网页抓取和自动化工作流!
立即注册即可获得 5 美元免费额度 —— 无需信用卡。

现在就在 Scrapeless 控制台 领取你的免费额度。

提取并校验每条记录

记录校验应要求能标识有意义源数据的字段。示例要求用于计算哈希的引文文本和已观察到的作者姓名同时存在,才会提交某页面的记录。

选择器属于已检查的演示源:div.quote、span.text、small.author 和 a.tag。主数据容器是最后一个匹配的内容列,与标题列不同。当源发生变化时,请重新检查这些假设。

HTTP 响应语义描述的是协议结果,而不是这些源字段的正确性。成功的响应仍然必须满足应用契约。

解析器会在将候选记录添加到输出集之前,为页面构建其候选记录。缺少必需字段会导致该页面被拒绝,而不是默默将不完整的捕获结果当作完整结果导出。

跟踪已完成、失败和为空的页面

页面结果应解释每个排队源发生了什么。演示区分了有效记录页面、已确认的空页面以及 HTTP 或源契约失败。

有界运行使用了五个 URL 输入。移除一个仅片段重复项后,剩下四个排队页面。直接获取运行产生了两个完成页面、一个已确认的空页面和一个失败页面,并将 20 条唯一记录写入两个文件。

Observation Run result Interpretation
Raw URL inputs 5 原始任务范围
Duplicate inputs removed 1 归一化后重复的源
Unique queued pages 4 需要结果的页面
Completed pages 2 源契约和记录被接受
Confirmed empty pages 1 观察到源的无结果状态
Failed pages 1 有意设置的不可用路径案例
Unique exported records 20 内容去重后被接受的记录

这些计数描述的是有界的公开演示,而不是对其他站点的保证结果或托管获取基准。包含不可用路径是为了测试结果对账;它不会被反复请求直到从失败列表中消失。

去重记录并保留源谱系

记录去重在抽取后比较已定义的内容标识。示例将引文文本和作者一起进行哈希,并在同一标识再次出现时保留源 URL。
这是一个针对演示记录类型的精确标识规则。产品标识符、修订版或区域设置可能需要不同的键。不要使用一个通用的文本哈希来合并在任务中差异具有重要意义的记录。

将源文档与已接受的数据一起保留。去重应当保留与源捕获之间的关系,而不是抹去重复内容被观测到的位置。

导出 CSV 和 JSONL,然后对它们进行对账

导出验证应在序列化之后读取工件。程序重新打开 CSV 和 JSONL,并在报告摘要之前比较有序记录标识符。

该演示生成了 20 行 CSV 和 20 条具有匹配标识符的 JSONL 记录。summary.json 保留这些计数,而 pages.json 保留各个页面结果。

CSV 字段引用处理序列化细节。格式有效性与源接受是分开的,因此两项检查都必需。示例将列表值字段作为 JSON 字符串存储在 CSV 单元格内,而在 JSONL 中作为数组存储。

守恒检查很直接:已完成页面、空页面和失败页面之和等于唯一队列大小,并且导出的标识符在文件之间匹配。这些检查无法证明所有源事实,但可以暴露被丢弃的页面或不一致的工件。

当分页需要浏览器交互时

当源的下一个状态需要点击或其他页面操作时,交互式分页需要浏览器工作流。渲染一个返回的 HTML 文档并不会自动成为一个持久的多步会话。

在这种情况下选择专用的 Agent Browser 工作流,检查实际的下一步控件,并等待所需的新记录。在浏览器获取过程中保持相同的页面预算、访问状态跟踪和记录接受策略。

不要将路径隐形混合。记录源是通过直接 HTTP、Web Unlocker 响应还是交互式浏览器会话获得的。获取方式的变化可能会改变页面状态或内容范围。

负责任地处理源范围

多页面任务应保持在批准的源列表内,并且只使用其目的所需的数据。在启用更广泛的采集器之前,查看条款和机器人排除规则。

保持保守的每主机预算。此示例是顺序的;一个使用并发的试点应保持在其被允许的采集策略范围内,例如每个主机限制三个工作线程,而不是假定服务的最大值。

保留未解决的结果以供审查。失败列表是已完成任务证据的一部分,而不是可以从最终报告中省略的不便内容。

结论

使用 Python 抓取多个网页需要有界队列、源特定接受策略和文件对账。只有当每个排队输入都有一个结果并且写出的工件与已接受记录匹配时,采集过程才算完成。

对于合适且允许的静态源使用直接获取,对于需要响应导向路径的任务使用有文档说明的 Web Unlocker 分支,对于交互式分页使用单独的浏览器工作流。在这些选择之间保持台账和输出契约的稳定。

准备构建一个已对账的采集任务了吗?

使用 Scrapeless 规划一个有界工作流,并根据当前定价评估获取需求。在 Telegram 上讨论队列和导出契约。

常见问题解答

问:公开可见性是否建立了采集多页的许可?

公开可见性并不能为每一种采集或使用建立许可。查看源条款和适用要求,在预期活动不确定时咨询合格法律顾问。

问:采集器什么时候应该使用 URL 列表而不是分页?

当允许的源集合是已知的时,使用 URL 列表。当任务通过检查的链接或交互来发现其下一页时,使用源分页。

问:空的选择器结果是一个有效的空页面吗?

仅有空的选择器结果仍然是未解决状态。要求源提供正向的空状态证据,并在记录有效的空结果之前确认页面身份。

问:任务如何表明已处理每个输入 URL?

保留原始输入、去重后的队列,以及每个排队源的一个结果。将已完成、空和失败的结果与该队列进行对账。

问:Web Unlocker 分支是否需要单独配置代理?

所示的托管请求使用了其文档中规定的国家字段。当你的客户端改为使用独立代理产品时,需要单独分配的代理凭据。

问:对于验证码页面或访问被拒绝的文档,应当如何处理?

采集器应当拒绝不合适的源内容,并保留获取原因。HTTP 成功状态并不会使验证码文档成为被接受的数据页面。

问:当源站的标记结构发生轮换时,应当改变什么?

重新检查页面身份和所需字段,然后更新特定于该源的选择器和空状态检查。不要将标记变化视为该源不再有记录的证据。

问:教程应使用多少并发度?

演示的采集器是顺序运行的。获准的试运行可以为每个主机设置一个保守的上限,比如三个工作线程,然后在扩展之前评估运行情况。

问:这个工作流是否需要一个 AI 代理?

Python 队列、抽取和导出检查可以在没有 AI 代理的情况下运行。代理可以消费已接受的记录,或围绕这些确定性的组件来监督任务。

问:请求的 URL 应该存储为最终 URL 还是规范 URL?

将请求的 URL 与观测到的最终或规范身份分开存储。直接模式会记录响应的最终 URL;所示的 API 分支会将该字段留空,而不是凭空捏造一个重定向目标结果。

在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。

最受欢迎的文章

目录