浏览器 Cookie 管理:多身份工作流指南
Cookie 如何承载会话与同意状态,为什么页面创建后再添加的 Cookie 可能错过第一个请求,以及如何在启动时为每个身份加载独立的 Cookie 集合。
BotBrowser Team
Cookie 是服务器要求浏览器保存、并在之后的请求中带回的小段状态。网站用它记录登录会话、语言和主题偏好,以及同意选择。如果你以开发者或 QA 工程师的身份测试网站,Cookie 存储就成了测试输入的一部分。同一个页面对首次访问者、已经接受同意横幅的回访者和已登录账号的表现各不相同。不控制初始 Cookie 集合的测试,会在不经意间测量其中某一种情况。
下面的章节沿着一个 Cookie 从服务器响应头走到浏览器存储,解释为什么通过框架 API 添加的 Cookie 可能晚于第一个请求,并说明如何用 BotBrowser 的 --bot-cookies 参数在启动时加载 Cookie 集合,让每个身份都有一个有文档记录、可重复的起始状态。它们也会点明 Cookie 存储不隔离什么,因为独立的 Cookie 文件只是独立身份的其中一层。关于如何在 Cookie 之外分隔多个账号,可参阅社交媒体多账号管理与浏览器隔离。
这张图对比了两种事件顺序。当 Cookie 在任何页面出现之前就已在存储中时,第一个请求会携带它们。当脚本在页面已经开始加载之后才添加它们时,较早的请求就不带这些 Cookie。
TL;DR
- Cookie 只有在
Domain、Path、Secure、SameSite与有效期都与请求匹配时才会发送,属性写错看起来就像网站无视了你的设置。 context.addCookies()与page.setCookie()在上下文或页面已存在后才执行,先发出的请求不带任何 Cookie。--bot-cookies在第一次导航之前加载集合,可从内联 JSON 或@文件读取,缺少url字段的 Cookie 会被静默跳过。- 每个身份一份 Cookie 文件并配上它自己的 profile,才能把回访者与同意流程测试归到同一个有文档记录的状态。
Contents
- Cookie 携带什么,哪些属性影响发送与可见性
- 为什么通过框架 API 添加的 Cookie 可能错过第一个请求
- 在启动时为每个身份加载 Cookie 集合
- 让身份与上下文保持分离
- 确认起始状态与你的预期一致
- BotBrowser 适用于哪里,边界在哪里
- 实践结论
Cookie 携带什么,哪些属性影响发送与可见性
RFC 6265 定义了这套机制。服务器发送带有名称、值和可选属性的 Set-Cookie 响应头。浏览器保存该 Cookie,并在之后向匹配的主机和路径发出请求时,通过 Cookie 请求头把它带回去。页面脚本可以通过 document.cookie 读写 Cookie,但标记为 HttpOnly 的除外,浏览器不让页面脚本接触它们。MDN 的 Cookie 指南描述了同样的流程,并补充了 SameSite 属性,该属性是在最初的规范之后出现的。
Domain、Path、过期时间、Secure 和 SameSite 这五类属性会影响发送。HttpOnly 不影响是否发送,只控制页面脚本能否读取 Cookie。
- Domain 与仅限主机的 Cookie。 没有
Domain属性的 Cookie 只属于设置它的那个主机。带有Domain=example.com的 Cookie 也会发送给该主机的子域名。 - Path。 Cookie 只会发送给与已保存路径匹配的请求路径。
- Expires 与 Max-Age。 两者都没有的 Cookie 是会话 Cookie,应随浏览会话结束而结束。带有未来日期的 Cookie 是持久 Cookie,重启后仍然存在。
- Secure。 Cookie 只通过 HTTPS 发送。
- SameSite。 取值
Strict、Lax和None控制 Cookie 是否随跨站请求发送,None需与Secure一起使用。
对测试来说,属性往往比值更重要。用错误的域名、错误的路径保存的 Cookie,或在纯 HTTP 测试站点上带有 Secure 的 Cookie,会无报错地保存下来,然后永远不会被发送,看起来就像网站无视了你的设置。RFC 6265 只规定了浏览器保存 Cookie 数量的最低限度,把淘汰策略留给具体实现,而且浏览器对 SameSite 等属性的默认值也随时间变化过。请把规范和 MDN 指南都当作参考,并在你实际测试所用的浏览器版本中确认行为。
同意类 Cookie 还需要再说明一点。同意选择没有标准的名称或值。每个网站或同意管理平台都会定义自己的 Cookie、自己的值格式和自己的有效期,下面示例中的名称和值只是示意。要构建真实的起始状态,请在你自己控制的普通浏览器会话中做一次选择,读取网站作为响应设置的 Cookie,并复制它的名称、值的格式、域名、路径和过期时间。凭猜测编造的 Cookie 可能直接被网站忽略,测试看到的就会是你以为已经关闭的横幅。
为什么通过框架 API 添加的 Cookie 可能错过第一个请求
自动化框架提供了自己的 Cookie 调用。Playwright 有 context.addCookies(),Puppeteer 有 page.setCookie()。这两个都是脚本在浏览器上下文或页面已经存在之后才发出的调用。浏览器启动,创建上下文和页面,然后你的调用才到达。从那一刻起,Cookie 才在存储中。在调用之前发出的请求不带其中任何一个。
在很多脚本中,这个间隙是无害的。如果你先创建上下文,添加 Cookie,之后才第一次调用 goto(),第一次导航就已经带上它们。当有内容在你的调用之前加载时,间隙才会出现。浏览器或启动器在启动时打开的页面,可能自行开始加载。测试运行器的 fixture 可能在一处创建页面,在另一处添加 Cookie。共享的辅助函数可能替你打开第一个页面。在每种情况下,最早的请求发出时 Cookie 存储仍然是空的,而你得到哪种顺序,可能取决于你无法控制的时序。
同意流程和回访者测试对此最敏感。第一个响应往往决定服务器是显示同意横幅,还是为从未见过的访问者签发新的会话 Cookie。如果该响应是针对不带 Cookie 的请求生成的,服务器可能设置它自己的 Cookie,而你片刻之后添加的 Cookie 可能与之冲突,或者来得太晚而不起作用。这时测试反映的是新访问者的遭遇,尽管本意是测试回访者。
启动时加载改变了顺序。根据 BotBrowser 文档,通过 --bot-cookies 传入的 Cookie 在第一次页面导航之前加载,因此第一个 HTTP 请求就已经包含它们。这是这里做的唯一比较:Cookie 相对于第一个请求何时可用。框架 API 在测试中途修改 Cookie 时仍然有用,不需要为此替换它们。
要弄清这个间隙是否影响你的配置,请从浏览器启动的那一刻起只记录每个请求是否有 Cookie 请求头以及其中的名称。把最前面的记录与预期名称对照;不要保存原始请求头,因为其中可能包含会话凭据。如果第一个请求已经带有预期名称,你的顺序没有问题,用框架调用就足够。如果最早的记录没有名称,就把这组 Cookie 移到启动参数里。
在启动时为每个身份加载 Cookie 集合
--bot-cookies 参数需要 PRO 级别。它接受由 Cookie 对象组成的 JSON 数组,可以直接写在参数值里,也可以在值以 @ 开头时从文件读取。内联 JSON 适合小型集合,例如单个同意 Cookie:
chromium-browser \
--bot-profile="path/to/profile.enc" \
--bot-cookies='[{"url":"https://example.com","name":"consent","value":"accepted","domain":".example.com"}]'
文件适合较大的集合,并让启动命令保持易读。文档的故障排查建议是在 @ 之后使用绝对路径:
[
{
"url": "https://example.com",
"name": "session_id",
"value": "abc123",
"domain": ".example.com",
"path": "/",
"secure": true,
"httpOnly": true,
"expirationDate": 1893456000
},
{
"url": "https://example.com",
"name": "locale",
"value": "en-US",
"domain": ".example.com",
"path": "/"
}
]
每个 Cookie 对象支持以下字段,与文档中列出的一致:
- url(必填):用于设置 Cookie 的完整 URL,例如
https://example.com(含协议)。没有它的 Cookie 会被静默跳过。 - name 和 value(必填):Cookie 本身的数据。
- domain:Cookie 所属的域名。像
.example.com这样带前导点的写法包含子域名。 - path:默认为
/。 - secure:默认为
true,因此 Cookie 只通过 HTTPS 发送。 - httpOnly:默认为
false。 - sameSite:
strict、lax或none。 - expirationDate:以秒为单位的 Unix 时间戳。
大部分困惑来自三个细节。第一是 url 字段。没有它的 Cookie 会被静默跳过,所以缺少一个字段不会产生任何错误,只会得到比预期更小的 Cookie 集合。启动后请清点 Cookie 数量,而不要假定它们都已加载。第二是 secure,默认值为 true。按我们对该默认值的理解,如果你的测试站点使用纯 HTTP,请显式设为 false,否则 Cookie 会被保存却永远不会发送。第三是在代码中构造参数的方式。把 JSON.stringify() 的输出直接作为值传入,不要再包一层额外的引号,因为多余的引号会成为值的一部分,JSON 就无法解析了。还要使用未来的、以秒而不是毫秒为单位的 expirationDate,免得 Cookie 一加载就过期。
使用一个身份、一个配置文件和一个 Cookie 文件的 Playwright 启动方式如下:
import { chromium } from 'playwright-core';
const browser = await chromium.launch({
executablePath: process.env.BOTBROWSER_EXEC_PATH,
headless: true,
args: ['--bot-profile=profiles/identity-a.enc', '--bot-cookies=@/absolute/path/cookies/identity-a.json'],
});
const page = await browser.newPage();
await page.goto('https://example.com/');
让身份与上下文保持分离
每个身份保留一个 Cookie 文件,并以该身份命名。把它与该身份自己的配置文件配对,不要把文件从一个身份复制到另一个身份,因为复制过去的集合会把第一个身份的同意选择和会话记录带进第二个身份。轮换配置文件时,同时复查 Cookie 文件,因为与旧配置文件相符的同意记录或语言偏好,可能与新配置文件不再相符。把这些文件放在一个有文档记录的文件夹里,并只允许运行测试的人访问,也能让审阅者在不同次运行之间有具体的东西可比较,这正是可重复起始状态的意义所在。混用文件会让结果难以归因:如果同意测试和已登录测试共用一个文件,失败可能来自任意一种状态,不重新运行两者就没人说得清是哪一种。分开的文件把这个问题变成一次查阅。多账号隔离指南和配置文件管理指南介绍了如何组织配置文件及其配套文件。
参数的作用范围取决于你如何传入它。BotBrowser 文档说明,启动时加载的 Cookie 会成为浏览器的启动状态。使用 Puppeteer 时,请用 browser.defaultBrowserContext() 访问该上下文,因为在其他上下文上调用 context.cookies() 可能返回空列表。配合 Per-Context Fingerprint,也可以在创建 BrowserContext 时通过 botbrowserFlags 传入 --bot-cookies,此时 Cookie 只会导入该上下文。每个上下文以自己的 Cookie 状态启动,同时共享同一个浏览器进程:
const client = await browser.newBrowserCDPSession();
const cookies = JSON.stringify([
{ url: 'https://example.com', name: 'consent', value: 'accepted', domain: '.example.com', path: '/' },
]);
const { browserContextId } = await client.send('Target.createBrowserContext', {
botbrowserFlags: ['--bot-profile=profiles/identity-a.enc', '--bot-cookies=' + cookies],
});
请在上下文存在之后再创建页面,这样第一次导航就已使用该上下文的 Cookie 状态。
单靠 Cookie 存储,并不能隔离让两个身份彼此独立的全部内容。RFC 6265 明确指出了若干限制,其余的则来自该参数文档所述的范围:
- Cookie 不按端口隔离,所以同一主机上的两个服务共享该主机的 Cookie。
- 父域名的 Cookie 对所有子域名可见,同级子域名可以设置另一个子域名会读取的 Cookie。
- 本地存储、IndexedDB、缓存和 Service Worker 是各自独立的存储。
--bot-cookies的文档没有描述对它们的预加载。 - Cookie 文件并不涉及与身份相伴的指纹配置文件、语言设置或网络路由。
浏览器存储指南解释了这些其他存储与 Cookie 有何不同。如果某个身份必须在没有残留状态的情况下启动,还要检查重复使用的 --user-data-dir 带来了什么,因为它会把所有这些存储一并保留,而不仅仅是 Cookie。
确认起始状态与你的预期一致
三项检查覆盖起始状态:存储中有什么,第一个请求携带什么,页面能读到什么。下面的 Playwright 示例读取已存储的 Cookie 状态;下一项检查会查看最早的请求:
await page.goto('https://example.com/');
console.log(await page.context().cookies('https://example.com/'));
比较 Cookie 名称,不记录 Cookie 值
存在 Cookie 请求头只是第一项检查。为了得到可重复的结果,请将预期加载的名称与第一个请求中观察到的名称进行比较,同时不要把值保存在日志中:
const expectedNames = new Set(['session_id', 'locale']);
let firstRequestSeen = false;
let firstCookieNames = [];
page.on('request', request => {
if (firstRequestSeen) return;
firstRequestSeen = true;
const header = request.headers().cookie || '';
firstCookieNames = header
.split(';')
.map(part => part.split('=')[0].trim())
.filter(Boolean);
});
await page.goto('https://example.com/');
const actualNames = new Set(firstCookieNames);
console.log(
JSON.stringify({
headerPresent: firstRequestSeen && actualNames.size > 0,
expected: [...expectedNames].sort(),
actual: [...actualNames].sort(),
missing: [...expectedNames].filter(name => !actualNames.has(name)).sort(),
})
);
missing 为空只能证明这些 Cookie 名称到达了第一个请求,不能证明 Cookie 值或站点会话策略有效。如果缺少名称,请先检查 url、域名、路径、secure、过期时间和启动顺序,再检查应用响应。
请在第一次导航之前注册请求监听器,这样最早的请求也会被包含在内。Cookie 列表应包含文件中的每个 Cookie,第一个请求应显示 Cookie 请求头。如果数量少于文件中的数量,请首先查找是否缺少 url 字段。请记住,标记为 HttpOnly 的 Cookie 不会出现在 document.cookie 中。该属性本就如此定义,所以它在页面上缺席并不是失败。对这类 Cookie,请检查存储和请求头。
把 Cookie 的标志与目标站点实际设置的内容对照。在一次性的浏览器配置文件里正常打开该站点,在网络面板中查看它的 Set-Cookie 响应头,并让 Domain、Path、Secure、HttpOnly、SameSite 和过期时间保持一致。预加载的 Cookie 如果标志与真实的不同,可能会被区别对待,不同于站点本会签发的 Cookie,这时测试覆盖的就是真实访问者从不会有的状态。
当某个 Cookie 没有到达时,请按常见程度依次排查原因。第一,检查 url 字段,因为被跳过的 Cookie 不留任何痕迹。第二,把 domain 和 path 与请求 URL 对照,并记住仅限主机的 Cookie 不会到达子域名。第三,查看 secure 和站点的协议。第四,考虑 sameSite,因为标记为 lax 或 strict 的 Cookie 可能不随跨站请求发送。第五,确认过期时间在未来并且以秒为单位。第六,确保参数值是没有多余引号的有效 JSON,且文件路径是绝对路径。最后,使用 Puppeteer 时,从默认浏览器上下文读取 Cookie。
一份简短的复查记录能让配置保持可重复。为每个身份记下:
- Cookie 值的来源,例如在测试账号中记录的同意状态,或手工整理的文档;
- 分配给该身份的文件,以及与之配对的配置文件;
- 与目标站点
Set-Cookie行为相匹配的过期时间和标志; - 你如何验证预加载状态,以及使用的浏览器版本。
如果你在会话结束时导出 Cookie 以便复用,请注意框架返回的 Cookie 对象可能没有 url 字段。在把文件传给 --bot-cookies 之前,为每个对象添加一个,否则该 Cookie 会被跳过。只加载属于你自己测试账号或有文档记录的同意状态的 Cookie。提取、伪造或重放他人的会话 Cookie 不在这套配置的范围之内,而且站点无论如何都可能拒绝或使任何注入的 Cookie 过期。每当浏览器版本、配置文件或 Cookie 文件发生变化时,都要重复验证,因为当自上次成功运行以来只变了一项时,第一个请求中的差异最容易解释。
BotBrowser 适用于哪里,边界在哪里
BotBrowser 文档说明了如何在启动时用 --bot-cookies(PRO 级别)从内联 JSON 或 @ 文件加载 Cookie,并在第一次页面导航之前完成,使第一个 HTTP 请求就已携带它们,还说明了通过 botbrowserFlags 传入该参数时,如何把它们导入单个 BrowserContext,使团队能够为回访者和同意流程测试重复一个有文档记录的 Cookie 起始状态,并保持各身份相互分离。BotBrowser 不会改变 Cookie 规范或 SameSite 与 Secure 规则,会静默跳过缺少 url 字段的 Cookie,文档中没有说明预加载 localStorage 或 IndexedDB,也不保证目标站点会接受、保留任何注入的 Cookie,或把它与某个会话关联起来。
来源
实践结论
从请求入手而不是从参数入手:记录最早那个请求中的名称并与加载的文件对照,不要保留原始 Cookie 请求头。若该请求带有预期名称,顺序就是对的,框架调用足够;若没有,就把这组 Cookie 移到 --bot-cookies 并在启动时加载。把 Domain、Path、Secure、SameSite 和有效期与网站真正返回的 Set-Cookie 对照,因为属性写错的 Cookie 从不会发送,测试会悄悄测量成首次访问者。启动后要数 Cookie 数量而不要只信命令,缺少 url 字段会不报错地跳过一个,@ 之后要用绝对路径。每个身份一份文件并放在该身份 profile 旁边,复用的导出 Cookie 都要补上 url。记录值来源、属性、浏览器版本以及你如何验证预加载状态,这样下一次运行的差异就落在一个已知字段上,而不是同时变了几个。
本文属于指南: 浏览器身份表面:一致性实用总览