跳至内容
返回博客

使用 AWS Lambda 进行网页抓取:Python、Java 指南 2026

Suciu Dan最后更新于 6 min read
使用 AWS Lambda 进行网页抓取:Python、Java 指南 2026
简而言之:使用 AWS Lambda 进行网页抓取时,最佳实践是确保每次调用时间短、范围有限且可独立重试。建议从直接 HTTP、AWS SAM 和 S3 开始,仅当工作负载确实需要时,再添加 SQS、容器、浏览器渲染、代理或托管抓取层。

使用 AWS Lambda 进行网页抓取,是指将页面获取和数据提取代码作为短暂运行的 AWS 函数来执行,而非维护专门的抓取服务器。Lambda 会根据计划任务、队列、HTTP 调用或其他 AWS 事件来触发这些代码。

这种运行模式虽然极具吸引力,但并非所有爬虫都适合采用无服务器架构。 Lambda 适用于定时产品检查、单 URL 任务、Webhook 驱动的提取以及队列处理程序。对于需要数小时不间断运行时间、必须保持浏览器会话活跃,或针对敏感目标进行不受控扇出(fan-out)的爬取任务,其适用性则较弱。

本指南采用“决策优先”的方法。您将构建一个基于 Python 的 AWS Lambda 网页抓取工具,了解使用 HttpClient 和 Jsoup 的 Java 21 对应方案,比较 ZIP 和容器打包方式,将结果持久化到 S3,并通过 SQS 扩展 URL 处理能力。 您还将获得针对 JavaScript 和阻塞操作的保守升级路径,以及将 Lambda 计算成本与存储、日志、网络、镜像、代理和 API 费用区分开来的成本计算公式。与时间相关的 AWS 数值均已明确标注,以便您对照链接的官方文档进行核对。

首先从部署决策开始:Lambda 是否是“使用 AWS Lambda 进行网页抓取”的合适运行时?

简短的回答是肯定的——当抓取任务能在单次限定调用内完成,或可拆分为独立单元(如单个 URL、单个列表页面或单个游标)时,Lambda 便是理想选择。对于定时检查和突发性任务,使用 AWS Lambda 进行网页抓取尤为有效,因为无需在事件之间持续运行工作器集群。

关键问题不在于 Lambda 能否抓取页面——它当然可以。问题在于,当每次调用都是临时性的时,故障、重试、状态和吞吐量是否仍可控。

工作负载特征

适合 Lambda 的场景

警示信号

运行时

几秒或几分钟

一次爬取需要数小时

状态

输入包含所需的一切

长期存在的浏览器或登录状态

并行处理

具有明确上限的独立URL

爬虫能发现无限的工作任务

依赖关系

HTTP 解析器或受控图像

庞大且脆弱的桌面技术栈

输出

每完成一项任务后持久化写入

结果仅存在于内存中

故障模型

可以安全地重试一个 URL

重试会导致副作用重复

一条有用的设计准则是将函数设计为可丢弃的。如果 AWS 在处理完一个请求后终止了环境,替代环境应能处理同一事件,而无需重建隐藏的本地状态。这将检查点、结果和凭据推入专用服务,而非 /tmp 或模块的全局变量中。

根据任务性质选择静态 HTML、爬虫、浏览器渲染或托管 API

选择复杂度最低且能返回所需数据的检索方法。静态页面通常需要 HTTP 客户端和解析器。多页面爬取可能需要使用 Scrapy。客户端渲染的应用程序可能需要浏览器、您有权调用的底层 JSON 端点,或托管渲染服务。

需求

首选方案

在以下情况下向上选择

服务器端渲染的 HTML

requests 加 BeautifulSoup

响应中缺少内容

基于链接的爬取

ZIP或图像中的Scrapy

原生依赖项超出 ZIP 压缩限制

点击、滚动、表单

容器中的 Playwright

浏览器操作占据运行时主导地位

阻塞风险高

先进行速率控制,再使用代理

IP声誉或地理位置至关重要

渲染加代理操作

托管式爬取API

维护浏览器和代理逻辑的成本高于外包数据抓取

渲染、会话状态、运行时长、请求量以及被封禁的风险应作为决策依据。无头浏览器架构虽能提供更多控制权,但相比解析 HTML,它会消耗更多内存、增加冷启动工作量,并产生更多故障模式。

对于 Lambda 无法处理的工作负载,请选择 Fargate、Batch 或 EC2

当工作单元无法限定时,请使用生命周期更长的计算服务。对于需要更长运行时间且无需管理主机的容器化工作进程而言,Fargate 是切实可行的下一步选择。 当任务需要排队处理、计算密集且天然适合以批处理方式提交时,Batch 是更优选择。EC2 为持久化浏览器、专用网络、本地缓存或持续运行的爬虫提供了最大的控制权,但同时也要求您的团队负责主机补丁更新和容量管理。

这一决策首先是定性的,其次才是财务层面的。如果您仅仅为了规避函数限制而被迫每隔几分钟就设置检查点,或者反复下载庞大的浏览器栈,那么容器服务在运维上可能更简单、更经济。Lambda 应该减少基础设施工作量,而不是将其转移到复杂的恢复代码中。

采用将控制流与数据流分离的生产架构

当生产环境中的爬虫将关注点分离后,其运维将更加轻松。将触发器视为控制流,将 Lambda 函数视为可替换的工作进程,将 S3 或数据库视为持久化数据流,并将 CloudWatch 加上密钥存储视为运维层。

典型的 AWS Lambda 网络爬取路径如下:

EventBridge schedule or URL producer
                 |
           SQS or Step Functions
                 |
        Lambda scraper workers
          |       |        |
         S3   CloudWatch   SSM/Secrets Manager

这种分离可以避免常见的耦合问题。调度程序不应包含解析逻辑;工作进程不应独占其处理结果的唯一副本;解析器不应了解密钥的加密方式;监控系统应接收每次运行的结构化事实,而非从非结构化的堆栈跟踪中重建这些信息。

为每种语言和每个触发器定义一个事件契约。一个简洁的契约就足够了:

{
  "url": "https://example.com/catalog",
  "limit": 20,
  "request_id": "job-2026-03-001"
}

返回或存储一个匹配的结果封装,其中包含 ok, request_id, url, count, items,以及 s3_uri。这样,Python 和 Java 即可在运行层面进行比较,且 SQS 重试机制也不依赖于特定语言的事件格式。

选择 EventBridge、SQS 或 Step Functions 作为触发器

当任务规模较小且具有周期性时,可在 AWS 上使用 EventBridge 进行定时网页抓取。一条规则可以每小时或每天调用一次函数,该函数可将生成的快照写入 S3。通过缩短运行时间、添加幂等输出键,并在同时运行可能造成危害时应用预留并发机制,来防止任务重叠。

当生产者能够枚举 URL 或页面游标时,请使用 SQS。队列可缓冲突发流量,Lambda 函数处理小批量数据,且失败的 URL 可重试,而无需重启已成功处理的工作。这是多 URL 收集的默认架构,因为生产者的速度不再决定目标请求率。

当任务包含明确的各个阶段(例如获取列表页面、生成详情 URL、丰富记录以及发布清单)时,请使用 Step Functions。它提供了显式分支和重试策略,但状态转换会增加成本,并需要管理另一项服务。

触发器

最适合

主要控制

EventBridge

周期性快照

计划任务和预留并发量

SQS

多个独立的 URL

批处理大小、事件源并发度、DLQ

Step Functions

多阶段工作流

状态级重试和分支

API Gateway

按需请求

身份验证、有效载荷和超时限制

S3 事件

处理上传的种子

对象键约定与重复项处理

将输出、密钥、日志和指标路由到专用服务

对于原始 HTML、JSON 记录和爬取清单而言,S3 是一个合理的默认选择,因为每次调用都可以写入一个持久化对象并返回一个简短的 URI。请使用确定性键,例如 scrapes/site/date/request-id.json;如果同一消息被重试,它应覆盖同一对象或检测到结果已存在。

将 API 密钥、代理密码和会话凭据保存在 SSM 参数存储或 Secrets Manager 中。仅在 Lambda 环境中放置参数名称或密钥 ARN。授予执行角色读取该特定资源的权限,而非账户中的所有密钥。

CloudWatch 会接收函数日志、指标和警报。请记录关联 ID、规范化主机、状态码、耗时、重试次数、项目数量和输出键。 避免记录完整的查询字符串、授权头、Cookie、页面正文和密钥值。X-Ray 或同类追踪工具可帮助区分 Lambda 内部耗时与 DNS、目标、代理、S3 或密钥存储的延迟。

这种服务分离还提供了自然的扩展点。您可以在不更改提取代码的情况下,为 S3 添加生命周期规则、在写入成功后执行归档任务,或为死信队列添加重放工具。

根据职责而非目标站点为函数和资源命名。当 URL 生成与页面检索的扩展方式不同时,请将其分离;在多个生产者依赖事件模式之前,请对事件模式进行版本控制。这可防止解析器部署意外更改调度或队列行为。

在编写代码前,请围绕 Lambda 配额进行设计

使用 AWS Lambda 进行网页抓取存在严格的平台限制,其中部分限制与时间密切相关。根据提供的资料所示,常被引用的数值大致如下。在发布或部署前,请务必通过官方 AWS Lambda 配额页面确认您所在区域和账户的具体数值。

限制

报告值

抓取影响

最大调用次数

约 900 秒

将长分页拆分为队列单元

内存

约128 MB至10,240 MB

内存越大,可用 CPU 资源也会随之变化

临时 /tmp

大约 512 MB 至 10,240 MB

浏览器文件和下载内容需要明确指定大小

ZIP压缩包

压缩后约50 MB,解压后250 MB

原生库可能会强制使用图层或图像

容器镜像

未压缩约 10 GB

打包更简单,但构建速度较慢且拉取体积较大

同步有效载荷

每次传输约 6 MB

将结果存储在 S3 中并返回引用

区域级并发

通常默认值为 1,000

新账户或受限账户的并发数可能较低

请勿将 HTTP 超时设置为与函数超时相同。应预留足够的时间用于解析、S3 写入、指标收集以及受控的错误响应。 例如,对于一个 60 秒的函数,将请求超时设置为 35 到 45 秒比 60 秒更安全,尽管具体数值取决于目标系统。

并发数也是出站流量控制的一种手段。请为爬虫设置预留并发数,并在支持的情况下为 SQS 设置事件源的最大并发数。服务配额并不意味着可以向网站发送如此多的并发请求。

使用 AWS SAM 配置存储库和基础设施

AWS SAM 在 CloudFormation 模板中定义函数、IAM 权限、调度、队列和存储桶。它为《使用 AWS Lambda 进行网页抓取》提供了可重复的本地和部署工作流,且对于轻量级的静态 HTML 函数而言,无需使用 Docker。

一个紧凑的代码库可同时支持多种语言和打包路径:

lambda-scraper/
├── template.yaml
├── events/
│   ├── scrape.json
│   └── sqs.json
├── python/
│   ├── app.py
│   └── requirements.txt
├── java/
│   └── pom.xml
├── container/
│   ├── Dockerfile
│   ├── lambda_function.py
│   └── spider.py
└── tests/
    └── fixtures/

如果您计划使用 sam local 或容器镜像,请安装受支持的 Python 版本、AWS CLI、AWS SAM CLI 以及 Docker。部署前请配置命名 AWS 配置文件并验证账户:

aws configure --profile scraper-dev
aws sts get-caller-identity --profile scraper-dev
sam validate --lint

请将测试事件纳入源代码控制,但切勿包含生产环境中的 Cookie、代理凭证或签名 URL。根据环境将输出存储桶和密钥标识符参数化。可重复的 SAM 工作流是 validate, build, local invoke, deploy,以及带日志检查的云调用。

选择 ZIP 打包、分层或容器镜像

对于 requestsBeautifulSoup、Jsoup 以及类似的小型依赖关系图。SAM 可将依赖项安装到构建工件中,且冷启动的开销相对较小。

当多个函数共享一组稳定的依赖项时,层(Layers)非常有用,但它们会增加版本协调的工作量。它们并不能消除包数量的限制,而且一个随每次部署而变化的层,通常只是另一个需要管理的构建产物。

当您需要原生系统包、难以打包为 ZIP 的 Scrapy 依赖项或无头浏览器时,请选择 AWS Lambda 容器镜像。镜像可提高环境的一致性,而非运行时的适用性。

打包

最佳适用场景

主要权衡

ZIP

静态 HTML、小型解析器

最严格的依赖限制

分层加 ZIP

共享稳定库

跨函数版本耦合

容器镜像

原生库、Scrapy、Playwright

构建、扫描、存储及冷启动开销

不要仅仅因为 Docker 更接近生产环境就默认使用它。对于简单的 HTML,体积最小的构建产物通常更容易打补丁、测试和观察。

将环境差异保留在 SAM 参数或配置文件中,而非手动编辑的模板中。部署应能基于提交、配置文件和参数集进行可重复操作,且堆栈输出应包含烟雾测试所需的函数名称、存储桶名称、队列 URL 及别名。

构建 Python 静态 HTML 抓取工具

对于静态 HTML,一个 Python AWS Lambda 网页抓取工具仅需一个 HTTP 客户端、一个 HTML 解析器以及一个用于持久化输出的 AWS SDK 客户端。BeautifulSoup 仅解析收到的响应;它不会执行 JavaScript,也不会等待客户端应用程序的响应。

下面的处理程序支持直接调用、API Gateway JSON 请求体或 EventBridge detail 对象。它会验证 URL、在预热调用中复用客户端、设置显式超时、解析可替换的 CSS 选择器、将结果写入 S3,并返回结构化的错误信息。

import json
import os
from datetime import datetime, timezone
from urllib.parse import urljoin, urlparse

import boto3
import requests
from bs4 import BeautifulSoup

SESSION = requests.Session()
SESSION.headers.update({"User-Agent": "catalog-monitor/1.0 (+ops@example.com)"})
S3 = boto3.client("s3")
BUCKET = os.environ["OUTPUT_BUCKET"]
REQUEST_TIMEOUT = (5, 35)

def normalize_event(event):
    payload = event or {}
    if isinstance(payload.get("body"), str):
        payload = json.loads(payload["body"] or "{}")
    elif isinstance(payload.get("body"), dict):
        payload = payload["body"]
    elif isinstance(payload.get("detail"), dict):
        payload = payload["detail"]

    url = str(payload.get("url", "")).strip()
    parsed = urlparse(url)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise ValueError("url must be an absolute HTTP or HTTPS URL")

    try:
        limit = max(1, min(int(payload.get("limit", 20)), 100))
    except (TypeError, ValueError):
        raise ValueError("limit must be an integer")

    return {
        "url": url,
        "limit": limit,
        "request_id": str(payload.get("request_id", "")).strip(),
    }

def parse_items(html, base_url, limit):
    soup = BeautifulSoup(html, "html.parser")
    items = []
    for card in soup.select(".product")[:limit]:
        link = card.select_one("a")
        items.append({
            "title": card.select_one(".title").get_text(" ", strip=True)
                     if card.select_one(".title") else None,
            "price": card.select_one(".price").get_text(" ", strip=True)
                     if card.select_one(".price") else None,
            "availability": card.select_one(".availability").get_text(" ", strip=True)
                     if card.select_one(".availability") else None,
            "url": urljoin(base_url, link.get("href")) if link else None,
        })
    return items

def handler(event, context):
    aws_id = getattr(context, "aws_request_id", "local")
    try:
        job = normalize_event(event)
        request_id = job["request_id"] or aws_id

        response = SESSION.get(job["url"], timeout=REQUEST_TIMEOUT)
        response.raise_for_status()
        if not response.encoding or response.encoding.lower() == "iso-8859-1":
            response.encoding = response.apparent_encoding

        items = parse_items(response.text, job["url"], job["limit"])
        result = {
            "ok": True,
            "request_id": request_id,
            "url": job["url"],
            "count": len(items),
            "items": items,
            "fetched_at": datetime.now(timezone.utc).isoformat(),
        }

        key = f"scrapes/{request_id}.json"
        S3.put_object(
            Bucket=BUCKET,
            Key=key,
            Body=json.dumps(result).encode("utf-8"),
            ContentType="application/json",
        )
        result["s3_uri"] = f"s3://{BUCKET}/{key}"
        return result

    except ValueError as exc:
        return {"ok": False, "error": {"type": "validation", "message": str(exc)}}
    except requests.RequestException as exc:
        status = getattr(exc.response, "status_code", None)
        return {"ok": False, "error": {"type": "request", "status": status}}
    except Exception:
        return {"ok": False, "error": {"type": "internal"}}

请将选择器替换为 fixture 测试中涵盖的选择器。如果返回零项属于异常情况,应将其视为解析失败,而非成功的空抓取。此外,还需决定是否允许重定向,以及用户提供的 URL 是否需要白名单,以降低服务器端请求伪造的风险。

本地测试、部署、调用,并将 JSON 写入 S3

使用一个简短的依赖文件:

requests
beautifulsoup4

下面的 SAM 资源将创建一个输出存储桶,并仅允许在 scraper 前缀下进行对象写入。验证通过后,在实际仓库中锁定支持的运行时和依赖项版本。

Resources:
  OutputBucket:
    Type: AWS::S3::Bucket

  PythonScraper:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: python/
      Handler: app.handler
      Runtime: python3.12
      Architectures: [x86_64]
      MemorySize: 512
      Timeout: 60
      Environment:
        Variables:
          OUTPUT_BUCKET: !Ref OutputBucket
      Policies:
        - Statement:
            - Effect: Allow
              Action: s3:PutObject
              Resource: !Sub "${OutputBucket.Arn}/scrapes/*"

创建 events/scrape.json:

{
  "url": "https://example.com/catalog",
  "limit": 10,
  "request_id": "local-001"
}

然后运行一个可重复的序列:

sam validate --lint
sam build
sam local invoke PythonScraper -e events/scrape.json
sam deploy --guided
aws lambda invoke \
  --function-name YOUR_STACK_FUNCTION \
  --payload fileb://events/scrape.json response.json

验证 S3 中的对象,并在 CloudWatch 中检查函数日志流。本地成功仅证明解析成功,并不代表云端权限、DNS 行为、目标访问或超时余量均正常。云端验证应确认确切的部署角色、环境变量、构建产物架构、输出密钥、状态码、执行时长及项目数量。

投入生产前,请添加最大响应大小策略,并拒绝您不打算解析的内容类型。这可保护内存,并避免将剩余的调用配额浪费在大型下载上。仅在诊断需要时保留原始 HTML 样本,并实施保留期限和访问控制。

将 Scrapy 或 Playwright 打包到 Lambda 容器中

当依赖关系图、原生库或浏览器运行时无法再轻松地打包到 ZIP 文件中时,使用容器是合理的。但这并非绕过 Lambda 运行时模型的手段。该进程仍然具有有限的调用时长、临时本地存储以及可丢弃的执行环境。

对于 Scrapy AWS Lambda 工作进程,应将每个爬虫运行隔离在子进程中。Twisted 的反应器并非设计用于在同一个 Python 进程中反复停止和重启,这可能会导致 Lambda 暖启动调用出现意外情况。子进程虽然会增加启动开销,但能为每次爬取提供一个干净的反应器和明确的超时边界。

一个面向 Lambda 的精简镜像可能如下所示:

FROM public.ecr.aws/lambda/python:3.12

COPY requirements.txt ${LAMBDA_TASK_ROOT}/
RUN pip install --no-cache-dir -r requirements.txt

COPY lambda_function.py spider.py ${LAMBDA_TASK_ROOT}/
CMD ["lambda_function.handler"]

在部署构建中固定并哈希化依赖项。此处展示的基础镜像和运行时环境必须在部署时与受支持的 Lambda 基础镜像进行核对。

一个紧凑的处理程序可以运行现有的爬虫,将 JSON 数据收集到 /tmp,并上传:

import json
import os
import subprocess
import uuid

import boto3

S3 = boto3.client("s3")
BUCKET = os.environ["OUTPUT_BUCKET"]

def handler(event, context):
    url = event["url"]
    request_id = event.get("request_id") or str(uuid.uuid4())
    output = f"/tmp/{request_id}.json"

    subprocess.run(
        [
            "scrapy", "runspider", "spider.py",
            "-a", f"start_url={url}",
            "-O", output,
        ],
        check=True,
        timeout=720,
    )

    key = f"scrapes/{request_id}.json"
    S3.upload_file(output, BUCKET, key)
    with open(output, encoding="utf-8") as handle:
        count = len(json.load(handle))

    return {
        "ok": True,
        "request_id": request_id,
        "url": url,
        "count": count,
        "s3_uri": f"s3://{BUCKET}/{key}",
    }

该爬虫应支持 start_url,使用显式的下载超时设置,限制分页数量,并生成符合 Python 和 Java 输出模式的记录。Scrapy 也可以将数据流直接写入 S3,但显式的上传操作能更清晰地展示输出契约和错误边界。

Playwright AWS Lambda 的打包遵循相同的镜像原则,但体积更大。Chromium、字体、共享库和浏览器缓存都必须与镜像架构相匹配。需预留更多内存并考虑冷启动时间,将下载内容仅写入 /tmp,在 finally中关闭上下文,且切勿假设浏览器配置文件能在调用结束后保留。只有当数据需要浏览器渲染时,Scrapy-Playwright 配置才适用。

使用 SAM 构建、推送到 ECR 并部署镜像

构建一个与函数匹配的架构。以下 x86 示例使用了带版本号的标签;请替换为当前受支持的基础镜像和区域。

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=us-east-1
REPO=lambda-scraper
TAG=2026-03-01

aws ecr create-repository --repository-name "$REPO" 2>/dev/null || true
aws ecr get-login-password --region "$REGION" |
  docker login --username AWS --password-stdin \
  "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com"

docker buildx build \
  --platform linux/amd64 \
  -t "$REPO:$TAG" \
  --load container/

docker tag "$REPO:$TAG" \
  "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO:$TAG"
docker push "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO:$TAG"

将带版本号的镜像 URI 传递给 SAM,而不是部署一个含义模糊的 latest 引用:

Parameters:
  ScraperImageUri:
    Type: String

Resources:
  ContainerScraper:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      ImageUri: !Ref ScraperImageUri
      Architectures: [x86_64]
      MemorySize: 2048
      EphemeralStorage:
        Size: 2048
      Timeout: 840

使用精确的标签进行部署:

sam deploy \
  --parameter-overrides \
  ScraperImageUri="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO:$TAG"

为了提高可重现性,请在部署元数据中记录由 ECR 生成的镜像摘要。仅在验证了当前 ECR 选项和您所在组织的政策后,才启用存储库扫描和保留控制。清理旧的未被引用的镜像、本地层、测试堆栈和 S3 测试对象,以免容器实验造成长期成本。

使用 Lambda 的运行时端点在本地测试镜像,或 sam local invoke,随后进行云端烟雾测试。本地 Docker 架构、Lambda 架构、文件系统权限以及浏览器系统库是导致“在我机器上能运行”类故障的常见原因。

通过在 CI 中记录基础镜像摘要、依赖项锁定、目标架构以及生成的镜像摘要,确保容器构建的可预测性。应定期重建以应用安全更新,但在不同环境之间应推广经过测试的摘要,而非为开发和生产环境分别重建不同的镜像。

使用 HttpClient 和 Jsoup 构建 Java 21 爬虫

Java AWS Lambda 网页抓取工具应采用与 Python 版本相同的事件和结果契约。 HttpClient 负责处理有限范围的 HTTP 请求,Jsoup 解析 HTML,Jackson 对 JSON 事件正文进行规范化处理,而 AWS SDK 负责写入持久化结果。

package example;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.*;

public final class Handler
    implements RequestHandler<Map<String, Object>, Map<String, Object>> {

  private static final ObjectMapper JSON = new ObjectMapper();
  private static final HttpClient HTTP = HttpClient.newBuilder()
      .connectTimeout(Duration.ofSeconds(5))
      .followRedirects(HttpClient.Redirect.NORMAL)
      .build();
  private static final S3Client S3 = S3Client.create();
  private static final String BUCKET = System.getenv("OUTPUT_BUCKET");

  @Override
  public Map<String, Object> handleRequest(
      Map<String, Object> event, Context context) {
    try {
      Map<String, Object> input = normalize(event);
      String url = Objects.toString(input.get("url"), "").trim();
      URI uri = URI.create(url);
      if (!Set.of("http", "https").contains(uri.getScheme())) {
        throw new IllegalArgumentException("url must use HTTP or HTTPS");
      }

      int limit = Math.max(1, Math.min(
          Integer.parseInt(Objects.toString(input.getOrDefault("limit", 20))), 100));
      String requestId = Objects.toString(
          input.getOrDefault("request_id", context.getAwsRequestId()));

      HttpRequest request = HttpRequest.newBuilder(uri)
          .timeout(Duration.ofSeconds(35))
          .header("User-Agent", "catalog-monitor/1.0 (+ops@example.com)")
          .GET()
          .build();

      HttpResponse<String> response = HTTP.send(
          request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() < 200 || response.statusCode() >= 300) {
        return error("request", requestId, response.statusCode());
      }

      Document doc = Jsoup.parse(response.body(), url);
      List<Map<String, String>> items = new ArrayList<>();
      for (Element card : doc.select(".product")) {
        if (items.size() >= limit) break;
        Element link = card.selectFirst("a");
        Map<String, String> item = new LinkedHashMap<>();
        item.put("title", text(card, ".title"));
        item.put("price", text(card, ".price"));
        item.put("availability", text(card, ".availability"));
        item.put("url", link == null ? null : link.absUrl("href"));
        items.add(item);
      }

      Map<String, Object> result = new LinkedHashMap<>();
      result.put("ok", true);
      result.put("request_id", requestId);
      result.put("url", url);
      result.put("count", items.size());
      result.put("items", items);
      result.put("fetched_at", Instant.now().toString());

      String key = "scrapes/" + requestId + ".json";
      S3.putObject(
          PutObjectRequest.builder().bucket(BUCKET).key(key)
              .contentType("application/json").build(),
          RequestBody.fromString(JSON.writeValueAsString(result)));
      result.put("s3_uri", "s3://" + BUCKET + "/" + key);
      return result;

    } catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      return error("interrupted", context.getAwsRequestId(), null);
    } catch (Exception ex) {
      return error("internal", context.getAwsRequestId(), null);
    }
  }

  private static String text(Element root, String selector) {
    Element node = root.selectFirst(selector);
    return node == null ? null : node.text();
  }

  private static Map<String, Object> normalize(Map<String, Object> event)
      throws Exception {
    Object body = event.get("body");
    if (body instanceof String text && !text.isBlank()) {
      return JSON.readValue(text, new TypeReference<>() {});
    }
    if (body instanceof Map<?, ?> map) return (Map<String, Object>) map;
    Object detail = event.get("detail");
    if (detail instanceof Map<?, ?> map) return (Map<String, Object>) map;
    return event;
  }

  private static Map<String, Object> error(
      String type, String requestId, Integer status) {
    Map<String, Object> details = new LinkedHashMap<>();
    details.put("type", type);
    if (status != null) details.put("status", status);
    return Map.of("ok", false, "request_id", requestId, "error", details);
  }
}

在预热调用期间复用静态客户端,但不要将对正确性至关重要的爬取状态存储在静态字段中。与 Python 版本相同的注意事项同样适用:对日志进行净化处理,将意外的零项解析视为失败,并将 HTTP 超时设置在函数超时阈值之下。 当需要对选择器或绝对链接的行为进行更深入处理时,Java 中使用 Jsoup 进行 HTML 解析的参考资料会很有帮助。

使用 Maven 打包,并通过 SnapStart 缩短启动时间

Maven 项目需要 Lambda 核心接口、Jsoup、Jackson、S3 SDK,以及一个生成单个部署 JAR 的打包步骤。在检查当前发布版本和您的依赖策略后,锁定版本。

<dependencies>
  <dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-lambda-java-core</artifactId>
    <version>${lambda.core.version}</version>
  </dependency>
  <dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>${jsoup.version}</version>
  </dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>${jackson.version}</version>
  </dependency>
  <dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
    <version>${aws.sdk.version}</version>
  </dependency>
</dependencies>

配置 maven-shade-plugin 期间 package,随后将 SAM 指向打包后的 JAR。预期的 SnapStart 配置应使用已发布的版本或别名,而非 $LATEST:

JavaScraper:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: java/target/scraper.jar
    Handler: example.Handler::handleRequest
    Runtime: java21
    MemorySize: 1024
    Timeout: 60
    AutoPublishAlias: live
    SnapStart:
      ApplyOn: PublishedVersions

构建并调用 mvn clean package, sam build, sam local invoke JavaScraper -e events/scrape.json,并 sam deploy。在撰写本文时,请验证部署区域是否支持 Java 21 和 SnapStart,并确认快照恢复后在初始化、网络连接、熵值以及缓存凭据方面的任何限制。

谨慎处理 JavaScript、代理和反机器人防御机制

使用 AWS Lambda 进行 Web 抓取不会改变目标站点渲染内容或评估流量的方式。请从最简单的授权请求路径开始,观察失败情况,并仅在诊断出具体原因后才升级处理。

  1. 直接 HTTP:当响应中包含数据时使用此方法。添加真实的用户代理,针对临时故障设置有限重试次数,采用合理的速率控制,并进行解析器测试。
  2. 底层数据请求:如果页面从 JSON 端点加载公开数据,请仅在该端点的条款和授权允许的情况下使用该端点。不要假设未记录的端点是稳定的。
  3. 无头浏览器:当工作流确实需要执行 JavaScript、点击、滚动或表单提交时,请使用 Playwright。浏览器渲染并不能保证访问成功。
  4. 显式代理:当地理位置、稳定的出站连接或 IP 轮换是合理需求时,请使用代理。代理声誉、会话亲和性及目标策略仍然至关重要。
  5. 托管抓取服务:当浏览器、代理、验证码和重试操作的维护成本高于数据提取逻辑时,应将数据检索工作外包。

某些网站可能会识别或屏蔽云服务商的地址范围。这取决于具体情境,转用代理或浏览器也无法保证成功。如果真正的问题在于请求速率、授权、账户状态或标记变化,那么请求头和隐身插件也可能带来虚假的可靠性感。

一套实用的反机器人网络爬虫策略会将结果分类:传输失败、超时、HTTP 阻塞、验证页面、登录要求、渲染结果为空以及解析器不匹配。每类情况都有不同的解决方法。对所有情况采用相同的重试方式不仅浪费资金,还会增加目标网站的压力。

比较显式代理与托管式抓取 API

显式代理将请求构建和解析保留在您的代码中。请从密钥存储中加载其凭据,而非从事件或存储库中获取:

import boto3
import os
import requests

ssm = boto3.client("ssm")
proxy_url = ssm.get_parameter(
    Name=os.environ["PROXY_PARAMETER"],
    WithDecryption=True,
)["Parameter"]["Value"]

response = requests.get(
    event["url"],
    proxies={"http": proxy_url, "https": proxy_url},
    timeout=(5, 35),
)
response.raise_for_status()

托管式爬取 API 可根据服务商文档中记载的能力,将原始 HTML 获取、代理轮换、验证码处理或浏览器渲染等操作移出 Lambda 函数。在验证当前身份验证信息和参数之前,请保持集成方案的通用性:

token = ssm.get_parameter(
    Name=os.environ["API_TOKEN_PARAMETER"],
    WithDecryption=True,
)["Parameter"]["Value"]

response = requests.get(
    os.environ["SCRAPING_API_URL"],
    params={"url": event["url"]},
    headers={"Authorization": f"Bearer {token}"},
    timeout=(5, 50),
)
response.raise_for_status()

选项

您控制

您负责维护

成本结构

直接请求

HTTP 及解析

重试、速率控制、IP路径

Lambda 及网络功能

显式代理

代理选择与会话

轮询、故障、凭据

代理流量与计算

浏览器容器

完全交互

浏览器镜像与稳定性

更高的内存和运行时长

托管API

请求选项与解析

供应商集成与备用方案

按请求、信用额度或结果计费

Python requests 代理使用指南》是实现身份验证、轮询和错误处理时的绝佳参考。在选择托管方案之前,请验证延迟、响应格式、最大正文大小、计费单位、区域路由、数据处理以及请求失败时的行为。

使用 SQS 安全地扩展多 URL 任务

基于队列的抓取会将一个独立的工作单元放入每条 SQS 消息中,通常包括一个 URL 以及请求 ID、解析器版本和可选的爬取元数据。Lambda 接收一个小批量消息,处理每条消息,并分别写入每个结果。这样,成功的 URL 保持完整,而失败的 URL 则会重试。

对于使用 AWS Lambda 进行 Web 抓取,消息粒度是速率控制的决策因素。每条消息包含一个 URL 可实现最纯粹的重试和幂等行为。将少量密切相关的 URL 组合在一起可以减少队列开销,但若其中一个 URL 处理缓慢,则会导致整条消息延迟。

请保持消息体积小巧。将大型种子列表或会话数据存储在 S3 中,仅将对象引用放入 SQS。包含足以重现请求的信息,但不要包含代理密码、Cookie 或 API 令牌。

在各层之间统一超时设置:

  • HTTP 超时必须小于函数超时。
  • 函数超时必须预留足够的时间用于输出和诊断。
  • SQS 的可见性超时必须超过批次可能处于进行中的时间,并包含适当的重试缓冲时间。
  • 消息保留和死信保留时间必须足够长,以便运维人员进行排查。
  • 预留并发量和事件源并发量必须体现对目标系统友好的请求速率,而不仅仅是账户容量。

AWS 设置和强制执行的最低值可能会发生变化,因此请通过官方的 Lambda 与 SQS 文档验证当前的集成行为。

实现重试、部分批处理失败、死信队列(DLQ)、幂等性和并发上限

启用部分批处理响应,以便 Lambda 仅返回失败的消息 ID。若未启用此行为,一条错误记录可能会导致批处理中的所有记录重新出现。

import json
import logging

log = logging.getLogger()
log.setLevel("INFO")

def sqs_handler(event, context):
    failures = []

    for record in event.get("Records", []):
        message_id = record["messageId"]
        try:
            job = json.loads(record["body"])
            scrape_one(job)  # Writes deterministic S3 key
        except Exception as exc:
            log.exception("scrape_failed", extra={"message_id": message_id})
            failures.append({"itemIdentifier": message_id})

    return {"batchItemFailures": failures}

在 SAM 中,需在事件源上声明响应模式:

Events:
  UrlQueue:
    Type: SQS
    Properties:
      Queue: !GetAtt ScrapeQueue.Arn
      BatchSize: 5
      FunctionResponseTypes:
        - ReportBatchItemFailures

确保 scrape_one 幂等。使用确定性键(例如 scrapes/{job_id}.json、DynamoDB 条件写入或现有结果检查,可防止重复消息产生重复的下游影响。

通过 SQS 重试策略附加一个死信队列,并根据您实际期望的重试行为选择其接收阈值。 将耗尽的消息发送到该队列以供检查和选择性重放。在可行的情况下,同时对函数和事件源设置并发上限。这些控制措施可保护网站安全,为其他函数预留容量,并防止积压演变为意外的流量激增。

背压从生产者端开始。如果队列滞留时间增加,应暂停或减缓 URL 发现,而不是允许无限量的积压。当各个目标或主机需要不同的并发、速率控制、凭据或重试策略时,请分别对其进行跟踪。

添加可观测性和故障诊断

AWS Lambda 抓取程序应在每次尝试后输出一个结构化摘要。使用无需解析自然语言即可查询的 JSON 字段:

{
  "event": "scrape_complete",
  "request_id": "job-2026-03-001",
  "host": "example.com",
  "status": 200,
  "duration_ms": 842,
  "retry_count": 0,
  "item_count": 20,
  "output_key": "scrapes/job-2026-03-001.json"
}

规范化主机名,并省略查询字符串(除非已知其安全无虞)。切勿记录授权头、Cookie、包含凭据的代理 URL、完整的 HTML 正文或机密值。

至少跟踪以下指标类别:

  • 工作:尝试次数、成功次数、提取的项目数、存储的字节数。
  • 请求健康状况:延迟、超时、连接错误、HTTP 状态码类别。
  • 阻塞信号:挑战页检测、访问拒绝、登录重定向。
  • 解析器健康状况:结果为零项、缺少必填字段、选择器失败。
  • AWS 健康状况:错误、限流、持续时间、内存、SQS 消息年龄和 DLQ 深度。

针对速率和持续趋势设置告警,而非针对每个单独的故障。解析器告警应与阻塞告警区分开来,因为相应的运行手册和负责人可能不同。有针对性地设置日志保留策略,将关联 ID 附加到队列消息和 S3 键上,并在目标延迟与 AWS 服务调用存在实质性差异时使用追踪功能。

CloudWatch 会接收函数日志组下的标准 Lambda 日志。X-Ray 可帮助可视化下游延迟,但追踪每个高流量请求可能会增加成本和噪声。应有针对性地进行采样,并保留足够的失败示例(去除敏感内容),以便重现事件。

围绕决策构建仪表盘:是减缓流量、回滚解析器、轮换凭证、增加内存,还是重放故障。如果一个图表无法改变运维人员的下一步行动,那它很可能只是噪音。

通过 CI/CD 测试并发布变更

将解析器视为确定性代码,将网络视为可替换的边界。存储具有代表性的 HTML 测试数据,涵盖正常页面、空白页面、已更改的标记、验证页面以及格式错误的响应。单元测试应直接调用解析函数,并验证必填字段、绝对 URL 以及失败行为。

在处理程序测试中模拟 HTTP 响应、S3 写入、SSM 读取以及超时情况。添加合同测试,使其运行与 url, limitrequest_id 事件,并比较结果封装,而非特定于语言的内部实现。

一个实用的管道运行流程如下:

  1. 格式化器、代码检查器、类型或编译检查,以及单元测试。
  2. sam validate --lint 此外还包括 CloudFormation 策略检查。
  3. 依赖项和容器漏洞检查。
  4. sam build 或针对特定架构的镜像构建。
  5. 使用测试环境服务器进行本地烟雾测试。
  6. 部署到非生产环境堆栈以及一个受控的在线金丝雀环境。
  7. 分阶段生产环境推广,并配备即时回滚路径。

不要让 CI 每次提交都依赖于不受控的公共网站。使用本地测试服务器以确保可重复性,并安排单独的集成测试来验证目标行为。验证部署角色无法扩大 scraper 角色的权限范围,机密信息绝不会出现在构建日志中,并且旧的容器镜像和测试存储桶已被清理。

计算总成本,而不仅仅是 Lambda 计算成本

AWS Lambda 抓取成本始于请求次数和持续时间,但这仅是计算成本的一部分。在应用区域价格之前,请先计算使用量:

GB-seconds = invocations × average duration in seconds × configured memory in GB
request units = total invocations, including retries

一个每天运行、占用 256 MB 内存 3 秒的函数,30 天内消耗约 22.5 GB-秒。对于 100 万个页面,一个静态解析器若占用 256 MB 内存 2 秒,则消耗 500,000 GB-秒。 一个占用 2 GB 内存、运行 10 秒的浏览器工作线程将消耗 20,000,000 GB-秒。这些使用量数据比直接给出的美元金额更具参考价值,因为费率、架构折扣、免费套餐资格以及区域可能存在差异。

提供的资料引用了每月约 100 万次请求和 40 万 GB-秒的免费配额,并根据其假设估算,静态示例成本约为 1.33 美元,浏览器示例成本约为 261.33 美元。请将这些美元数值视为未经核实的规划参考,而非 2026 年的报价。 请根据部署区域的官方 AWS Lambda 定价页面重新计算这些费用。

添加所有附加项目:

成本领域

典型成本驱动因素

S3

对象写入、存储、读取、生命周期

CloudWatch

日志采集、保留、指标、告警

ECR

镜像存储、扫描、跨区域传输

网络

数据传输及可能的 NAT 网关处理

SQS 或 Step Functions

请求、状态转换、重试

浏览器执行

更多内存、执行时长和图像大小

代理或托管 API

流量、请求、积分或成功结果

如果将 Lambda 仅部署在 VPC 中以确保稳定的出站流量,NAT 可能会对小型工作负载造成显著影响。还需模拟重试和被阻塞的响应。即使这些操作未产生任何数据,它们仍会消耗计算资源和第三方流量。

生产环境上线检查清单:安全性、合规性及尊重用户权益的爬取

在发布《使用 AWS Lambda 进行 Web 爬取》之前,请同时从 AWS 工作负载和自动化数据采集器的双重角度对系统进行审查。

  • IAM:仅向每个函数授予其所需的 S3 前缀、队列、指标命名空间和密钥 ARN。将部署者权限与运行时权限分开。
  • 密钥:将代理、API 和登录凭据存储在 SSM 参数存储或 Secrets Manager 中。定期轮换密钥,并防止解密后的值进入日志。
  • 加密:根据您的数据分类和组织政策,对 S3、队列、日志以及敏感配置进行加密。
  • 输入控制:验证方案和主机。如果调用方提供 URL,请考虑采用白名单机制,并防范针对元数据、私有地址或链路本地地址的请求。
  • 流量控制:设置预留并发数、队列上限、请求超时、重试上限以及全局紧急关闭开关。通过配置标志阻止新请求比紧急部署代码更快捷。
  • 数据最小化:仅收集实现既定目的所需的字段,明确数据保留期限,并限制对可能包含个人或敏感数据的原始 HTML 的访问权限。
  • 目标审查:在数据采集前,需核查 robots.txt 文件、服务条款、授权机制、速率限制指南、账户规则、隐私义务及适用法律。这些要素具有不同的法律含义,因此对于重大风险,应建立网络爬虫合规框架并寻求合格法律顾问的协助。
  • 运营责任:指定警报机制、DLQ重放流程、解析器变更运行手册,并设立目标方投诉联络人。
  • 发布安全:从低并发量开始,观察状态和解析器指标,然后有计划地提高吞吐量。

本文为工程指导,而非法律建议。数据采集是否合法取决于数据本身、管辖权、访问方式、合同关系及预期用途。切勿将技术上的可访问性视为授权。

首先部署什么

从最简便的实用系统开始:使用 Python 直接处理 HTTP 请求,由 SAM 打包,由一个测试事件触发,并将 JSON 写入 S3。该基准系统以最少的组件验证了权限、网络、解析、存储和可观测性。

添加 EventBridge 以实现小型定时任务。 当 URL 成为独立的工作项时,添加 SQS。仅在依赖关系确实需要时才迁移到容器,且仅当数据需要渲染或交互时才使用浏览器。只有在测定故障模式后,才升级到代理或托管检索。这一顺序可确保《使用 AWS Lambda 进行 Web 抓取》在规模扩展时仍易于理解。

关键要点

  • 当每次抓取操作时间短、范围有限且可独立重试时,应使用 Lambda;对于持久会话或长达数小时的爬取任务,则应选择生命周期更长的计算资源。
  • 在 Python、Java、计划任务、队列和本地测试中保持统一的事件和结果契约。
  • 将结果存储在 S3 中,凭据存储在托管密钥库中,运营数据则存储在结构化日志和指标中。
  • 在增加 URL 数量之前,先添加 SQS 部分失败处理、死信队列(DLQ)、幂等性及并发限制。
  • 将计算、存储、日志、图像、网络、重试、代理和托管服务分别作为独立的成本项目进行核算。

常见问题

AWS Lambda 爬虫是否必须在 VPC 内运行?

不需要。Lambda 函数无需连接到您的 VPC 即可访问公共网站。仅当需要访问私有资源、使用受控网络检查,或通过具有固定公共 IP 的 NAT 网关发送流量时,才应使用 VPC。连接 VPC 会增加网络配置,并可能产生 NAT 成本,因此应仅用于满足特定需求。

当代理或目标需要白名单时,Lambda 如何使用稳定的出站 IP?

将连接到 VPC 的 Lambda 函数通过私有子网和关联弹性 IP 的 NAT 网关进行路由,或者使用具有稳定地址的代理端点。如果可用性很重要,请配置冗余网络。NAT 网关会产生按小时计费和数据处理费用,而代理则会带来自身的流量和身份验证问题。

在内存中无法可靠地持久化。虽然可以复用“温热”的执行环境,但 AWS 不保证下一次调用会进入相同的环境。请将加密的 Cookie 存储或会话状态持久化到适当的持久化存储中,应用过期和访问控制,并设计支持并发更新。请确认自动登录和凭证使用已获得授权。

超过 Lambda 同步有效载荷限制的抓取结果应如何存储?

将正文写入 S3,并返回一个小型对象键、URI、校验和及元数据封装。对于下游处理,应通过 SQS、EventBridge 或数据库记录发布该引用,而非传递完整结果。在适当情况下使用压缩和多部分上传,并通过有效期和权限限制任何预签名 URL。

当爬取操作可能超过 Lambda 的最大运行时间时,应如何划分分页?

将每个页码、光标或续传令牌作为独立的队列任务进行处理。 将发现的下一页任务存储在 SQS 中,并持久化爬取 ID 及检查点,以确保重试操作保持幂等性。限制分页发现次数,检测重复光标,并且仅当显式工作流状态比简单的生产者-队列模式更有价值时,才使用 Step Functions。

结论

生产环境中的无服务器爬虫主要是一项边界管理练习。保持每次调用简短,确保事件自包含,持久化输出,并假设任何消息都可能被多次传递。对于静态页面,直接使用 HTTP 配合解析器是正确的默认方案,而 SQS 则为受控的多 URL 扩展提供了最简洁的实现路径。

容器对于 Scrapy、原生包和浏览器依赖项很有用,但并不能消除 Lambda 的运行时和状态限制。Java 21 可以遵循与 Python 相同的规范,使用 HttpClient、Jsoup、S3 以及经过验证的 SnapStart 配置。 对于被封锁或 JavaScript 内容繁重的页面,在添加代理、浏览器或托管服务之前,请先诊断实际故障原因,切勿将这些选项视为能保证访问的方案。

最后,请计算完整的系统成本,并验证您所在区域内所有与时间相关的 AWS 参数。如果请求被阻塞成为主要的工程负担,WebScrapingAPI 可作为受管的原生 HTML 抓取层,负责处理代理轮换、阻塞和验证码(CAPTCHA),而您的 Lambda 函数则继续负责解析、验证和存储。 仅当此升级方案能降低总体运维复杂度时,才应采用该方案。

关于作者

Suciu Dan, 联合创始人 @ WebScrapingAPI

Suciu Dan

联合创始人

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

开始构建

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

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