知识

如何在 Playwright 中使用住宅代理

一份在 Playwright 中使用住宅代理的实操指南:认证、按上下文轮换、地理定位、削减带宽,以及那些让人栽跟头的 Chromium 坑。

Chris Collins

Chris Collins

2026年7月6日 · 4 分钟阅读

一旦你见过一次,把住宅代理接进 Playwright 也就是几行代码。摩擦从来不在启动选项上,而在那些没人写下来的细节里:凭据如何编码定位、那个会让按上下文代理失效的 Chromium 坑、轮换如何映射到浏览器上下文,以及浏览器加载的每一张图片都是你要付费的带宽这个事实。

这是如何在 Python 中使用住宅代理的姊妹篇,讲浏览器这一侧。这里是真正能跑起来的复制粘贴示例,外加那些把一个能用的片段变成能扛住生产的自动化的部分。

下面的一切都使用 Shifter 的住宅 gateway:一个端点 p.shifter.io:443,所有定位都编码在用户名里。如果你在用别家供应商,形态是一样的;换掉主机和凭据格式即可。示例是 Node/JavaScript;一段 Python 版 Playwright 的片段在接近结尾处。

你必须先理解的那一件事

在住宅 gateway 上,代理用户名同时承载你的认证你的定位。你不是靠切换端点来换国家或换会话,而是靠改用户名字符串。一个用户名长这样:

customer-USERNAME-country-us-sid-abc123-ttl-600

从左到右读:账户 id,然后是标志(flags)。country-us 定位美国。sid-abc123 固定一个粘性会话。ttl-600 把那个 IP 保持 600 秒。去掉 sid/ttl,每一次新连接都会轮换到一个新 IP。密码是恒定的。这就是全部的心智模型,剩下的只是把它插进 Playwright 的代理槽位。

把凭据放在环境变量里,绝不要硬编码:

const USER = process.env.SHIFTER_USER; // 你的账户用户名
const PASS = process.env.SHIFTER_PASS;
const GATEWAY = "http://p.shifter.io:443";

先说一个 Playwright 特有的提醒:不要把用户名和密码内联到代理 URL 里。 Chromium 会忽略内联的代理凭据,所以 http://user:pass@host 会在认证时悄无声息地失败。Playwright 在代理对象上有专门的 usernamepassword 字段,用那两个。

10 行版本

要给整个浏览器用一个代理,在 launch() 上设置 proxy

const { chromium } = require("playwright");
const USER = process.env.SHIFTER_USER;
const PASS = process.env.SHIFTER_PASS;
(async () => {
const browser = await chromium.launch({
proxy: {
server: "http://p.shifter.io:443",
username: `${USER}-country-us`, // 定位就住在这里
password: PASS,
},
});
const page = await browser.newPage();
await page.goto("https://api.ipify.org?format=json");
console.log(await page.textContent("body")); // {"ip":"<一个美国住宅 IP>"}
await browser.close();
})();

username 字段就是整个 gateway 模型的所在。${USER}-country-us 定位美国;加 sid/ttl 得到粘性 IP,加 city/asn 进一步收窄。密码从不改变。

Chromium 坑:按上下文的代理

单代理版本用于一个身份很好。但使用 Playwright 的真正理由,是运行许多个身份,而为此你想要每个浏览器上下文一个不同的代理(每个上下文都是它自己隔离的 cookies/存储,也就是它自己的”用户”)。

坑在这里:在 Chromium 中,按上下文的代理只有在你启动浏览器时就已设置了一个代理的情况下才生效。不带代理启动,那么每个 newContext({ proxy }) 都会被悄无声息地忽略。修法是在启动时给一个占位服务器:

const browser = await chromium.launch({
proxy: { server: "per-context" }, // 占位符,启用按上下文代理
});
// 现在每个上下文都能带上它自己真正的代理 + 定位
const ctxUS = await browser.newContext({
proxy: {
server: "http://p.shifter.io:443",
username: `${USER}-country-us-sid-a1-ttl-600`,
password: PASS,
},
});
const ctxDE = await browser.newContext({
proxy: {
server: "http://p.shifter.io:443",
username: `${USER}-country-de-sid-b2-ttl-600`,
password: PASS,
},
});

ctxUS 在一个美国 IP 上浏览,ctxDE 在一个德国 IP 上,同时进行,在同一个浏览器里。这就是要在其上构建的模式。(Firefox 和 WebKit 并不严格需要这个占位符,但设上它是无害的,还能让你的代码跨浏览器通用。)

轮换 vs 粘性,Playwright 的做法

在一个裸 HTTP 抓取器里,你常常每个请求都轮换。在一个浏览器里,那通常是错的:真实用户不会在会话中途换 IP,所以一个在页面加载之间跳变、而 cookies 却保持恒定的 IP,是一个检测信号。惯用的映射是:

  • 一个上下文 = 一个粘性身份。 给每个上下文一个唯一的 sid 和一个覆盖该会话的 ttl,这样它的所有页面加载共享一个 IP,就像一个真实用户。
  • 靠创建新上下文来轮换。 一个带新 sid(或不带 sid)的新鲜上下文会得到一个新 IP。用完就把旧的关掉。
async function contextForSession(browser, sessionId, country = "us") {
return browser.newContext({
proxy: {
server: "http://p.shifter.io:443",
username: `${USER}-country-${country}-sid-${sessionId}-ttl-600`,
password: PASS,
},
});
}
// 处理 50 个目标,每个都有自己 IP 稳定的身份
for (let i = 0; i < 50; i++) {
const ctx = await contextForSession(browser, `job-${i}`, "us");
const page = await ctx.newPage();
await page.goto("https://example.com/item/" + i);
// ... 抓取 ...
await ctx.close(); // 用完这个身份
}

sid 自己挑,每个逻辑会话任选一个唯一字符串。相同的字符串在 TTL 到期前会返回相同的 IP。让上下文并发运行(一个小池子),而不是一次开 50 个;更多浏览器不等于更快,而且一群 IP 猛击同一个目标仍会触发行为检测。如果你是持续被封而非间歇被封,问题在 IP 质量或行为,不在轮换,见如何在抓取时避免被封

地理定位,以及让浏览器与 IP 相符

因为定位住在用户名里,地理就只是一个标志:country-de,加 city-berlinasn-3320 来收窄。但用真实浏览器时,还有被人忽略的另一半:你的浏览器指纹必须与你的 IP 相符。 一个德国住宅 IP 配上一个 en-US 语言环境和一个纽约时区,是一个反机器人系统一眼就会标记的矛盾。(见为什么爬虫会被封。)

Playwright 让你在上下文上把这一切对齐:

const ctx = await browser.newContext({
proxy: {
server: "http://p.shifter.io:443",
username: `${USER}-country-de-city-berlin-sid-de1-ttl-600`,
password: PASS,
},
locale: "de-DE",
timezoneId: "Europe/Berlin",
geolocation: { latitude: 52.52, longitude: 13.405 },
permissions: ["geolocation"],
});

城市名使用 gateway 的格式(小写);标志随意组合,顺序对 gateway 无所谓。把 locale/timezoneId/geolocation 与代理国家对齐,是你为了看起来像一个真正的本地用户所能做的杠杆最高的事情之一。

削减你的带宽(你是按 GB 付费的)

这一条是浏览器特有的,而且很要紧,因为住宅代理是按吉字节计费的。一个无头浏览器,若放任不管,会下载页面上的每一张图片、字体、视频和跟踪脚本,其中大多数你抓取时并不需要。把它们中止掉,可以把带宽(以及成本)削减掉远超一半,还能加快你的运行。

context.route() 在重型资源触及代理之前把它们拦下:

await ctx.route("**/*", (route) => {
const type = route.request().resourceType();
if (["image", "media", "font"].includes(type)) {
return route.abort(); // 从不下载,从不计费
}
return route.continue();
});

保留 documentscriptxhrfetch(真正的数据和渲染它的 JS);其余的丢掉。如果站点把内容懒加载在图片后面,测试一下你的抓取是否仍然有效,但对大多数任务来说这是白捡的钱。它与按带宽计费的住宅代理所讲的按 GB 模型很搭。

验证它真的有效

在信任一份配置之前,确认两件事:IP 在正确的国家(地理),并且它在上下文之间会变(轮换)。一个快速检查:

const page = await ctx.newPage();
await page.goto("http://ip-api.com/json");
const data = JSON.parse(await page.textContent("body"));
console.log(data.query, data.countryCode); // 期望一个 DE IP

启两个带不同 sid 的上下文,你应该看到两个不同的 IP,都在正确的国家。如果国家不对,检查你标志的拼写。如果 IP 在上下文之间从不改变,你很可能忘了启动时的占位符,而 Chromium 正在忽略按上下文的代理。

真正会发生的错误

Playwright 把代理问题以导航错误的形式暴露,而不是 HTTP 状态码。你会遇到的三个:

  • net::ERR_TUNNEL_CONNECTION_FAILED 代理认证失败,或一个不被识别的标志(countryasn 里的拼写错误,或被 Chromium 忽略的内联凭据)。修正 username/password 字段;重试无济于事。
  • net::ERR_PROXY_CONNECTION_FAILED 触及不到 gateway,或没有 IP 匹配一个过紧的过滤条件(如 country + city + asn)。检查连通性,然后放宽一个标志。
  • 一个加载了但显示封锁/CAPTCHA 的页面 是目标而非代理在拒绝你。轮换到一个新上下文/IP,放慢速度,并像上面那样对齐指纹。

把导航包在一个小的重试里,失败时造一个新鲜的上下文:

async function gotoWithRetry(browser, url, country = "us", tries = 3) {
for (let attempt = 0; attempt < tries; attempt++) {
const ctx = await contextForSession(browser, `r-${Date.now()}-${attempt}`, country);
const page = await ctx.newPage();
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
return { ctx, page }; // 调用方用完后关闭 ctx
} catch (e) {
await ctx.close();
if (attempt === tries - 1) throw e;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // 1s、2s
}
}
}

一个新上下文意味着一个新 IP,所以一次重试是真的换了一条不同的线路,而不是同一条失败的。

关于 SOCKS5 的一点说明

gateway 会说 SOCKS5,但有一个值得知道的 Playwright 限制:Playwright/Chromium 不支持带认证的 SOCKS 代理。 因为 gateway 是按用户名认证的(你的定位就住在那里),SOCKS5 在这里不实用,用上面展示的 HTTP 代理即可,它本来也是浏览器抓取的正确默认。SOCKS 的取舍见 用于自动化的 SOCKS5 代理

Python 里的同样做法

Playwright 的 Python API 与 JavaScript 版镜像一致;代理对象完全相同:

from playwright.sync_api import sync_playwright
import os
USER, PASS = os.environ["SHIFTER_USER"], os.environ["SHIFTER_PASS"]
with sync_playwright() as p:
browser = p.chromium.launch(proxy={"server": "per-context"})
ctx = browser.new_context(proxy={
"server": "http://p.shifter.io:443",
"username": f"{USER}-country-us-sid-a1-ttl-600",
"password": PASS,
})
page = ctx.new_page()
page.goto("https://api.ipify.org")
print(page.text_content("body"))
browser.close()

同样的启动时占位符规则,同样的编码在用户名里的定位。对于非浏览器的 HTTP 工作,Python 指南涵盖了 requestshttpx 和 Scrapy。

常见问题

为什么 http://user:pass@host 在 Playwright 里不管用? Chromium 忽略内联的代理凭据。改为把用户名和密码放进 proxy 对象专门的 username/password 字段。这是 Playwright 里”代理不工作”最常见的原因。

为什么我按上下文的代理被忽略了? 因为你启动 Chromium 时没带代理。按上下文的代理只有在浏览器带着一个代理启动时才会激活,给 launch() 传一个占位符 proxy: { server: "per-context" },然后在每个上下文上设真正的代理。

在浏览器里我该每个请求都轮换 IP 吗? 不。一个浏览器会话应该守着一个 IP,就像一个真实用户。用每个上下文一个粘性 sid,靠创建新上下文来轮换,而不是在会话中途切换 IP。

我怎么同时运行不同的国家? 一个浏览器,多个上下文,每个在其代理用户名里带一个不同的 country-<cc>。它们并发且独立地运行。

这在 Puppeteer 或 Selenium 上也行吗? gateway 行。Puppeteer 在启动时接受 --proxy-server 并通过 page.authenticate() 认证;Selenium 用一个代理 capability 或一个扩展来做认证。编码在用户名里的定位是完全一样的;只是接线方式不同。Playwright 的按上下文模型对于多身份工作是最干净的。

我的运行把带宽烧得很快。我该怎么办? 像上面展示的那样,用 context.route() 拦掉图片、媒体和字体。浏览器默认会下载一切,而在按 GB 的套餐上那是真金白银。中止重型资源常常把用量削减掉一半以上。

收尾

模式是:用一个 per-context 占位符启动 Chromium,给每个上下文一个 username 编码了 country/sid/ttl 的代理,把 locale/timezone/geolocation 与国家对齐,并中止图片和字体来省带宽。靠造新上下文来轮换,而不是切换 IP。错误以 net:: 导航失败的形式出现,而一个造出新鲜上下文的重试会给你一个新鲜的 IP。

从上面的片段开始,把它们指向住宅 gateway,一个下午你就有了具备生产形态的浏览器自动化。套餐和按 GB 费率在定价页面,完整的标志参考住在 gateway 文档里。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始