从现代 Web 应用中抓取单个页面是一个问题,爬取整个网站则是另一个截然不同的问题,而且比大多数教程所承认的要难得多。当你着手爬取 JavaScript 网站(无论是基于 React、Vue、Angular,还是任何在浏览器中填充页面的框架所构建的网站)时,你会遇到两个相互叠加的障碍。每个页面只有在 JavaScript 运行后才会显示真实内容,而你通常用来发现更多页面的导航本身也是由 JavaScript 渲染的,因此普通的 HTTP 请求只会返回一个几乎空白、没有任何可跟踪链接的文档。

本指南将向你展示如何构建一个能够端到端遍历 JavaScript 渲染网站的爬虫。你将渲染每个页面使其链接和内容显现,用 BeautifulSoup 解析这些链接,维护一个前沿队列和已访问集合以确保遍历终止,并礼貌地限速以保持受欢迎。获取操作通过 Crawlbase Crawling API 的 JavaScript token 完成,它在可信 IP 后面渲染每个页面并返回已渲染的 HTML。对于大型任务,我们还会介绍异步 Crawler,这样你就不必在每次渲染时都阻塞等待。

为什么爬取 JS 网站是两个问题,而不是一个

传统爬虫是一个紧密的循环:获取 URL,提取锚点,将新的加入队列,重复执行。这个循环假设你获取的 HTML 已经包含内容和链接。在服务端渲染的网站上确实如此,但在客户端渲染的网站上则不然。

第一个问题是渲染。当你用裸 HTTP 客户端请求一个 React 或 Vue 路由时,服务器返回的是一个壳:一个根 <div>、一堆 script 标签,以及几乎什么都没有。文章文本、产品网格、你想要的表格,所有这些都是在浏览器下载并执行 JavaScript 之后才注入的。没有浏览器,就没有内容。

第二个问题是链接发现,而这正是悄悄破坏朴素爬虫的元凶。网站的导航、分页和"相关"链接通常也在客户端渲染。因此即使你只想要链接而不是内容,普通的获取仍然什么都给不了你。爬虫在第一个页面就死掉了,因为前沿队列永远无法增长超过它。要爬取一个 JavaScript 网站,你必须渲染每一个页面,不是因为你总是需要正文,而是因为渲染才是使链接存在的前提。

先渲染,再解析

让 JS 爬取得以运行的单一法则:在寻找链接之前先渲染每个页面。内容和导航在同一次渲染过程中出现,因此一旦你拿到了已渲染的 HTML,就可以用同一个解析器同时提取你想要的数据和接下来要跟踪的 URL。

你将构建什么

一个 Python 广度优先爬虫,从 JavaScript 渲染网站上的一个种子 URL 出发,向外扩展,保持在单个域名内。具体来说,它将:

  • 渲染每个页面通过带有 JS token 的 Crawling API,使内容和链接都能呈现。
  • 提取链接从渲染后的 HTML 中使用 BeautifulSoup 提取,并规范化为绝对的同域 URL。
  • 管理前沿队列记录待访问的 URL 和已访问集合,确保没有内容被获取两次且遍历终止。
  • 礼貌地限速在请求之间加入延迟,并限制访问的页面数量。

前置条件

在编写任何代码之前,需要准备好几样东西,都不需要太长时间。

基础 Python 知识。 你应该能够运行脚本并用 pip 安装包。如果队列和集合对你来说很熟悉,就可以开始了。

Python 3.8 或更高版本。python --version 确认。如果没有,请从 python.org 安装。

Crawlbase 账号和 JS token。 注册后打开控制台,从账号文档页面复制你的 JavaScript (JS) token。JS token 是在真实浏览器中渲染页面的那一个;普通 token 只获取静态 HTML,会返回与普通获取相同的空壳。请将 token 保存在版本控制系统之外。

搭建项目

创建虚拟环境以隔离依赖,然后安装爬虫所需的两个库。

bash
python --version

python -m venv crawler_env
source crawler_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,用 crawler_env\Scripts\activate 代替 source 那行来激活环境。crawlbase 包是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你可以同时提取锚点和内容。

第 1 步:渲染单个页面并确认链接出现

在构建循环之前,先证明最难的部分有效:渲染客户端页面确实能展现普通获取会错过的链接。用你的 JS token 初始化客户端,请求一个 URL,并要求 API 等待异步内容加载完成。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

def render(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

if __name__ == "__main__":
    html = render("https://example.com/")
    print(len(html) if html else "No HTML returned")

两个等待选项对客户端渲染目标至关重要。ajax_wait 告诉 API 等待异步内容加载完成,page_wait 在加载后固定等待若干毫秒,使延迟渲染的元素在抓取前能够出现。五秒是合理的起始值;如果页面链接仍然返回为空,可以适当提高。将这个渲染版本的 body 长度与同一 URL 的普通 requests.get 对比,通常会看到渲染版本大得多,因为导航和内容现在都已存在。

第 2 步:提取并规范化链接

拿到渲染后的 HTML 后,从中提取锚点,并将其转换为可以比较和加入队列的干净绝对 URL。两个细节保持爬取的理智:针对链接所在页面解析相对 href,以及去除 URL 片段,使 /page/page#section 不被视为两个不同的页面。

python
from urllib.parse import urljoin, urldefrag, urlparse
from bs4 import BeautifulSoup

def extract_links(html, base_url, domain):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href = urljoin(base_url, a["href"])
        href, _ = urldefrag(href)
        parsed = urlparse(href)
        if parsed.scheme in ("http", "https") and parsed.netloc == domain:
            links.add(href)
    return links

同域检查(parsed.netloc == domain)防止爬虫跑到外部网站上,这正是爬取单个网站与意外尝试爬取整个互联网的区别。返回 set 对单个页面上多次出现的链接进行去重。由于你是从渲染后的 HTML 中提取这些链接,它们包含了 JavaScript 渲染的那些链接,这正是普通获取的爬虫会错过的内容。

Crawlbase Crawling API

爬取 JS 网站意味着要在可信 IP 后面反复渲染每个页面,同时不被封锁。Crawling API 接受 JS token,在真实浏览器中运行每个页面,在服务端轮换住宅 IP,并返回已渲染的 HTML,使内容和链接都能呈现。你无需自己运行无头浏览器集群和代理池。从免费套餐开始,将其指向一个种子 URL。

第 3 步:管理前沿队列和已访问集合

现在是任何爬虫的核心:一个等待访问的 URL 前沿队列,以及一个已经见过的 URL 已访问集合。没有已访问集合,一个充满相互链接的真实网站将永远循环;没有页面上限,一个大型网站将一直运行直到你的额度耗尽。这两个防护措施属于你编写的每个爬虫的标配。

python
import time
from collections import deque

def crawl_site(seed_url, max_pages=50, delay=2.0):
    domain = urlparse(seed_url).netloc
    frontier = deque([seed_url])
    visited = set()
    pages = []

    while frontier and len(visited) < max_pages:
        url = frontier.popleft()
        if url in visited:
            continue
        visited.add(url)

        html = render(url)
        if not html:
            continue

        pages.append({"url": url, "html": html})
        print(f"[{len(visited)}] crawled {url}")

        for link in extract_links(html, url, domain):
            if link not in visited:
                frontier.append(link)

        time.sleep(delay)

    return pages

结合 popleft 使用的 deque 实现了广度优先遍历,使爬虫在整个网站上扇形展开,而不是沿着某一条分支深入。在弹出 URL 的那一刻(而非获取成功后)就将其标记为已访问,意味着渲染失败的页面仍然算作已见过,避免不稳定的 URL 将循环困住。max_pages 上限和请求之间的 delay 是你的两个礼貌调节杠杆;根据目标网站和你自己的额度进行调整。

遵守 robots.txt

在任何规模的爬取之前,请阅读目标网站的 robots.txt 并遵守其禁止规则和爬取延迟要求。Python 标准库的 urllib.robotparser 可以用几行代码根据规则检查 URL。礼貌地限速并远离被禁止的路径,是让爬虫保持受欢迎而不被封锁的关键。

第 4 步:整合在一起

将渲染器、链接提取器和前沿循环整合为一个可运行的脚本。这个版本还从每个渲染页面提取页面标题,让你可以看到真实内容返回,这是证明渲染在整个遍历过程中都有效的直接证据。

python
import json
import time
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

def render(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

def extract_links(html, base_url, domain):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href, _ = urldefrag(urljoin(base_url, a["href"]))
        parsed = urlparse(href)
        if parsed.scheme in ("http", "https") and parsed.netloc == domain:
            links.add(href)
    return links

def title_of(html):
    soup = BeautifulSoup(html, "html.parser")
    return soup.title.get_text(strip=True) if soup.title else None

def crawl_site(seed_url, max_pages=50, delay=2.0):
    domain = urlparse(seed_url).netloc
    frontier = deque([seed_url])
    visited = set()
    results = []

    while frontier and len(visited) < max_pages:
        url = frontier.popleft()
        if url in visited:
            continue
        visited.add(url)

        html = render(url)
        if not html:
            continue

        results.append({"url": url, "title": title_of(html)})
        print(f"[{len(visited)}] {url}")

        for link in extract_links(html, url, domain):
            if link not in visited:
                frontier.append(link)

        time.sleep(delay)

    return results

def main():
    pages = crawl_site("https://example.com/", max_pages=25)
    with open("crawl.json", "w") as f:
        json.dump(pages, f, indent=2)
    print(f"Crawled {len(pages)} pages")

if __name__ == "__main__":
    main()

运行 python crawler.py,你会看到前沿队列随着每个渲染页面贡献新链接而增长,然后随着页面上限被达到而缩减。输出是一个 JSON 文件,记录了爬虫访问的每个 URL 及其标题。将 title_of 替换为真正的提取函数,你就拥有了一个完整的内容爬虫。如果你想深入了解如何解析单个渲染页面的正文,可以参阅如何用 Python 抓取 JavaScript 页面

使用异步 Crawler 进行规模化

上面的同步循环非常适合几十到几百个页面,但它有一个结构性上限:它在每次渲染时都会阻塞。每个页面都要等待 API 完成完整的浏览器渲染才能启动下一个请求,因此在一千个页面上进行五秒渲染,意味着超过一个小时的实际等待时间,大部分时间都处于空闲状态。

对于较大的任务,请切换到异步 Crawler。你不再等待每个页面,而是将 URL 推送到 Crawler,Crawlbase 在自己的基础设施上渲染它们,并将已渲染的 HTML 传递到你控制的 webhook 回调。你的代码不再是渲染等待循环,而是变成两个解耦的部分:一个提交器,尽可能快地将你发现的 URL 送入;一个接收器,摄取已渲染的页面,提取链接,并将新的链接重新提交。你的爬取速度达到 Crawler 集群的吞吐量,而不是单次渲染的延迟。

你已经写好的爬取逻辑可以直接迁移。前沿队列、已访问集合、同域检查和链接提取都完全相同;唯一改变的是传输方式:从阻塞的 api.get 调用变为提交回调流程。完整的模式可参阅使用 Crawlbase Crawler 提取数据。如果你的技术栈在 JVM 而非 Python 上,同样的前沿队列和已访问集合设计也可以很好地映射到用 Java 构建网络爬虫

爬取 JS 网站的常见陷阱

一些失败模式反复出现。提前了解它们可以节省大量调试时间。

  • 链接集合为空。 如果 extract_links 在你知道有导航的页面上什么都没返回,页面可能还没有渲染完成。提高 page_wait,并保持 ajax_wait 开启,使延迟注入的锚点在解析前能够出现。
  • 无限前沿队列。 日历、多面筛选和会话 ID 查询字符串会生成无数个唯一 URL。规范化掉跟踪参数,并考虑跳过超过一定深度的 URL,使爬取确实能够结束。
  • 爬出目标网站。 没有同域守卫,一个外部链接就会把你的网站爬取变成一场失控。始终在 netloc 上进行过滤。
  • 轰炸服务器。 没有延迟意味着一波看起来像攻击的请求,并会因此遭到封锁。保持合理的 delay,并遵守 robots.txt 中的爬取延迟要求。

如果你宁愿通过轮换住宅代理池路由自己的无头浏览器流量,而不使用托管 API,Smart AI Proxy 提供与托管 API 相同的 IP 轮换,作为即插即用的代理端点,你自己处理渲染。

回顾

核心要点

  • 爬取 JS 网站是两个问题。 每个页面都需要渲染才能显示内容,而你要跟踪的链接也是 JavaScript 生成的,因此必须渲染每一个页面才能发现下一批。
  • 先渲染,再解析。 带有 JS token 以及 ajax_waitpage_wait 的 Crawling API 返回已渲染的 HTML,使内容和链接同时到达。
  • 前沿队列和已访问集合是必须的。 广度优先队列、已见 URL 集合、同域过滤器和页面上限,这四者共同确保遍历终止。
  • 保持礼貌。 在请求之间加入延迟,遵守 robots.txt,并规范化 URL,使爬虫不会在跟踪参数上循环。
  • 使用异步 Crawler 进行扩展。 对于大型任务,提交 URL 并通过回调接收已渲染的页面,使你能以集群吞吐量爬取,而不是在每次渲染时阻塞等待。

常见问题

为什么普通爬虫在 JavaScript 网站的第一个页面就停下来了?

因为导航链接是在客户端渲染的。裸 HTTP 获取返回的是带有脚本但没有任何框架运行后渲染锚点的壳,所以你的链接提取器找不到任何内容可以加入队列,前沿队列永远无法增长。先渲染每个页面才能使这些链接存在,这也是为什么爬取 JS 网站必须渲染,即使你只关心发现 URL 也是如此。

爬取 JavaScript 网站需要普通 token 还是 JS token?

需要 JS token。普通 token 获取静态 HTML,对于客户端渲染的网站来说,那就是没有内容和渲染链接的空壳。JS token 在返回 HTML 之前先在真实浏览器中运行页面,所以数据和导航都能呈现给你的解析器和前沿队列。

如何防止爬虫永远循环下去?

维护已访问集合,在每次获取之前检查它,并在从前沿弹出 URL 的那一刻就将其标记为已访问,而不是等到成功之后。添加 max_pages 上限和同域过滤器。这些措施共同保证遍历终止,即使在一个每个页面都链接到其他所有页面的网站上也是如此。

爬取与抓取单个 JS 页面有何不同?

抓取单个页面是一次渲染加上一次解析,提取你想要的字段。爬取则是在多个页面上重复同样的渲染加解析,还附带了发现链接、将其加入队列、去重和控制遍历速率等额外机制。渲染技术是共用的;爬取在此之上增加了前沿队列、已访问集合和礼貌控制。

什么时候应该使用异步 Crawler 而不是同步循环?

当每次渲染阻塞成为瓶颈时,切换到异步 Crawler,通常是你爬取超过几百个页面的时候。你不再按顺序等待每次渲染,而是提交 URL 并通过 webhook 回调接收已渲染的页面,使你能以 Crawlbase 集群的吞吐量爬取,而不是一次等待一个渲染的延迟。

如何在不被封锁的情况下礼貌地爬取?

在请求之间加入延迟,限制每次运行访问的页面数,并阅读网站的 robots.txt 以遵守其禁止规则和爬取延迟要求。通过轮换住宅 IP 路由请求(Crawling API 为你处理了这一点),使单个地址不会触发速率限制。关注状态码,当挑战开始出现时及时退出。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量