您写了几行 Python,将 requests 指向某个商品列表页或搜索结果页,把响应交给 BeautifulSoup,却几乎什么都拿不到。标题有,布局有,但您真正想要的数据却不见踪影。这正是人们在尝试用 Python 抓取 JavaScript 页面时碰到的最常见障碍:页面在浏览器中、在初始 HTML 到达之后才渲染内容,因此普通的 HTTP 请求只能看到一个空壳。
本指南将解释这一现象的原因,逐步介绍获取已渲染数据的三种可行方案(无头浏览器、底层 JSON API,以及渲染 API),并展示一个简洁、可运行的示例:通过 Crawling API 获取渲染完成的页面,再用 BeautifulSoup 解析。读完本文,您将了解每种方案适用的场景,以及如何避免运行过程中被封禁。
为什么 requests 加 BeautifulSoup 只会返回空壳
与其阅读描述,不如亲眼看看这个问题。用最朴素的方式请求一个客户端渲染页面,看看返回了什么。
import requests from bs4 import BeautifulSoup url = "https://example-shop.com/search?q=smartwatch" html = requests.get(url).text soup = BeautifulSoup(html, "html.parser") products = soup.select("[data-product-title]") print(f"found {len(products)} products") # found 0 products
状态码 200,HTML 文档看起来完整,但商品数量为零。原因在于页面的生命周期:服务器发送的是一个轻量级 HTML 骨架,包含几个 div 挂载点、一些 <script> 标签,也许还有一个加载动画。只有当这些脚本执行后,浏览器才会调用 API、以 JSON 形式接收商品数据,并构建持有这些数据的 DOM 节点。requests 库不会执行 JavaScript,它只下载骨架便停止,因此 BeautifulSoup 永远找不到商品节点。
下面每种方案的修复思路在本质上是相同的:让页面进入 JavaScript 已经运行完毕的状态,再解析该状态。各方案的差异仅在于:如何到达已渲染的状态,以及在速度、基础设施和被封禁概率方面各自的代价。
在页面上右键选择"查看网页源代码",即可看到服务器发送的原始 HTML,这正是 requests 拿到的内容。再打开开发者工具,查看"元素"面板,其中显示的是脚本运行后的实时 DOM。如果目标数据出现在元素面板却不在源代码中,说明该页面是客户端渲染的,普通请求无法获取所需数据。
方案一:用 Selenium 或 Playwright 驱动真实浏览器
最直接的解决方法是使用真正能运行浏览器的工具。Selenium 和 Playwright 都可以启动 Chromium(无头或可见模式),加载 URL,等待脚本执行完毕,并让您读取已渲染的 DOM。由于使用的是真正的浏览器引擎来执行 JavaScript,普通请求中缺失的数据现在就出现了。
一个最简单的 Playwright 示例如下:
from playwright.sync_api import sync_playwright from bs4 import BeautifulSoup with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example-shop.com/search?q=smartwatch") page.wait_for_selector("[data-product-title]") html = page.content() browser.close() soup = BeautifulSoup(html, "html.parser") titles = [t.get_text(strip=True) for t in soup.select("[data-product-title]")] print(titles)
关键在于 wait_for_selector。与其用固定的 sleep 来猜测等待时间,不如告诉浏览器等到目标元素真正出现,这样既更快又更可靠。Selenium 也提供了类似的功能,即 WebDriverWait 和预期条件辅助函数。
这种方案确实有效,当您需要点击、滚动、填写表单或完成多页流程时,它是正确的选择。但它的代价不小:每个浏览器实例都会消耗数百兆内存和一个完整 CPU 核心,因此并行运行多个实例代价高昂。安装配置也很繁琐:需要管理浏览器二进制文件、驱动版本以及一个脆弱的依赖链。而且,单纯渲染并不能让您隐身。来自数据中心 IP、使用默认自动化指纹的无头浏览器,会被严肃的反爬虫系统同样迅速地标记和封禁,渲染只解决了 JavaScript 问题,对检测问题毫无帮助。关于各引擎的详细对比,请参阅如何为网络抓取选择无头浏览器,以及这篇使用 Selenium 和 BeautifulSoup 抓取动态内容的实战演练。
方案二:跳过浏览器,直接调用底层 API
这是大多数教程忽略的关键洞察。当客户端渲染页面构建自身时,它几乎总是从后端 JSON 端点获取数据。如果能找到该端点,就可以直接调用它,完全跳过渲染,不用任何浏览器,直接得到干净的结构化 JSON。
要找到它,打开开发者工具,进入"网络"选项卡,过滤 Fetch/XHR,然后刷新页面。您要寻找的是响应中包含目标数据的请求,通常是带有 /api/、/graphql 或查询密集路径的 URL。找到之后,用 Python 复现该请求。
import requests api = "https://example-shop.com/api/search" params = {"q": "smartwatch", "page": 1} headers = {"Accept": "application/json"} data = requests.get(api, params=params, headers=headers).json() for item in data["results"]: print(item["title"], item["price"])
如果这条路走通了,它毫无疑问是效率最高的选择:没有浏览器开销,得到的是结构化数据而非需要解析的 HTML,而且可以通过 API 自身的参数实现内置分页。在动用更重的工具之前,花十分钟在网络选项卡里搜一搜始终是值得的。
不过,这种方式并非总能奏效。端点可能需要签名令牌、会话 Cookie,或者页面动态生成的特定请求头。它也可能受到与页面相同的反爬虫层的保护。而且它可能不经预告就发生变化,因为内部 API 不承诺任何稳定性。当 API 可以访问时,优先走这条路。当它被锁定时,您只能回到需要已渲染页面的情况,这就引出了第三种方案。
方案三:通过 Crawling API 渲染并解析结果
前两种方案各自解决了问题的一半。无头浏览器能渲染但无法隐藏您的身份;直接 API 调用很干净但常被拦截。通常您需要的是两者兼得:一个能执行页面 JavaScript 的真实浏览器,同时背后有一个被网站识别为真实访客的 IP,并在一次调用中返回已完成的 HTML,让 Python 代码保持简洁。
这正是 Crawling API 的用途。您向它发送一个带有 JavaScript token 的 URL,它在自己那侧的真实浏览器中加载该页面,在服务器端轮换住宅 IP,然后将完整渲染的 HTML 返回给您。您无需运行浏览器集群或维护代理池,只需发出一次 HTTP 请求,再用您已熟悉的 BeautifulSoup 解析响应即可。
Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 先在真实浏览器中渲染页面。对于客户端渲染的目标,您需要 JS token,否则得到的仍是空壳,没有任何内容可供解析。
安装官方客户端和 BeautifulSoup,然后获取已渲染的页面。
python -m venv scraper_env source scraper_env/bin/activate pip install crawlbase beautifulsoup4
在 Windows 上,请用 scraper_env\Scripts\activate 代替 source 命令来激活环境。现在使用 JS token 以及两个对客户端渲染内容至关重要的等待选项来获取页面。
from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"}) def crawl(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__": page_url = "https://example-shop.com/search?q=smartwatch" html = crawl(page_url) print(html[:500] if html else "No HTML returned")
两个等待选项对客户端渲染目标发挥着关键作用。ajax_wait 告诉 API 在捕获页面前等待异步请求完成,而 page_wait 在加载后再固定等待指定毫秒数,以便延迟渲染的元素也能出现。五秒是一个合理的起点;如果字段返回为空,可以适当提高。运行此代码,您应该能在前 500 个字符中看到真实的标记,而不是普通请求返回的骨架。在编写任何选择器之前,这一步先确认渲染是否正常工作。
通过一次调用,在可信 IP 背后渲染 JavaScript 页面,正是 Crawling API 的用途。传入 JS token,它就在真实浏览器中运行页面,在服务器端轮换住宅 IP,并返回已完成的 HTML,让您无需自己维护无头浏览器集群和代理池。先在免费套餐中用真实页面试试吧。
用 BeautifulSoup 解析已渲染的 HTML
一旦 crawl 返回已渲染的 HTML,解析步骤就是普通的 BeautifulSoup 操作,因为 JavaScript 已经在服务器端运行完毕,数据节点已经存在。用一个小辅助函数包装字段访问,这样某个元素缺失时不会导致整个运行崩溃。
import json from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"}) def crawl(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") return None def parse_products(html): soup = BeautifulSoup(html, "html.parser") items = [] for card in soup.select("div.product-card"): title = card.select_one("[data-product-title]") price = card.select_one("span.price") items.append({ "title": title.get_text(strip=True) if title else None, "price": price.get_text(strip=True) if price else None, }) return items def main(): url = "https://example-shop.com/search?q=smartwatch" html = crawl(url) if not html: return products = parse_products(html) print(json.dumps(products, indent=2)) if __name__ == "__main__": main()
执行 python scraper.py,您将得到一个干净的结构化列表,可以直接写入 JSON、CSV 或数据库。
[ { "title": "Aero Fit Smartwatch 2", "price": "$129.00" }, { "title": "Pulse Sport Band Pro", "price": "$89.99" } ]
随着网站重新设计,class 名称和 data 属性会发生变化,因此上个月还有效的选择器今天可能返回空值。当某个字段返回 None 时,请在开发者工具中重新检查实时页面并更新选择器。定期维护选择器对任何生产环境的爬虫而言都是正常操作,不代表代码出了问题。
抓取 JavaScript 页面的常见陷阱
针对客户端渲染目标的失败运行,大多由以下几类问题引起。提前了解它们可以节省大量调试时间。
-
抓取时机太早。最常见的错误是在内容出现之前就开始解析。尽量等待特定选择器,或者在使用 Crawling API 时,优先依赖
ajax_wait和较充裕的page_wait,而不是盲目设置固定延迟。 - 需要交互才能显示的内容。某些数据只有在滚动、点击标签或按下"加载更多"之后才会出现。单次请求或单次渲染无法触发这些操作。这正是需要逐步脚本化驱动浏览器,或使用带有滚动指令的渲染方案的场景。
- 懒加载和分页列表。无限滚动页面随用户滚动分批加载。您可以在浏览器中驱动滚动,但更好的方式是找到背后的分页 API,直接逐页请求。
- 即使渲染了也被封禁。渲染本身并不等于隐身。数据中心 IP 或明显的自动化指纹仍会受到挑战。真正能维持大规模运行的,是住宅 IP 轮换。
选择方案
没有唯一正确的工具,只有最适合当前任务的工具。
首先尝试直接调用 API。如果网络选项卡暴露了一个开放的 JSON 端点,这是最干净、最快速的路径,完全没有渲染开销。在动用更重的手段之前,一定先检查一下。
需要交互时使用脚本化浏览器。登录、多步骤表单、点击以及滚动触发的内容都需要 Selenium 或 Playwright,让您逐步控制会话。接受内存和配置成本,换取这种控制能力。
需要在不被封禁的情况下大规模获取已完成 HTML 时,使用渲染 API。当任务是"可靠地抓取大量 JavaScript 页面并解析它们"时,Crawling API 消除了最难的两个问题,运行浏览器和轮换 IP,只留下一次 HTTP 调用加 BeautifulSoup。如果您更倾向于通过轮换池路由自己的浏览器流量,Smart AI Proxy(也称 AI Proxy)可作为即插即用的代理端点提供住宅轮换功能。关于这些模式的更广泛介绍,请参阅如何爬取 JavaScript 网站。
核心要点
-
普通请求只能看到骨架。
requests不执行 JavaScript,因此客户端渲染的数据在其下载的 HTML 中是缺失的。 - 存在三种真实的解决方案。驱动真实浏览器、直接调用底层 JSON API,或通过返回已完成 HTML 的 API 进行渲染。
- 优先检查是否存在开放 API。当直接 JSON 端点可访问时,它是最快、最干净的路径,渲染成本为零。
- 渲染不等于隐身。数据中心 IP 上的无头浏览器仍会被封禁;住宅 IP 轮换才是维持运行的关键。
-
Crawling API 将两者合二为一。JS token 通过可信 IP 在一次调用中渲染页面;
ajax_wait和page_wait控制 BeautifulSoup 解析结果前的等待时长。
常见问题
为什么 requests 在 JavaScript 页面上返回不到任何数据?
因为 requests 只下载服务器发送的 HTML,从不执行 JavaScript。客户端渲染页面发送的是一个轻薄骨架,然后在浏览器中通过加载后调用 API 来构建真实内容。由于普通请求中这一步永远不会发生,BeautifulSoup 解析响应时数据节点根本不存在,所以您的选择器什么也匹配不到。
用 Python 抓取 JavaScript 页面是否一定需要无头浏览器?
不是。无头浏览器是一个选项,但往往是最重的选项。在启动 Selenium 或 Playwright 之前,先打开网络选项卡,查找页面调用的 JSON 端点。如果该端点可以访问,直接用 requests 调用它更快、更简洁。只有在没有开放端点或数据需要交互才能获取的情况下,才应该使用浏览器或渲染 API。
ajax_wait 和 page_wait 有什么区别?
ajax_wait 告诉 Crawling API 在捕获 HTML 之前等待页面的异步(XHR/fetch)请求完成,这正是填充客户端渲染数据的步骤。page_wait 在加载后增加一个固定的毫秒级延迟,给延迟渲染的元素更多显示时间。对于客户端渲染目标应同时使用两者,如果字段返回为空,可以提高 page_wait 的值。
为什么我的无头浏览器仍然被封禁?
因为渲染和隐身是两个独立的问题。运行真实浏览器解决了 JavaScript 执行问题,但请求仍然来自一个可识别的 IP 和自动化指纹。反爬虫系统会无视渲染情况,直接标记数据中心 IP 和默认无头签名。住宅 IP 轮换(Crawling API 和 Smart AI Proxy 均提供)才是解决封禁问题的手段。
我可以将 BeautifulSoup 与 Crawling API 配合使用吗?
可以,这正是推荐的工作流程。Crawling API 返回完全渲染的 HTML,因此您可以像解析任何静态页面一样用 BeautifulSoup 解析它。区别在于 JavaScript 已经在服务器端运行完毕,所以您选择器所针对的数据节点在接收到的 HTML 中是存在的。
如何抓取在滚动时加载更多内容的 JavaScript 页面?
无限滚动页面随用户滚动分批加载,因此单次请求或渲染只能获取第一批内容。您有两个选择:在 Selenium 或 Playwright 中脚本化滚动并等待每一批,或者在网络选项卡中找到滚动触发的分页 API,直接逐页请求。当端点可访问时,直接 API 路由通常更快、更可靠。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
