起因:常规浏览器自动化在这个域名下直接失效
我平时驱动浏览器用的是一套基于扩展的方案,底层走 chrome.debugger API。它在绝大多数站点都好用,但在 Chrome 应用商店开发者后台上,第一条命令就被拒了:
CDP error -32000: Not allowed
域名是 chrome.google.com 和 chromewebstore.google.com。
这里要说清楚一件事,否则很容易得出错误结论:这不是"这个站点禁止自动化",而是 Chrome 禁止扩展的 chrome.debugger API 附加到 Google 自己的这几个高权限域名上。 这是对扩展 API 的域名级限制,防的是恶意扩展提权。换一条不经过扩展的路径------从外部用裸 CDP 连一个独立启动的 Chrome------完全正常。
所以方案就确定了:单独起一个带远程调试端口的 Chrome,用原始 CDP 协议驱动它。
一、启动:一个独立进程 + 一个全新的空 profile
bash
"C:\Program Files\Google\Chrome\Application\chrome.exe" \
--remote-debugging-port=9333 \
--user-data-dir="D:\tmp\cdp-profile"
两个参数都是必须的,而且 --user-data-dir 必须指向一个全新的、空的目录。
很多教程会教你把它指向自己日常 Chrome 的 profile 目录,好处是"省得重新登录"。不要这么做,两个原因:
- 两个 Chrome 进程同时操作同一个 profile 目录,有损坏 profile 的风险。 你的书签、密码库、扩展状态都在里面,为了省一次登录去赌这个,不划算。
- 更重要的是安全边界。 后面会展开:调试端口等于把这个浏览器的完全控制权敞开在本机。敞开一个只登录了一个开发者账号的空浏览器,和敞开一个装着你全部身份的日常浏览器,是两件完全不同的事。
顺带说一句,较新版本的 Chrome 已经直接禁止对默认用户数据目录启用远程调试了------你把 --remote-debugging-port 指向默认 profile,它会静默忽略这个参数。这个改动本身就说明了 Google 怎么看待这个风险。
代价是新窗口什么都没登录。登录这一步交给人做,别自己去填账号密码------这是原则问题,不是能力问题。等用户在那个窗口登录完开发者账号,再导航到后台继续。
二、下手之前先确认端口是谁的
9333 这种顺手的端口很容易撞车:上一次会话残留的进程、别的自动化工具管理的浏览器,都可能已经在监听。这时候你以为在操作自己的窗口,实际在操作别人的。
bash
curl http://127.0.0.1:9333/json/list
看返回里 tab 的 title 和 url 是不是你预期的那个,确认了再动手。这一步只花两秒,但省掉的是"我刚才到底改了谁的页面"这种问题。
三、裸 CDP 的最小骨架
CDP 就是一个 JSON-RPC over WebSocket:GET /json/list 拿到目标列表,挑出你要的那个 tab,连它的 webSocketDebuggerUrl,然后发消息、按 id 收响应。
js
// cdp.mjs --- node cdp.mjs <eval|nav|text> [arg]
const PORT = process.env.CDP_PORT || 9333;
const MATCH = process.env.CDP_MATCH || 'devconsole';
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
const t = list.find(x => x.type === 'page' && x.url.includes(MATCH))
|| list.find(x => x.type === 'page');
if (!t) { console.log('NO TARGET'); process.exit(1); }
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0; const pend = new Map();
const send = (m, p = {}) => new Promise((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ id: i, method: m, params: p }));
});
ws.onmessage = e => {
const m = JSON.parse(e.data);
if (m.id && pend.has(m.id)) {
const p = pend.get(m.id); pend.delete(m.id);
m.error ? p.rej(new Error(JSON.stringify(m.error))) : p.res(m.result);
}
};
const evalx = async (expr) => {
const r = await send('Runtime.evaluate', {
expression: expr, returnByValue: true, awaitPromise: true, userGesture: true
});
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval error');
return r.result.value;
};
ws.onopen = async () => {
const [cmd, ...rest] = process.argv.slice(2);
const arg = rest.join(' ');
try {
if (cmd === 'nav') { await send('Page.navigate', { url: arg });
await new Promise(r => setTimeout(r, 4000));
console.log('url:', await evalx('location.href')); }
else if (cmd === 'text') { console.log(await evalx(`document.body.innerText.slice(0,${arg || 4000})`)); }
else if (cmd === 'eval') { const v = await evalx(arg);
console.log(typeof v === 'string' ? v : JSON.stringify(v, null, 1)); }
} catch (e) { console.log('ERR:', e.message); }
ws.close(); process.exit(0);
};
setTimeout(() => { console.log('timeout'); process.exit(1); }, 40000);
一个实践建议:把要执行的 JS 写成 .mjs 脚本用 node 跑,不要在 shell 里拼几百个字符的表达式。 所有用户提供的文本(描述、隐私说明)都用 JSON.stringify() 序列化进去,别手工转义引号------手工转义是这类脚本最常见的翻车点。
userGesture: true 值得单独说:有些按钮的处理逻辑要求"来自用户手势",加上这个参数 CDP 才会把 Runtime.evaluate 标记成用户触发。
四、Web Store 表单的三个真坑
坑 1:标题和摘要不是输入框,是从 manifest 读的
控制台里那两个字段标着 "Title from package" / "Summary from package"。它们不可编辑。
要改标题或短描述的 SEO 措辞,只能改 manifest.json、重新打包、走 "Upload new package" 重传。别在页面上翻找输入框------没有。
坑 2:React 输入框必须用原生 setter
直接 el.value = text 对 React 受控组件是无效的:DOM 上看着变了,框架的状态没变,保存时写回去的还是旧值。而且这个失败是静默的,页面上看起来完全正常。
js
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
setter.call(textarea, text);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
textarea.dispatchEvent(new Event('blur', { bubbles: true }));
input 是给 React 的,change 和 blur 是给各种校验/自动保存逻辑的,三个都要发。
mat-select 这类下拉:点开 → 等大约 1.2 秒让选项列表渲染 → 按选项文本精确或包含匹配点击 mat-option / [role=option]。第一次跑一个陌生下拉时,让脚本在匹配不到的时候把所有可选项打印出来,比截图找选项快得多。
坑 3:单选按钮必须按 label 文本匹配
隐私页有个"是否使用远程代码"的 Yes/No。用一个宽泛的 input[type=radio] 选择器去点第 N 个------我这边连续点错过好几次,本意选 No,实际选中了 Yes。
正确做法是按可见 label 文本匹配(比如包含 "No, I am not using remote code"),点完之后再读回 {value, checked} 验证,而不是相信"点击返回成功"。
这条其实可以推广成一条通则:凡是"选错了也不会报错"的控件,都必须点完回读。
五、隐私页的四件事
- Single purpose description 和 host permission justification :各 ≤1000 字符,用上面的原生 setter 写。页面上通常正好两个
textarea[maxlength="1000"],顺序是单一用途在前、权限说明在后------但先按数量确认一遍再按下标写。 - Remote code 单选:见坑 3。
- 12 个数据收集类型复选框 :零数据收集的扩展全部不勾。别默认它们本来就是空的------数一遍已勾选数量是否为 0。
- 隐私政策 URL:普通文本框,正常填。
六、硬停止:最后三个复选框和 Submit 不要自动点
表单末尾有三个认证复选框(不出售数据 / 不用于无关用途 / 不用于信用评估)和一个 Submit for review。
我的规则是:这四个控件一律不由脚本点。
原因不是技术上做不到,是这一步的性质变了。点下去之后,条目进入 Google 的审核队列;而且提交弹窗里那个"审核通过后自动发布"的复选框默认是勾上的------也就是说,一次随手的提交会把"上架前最后一次人工确认"这个环节直接跳过去。
把其他所有字段填满,让条目停在 Draft 状态,然后明确告诉用户还剩哪几步需要他手动完成。填完表单不等于获得了提交授权。
另外:控制台里没有删除草稿条目的功能,只有溢出菜单里的 "Archive..."。如果不小心点 "New item" 建出了重复条目(我干过),归档是唯一的清理方式,而且归档前也该问一句。
七、风险清单:开一个调试端口到底意味着什么
这部分才是这篇文章真正想说的。
远程调试端口是一个没有任何认证的完全控制接口。 任何能访问 127.0.0.1:9333 的本地进程,不需要密码、不需要授权、不需要用户点确认,就可以:
- 在任意已打开页面里执行任意 JavaScript
- 读取这些页面的 DOM、localStorage、sessionStorage
- 通过
Network域读取请求和响应,包括 Cookie 头 - 任意导航、开新标签页、下载文件
- 以你的登录身份发起任何站内操作
换句话说,端口开着的这段时间,这个浏览器里所有登录态都等于是公开的------对本机任何进程而言。
具体到这个场景,那个浏览器里登录的是 Chrome Web Store 开发者账号:一个能修改、能发布扩展给成千上万用户的账号。这是个高价值目标。
几条具体的:
| 风险 | 说明 | 怎么办 |
|---|---|---|
| 本地进程完全接管 | 端口无认证,任何本地程序可连 | 只在任务期间开,任务结束立刻关进程 |
| 日常 profile 暴露 | 如果指向真实 profile,暴露的是你全部身份 | 永远用全新空目录;新版 Chrome 也已禁止对默认目录开调试 |
| profile 损坏 | 两个 Chrome 进程共用一个目录 | 同上 |
| 端口撞车 | 你以为在操作自己的窗口 | 动手前 GET /json/list 核对 title/url |
| 残留 profile 目录 | 目录里存着开发者账号的会话 Cookie | 任务结束后删掉整个 cdp-profile 目录 |
| 端口忘了关 | 任务结束进程还在跑,端口一直开着 | 收尾时确认进程已退出、端口已释放 |
| 网络暴露 | 默认只绑 127.0.0.1 | 绝不要 加 --remote-debugging-address=0.0.0.0,那等于把浏览器控制权开到局域网 |
还有一点容易被忽略:调试端口并不受同源策略约束。 页面里的 JS 访问不到它(Chrome 对 Host 头做了校验来防 DNS rebinding),但本机上的任何普通程序都能直连。所以威胁模型里的"攻击者"不是网页,是本机上你没审计过的那些进程。
一套最小化的收尾动作
bash
# 1. 确认进程退出
taskkill /F /IM chrome.exe /FI "COMMANDLINE eq *9333*" # 或直接关掉那个窗口
# 2. 确认端口释放
curl http://127.0.0.1:9333/json/list # 应该连不上
# 3. 删掉带登录态的临时 profile
rmdir /S /Q D:\tmp\cdp-profile
第 3 步最容易忘,但它是三步里最重要的:那个目录里躺着一份你的开发者账号会话。
小结
- 扩展方案在 Google 自家高权限域名上被封,是对
chrome.debugger的域名级限制,不是"此处禁止自动化";裸 CDP 可以绕开。 - 独立进程 + 全新空 profile,不要碰日常 profile。
- 动手前核对端口归属,动手时用脚本文件而不是 shell 拼串。
- 三个真坑:标题来自 manifest、React 输入要原生 setter、单选按钮按 label 匹配并回读。
- 最后的认证复选框和 Submit 不自动点------因为"自动发布"默认勾选,这一步跳过的是人工确认。
- 调试端口 = 无认证的完全控制权。用完就关,profile 目录记得删。
自动化能省掉的是重复劳动,省不掉的是判断。把不可逆的那一下留给人,是这套流程里唯一不能妥协的设计。