办公自动化里最难啃的从来不是 Excel,而是那个没有 API、只有网页、还带登录态的内部 OA。 这篇文章记录我用「宿主浏览器 + CDP 遥控」把一台致远 A8+(Seeyon A8+)协同平台的重复操作交给 Agent 的全过程: 密码是加密的、验证码要自己看、列表页藏在三层 iframe 里、表单是低代码渲染的。 下面 9 个坑全部来自真实调试,代码可直接抄。
场景很朴素,但每天都要做:
听起来 Excel 能干,问题是数据只在 OA 里。人工操作的完整链路是:
打开列表 → 翻页 → 找到目标行 → 点「修改」 → 等表单渲染 → 定位字段 → 改值 → 保存 → 关抽屉单条 30 秒,一千条就是一整天,而且这种活儿的错误率跟疲劳度成正比。
理想方案是走接口。现实是内部系统不给你 API 文档,但它在浏览器里怎么跑,你就能怎么跑——这就是这篇文章的全部技术路线。
第一反应当然是"起个无头浏览器,脚本里点一点"。我试了,两个死路:
死路 1:无头/有头 Chromium 打开的页面会自己清空。
这套低代码平台的前端脚本似乎做了环境检测。用独立浏览器打开后,页面加载完几秒内整个 DOM 变成:
<html><head></head><body></body></html>39 个字节。无头模式复现,有头模式也复现。你抓不到任何元素,因为页面把自己擦了。
死路 2:用现成的浏览器自动化 CLI,元素定位会"打架"。
CLI 工具在 connect 模式下,target 指针会在多个标签页之间漂移,open / eval / fill 三条命令可能命中三个不同的文档,元素的 ref 频繁失效。调试一晚上,收益是负的。
最终方案:宿主浏览器开调试端口,脚本用原生 WebSocket 直连 CDP。
# 在你自己的桌面终端里执行一次;独立配置目录,不动日常浏览器
microsoft-edge-stable \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.config/edge-agent" \
--no-first-run --no-default-browser-check// Node ≥ 22 自带全局 WebSocket,零依赖
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json();
const page = list.filter(t => t.type === 'page')
.find(t => t.url.includes('oa.example.internal'));
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0;
const send = (method, params = {}) => new Promise((res) => {
const myId = ++id;
const onMsg = (e) => {
const m = JSON.parse(e.data);
if (m.id === myId) { ws.removeEventListener('message', onMsg); res(m.result); }
};
ws.addEventListener('message', onMsg);
ws.send(JSON.stringify({ id: myId, method, params }));
});
const evaluate = async (expr, awaitPromise = false) => {
const r = await send('Runtime.evaluate',
{ expression: expr, returnByValue: true, awaitPromise });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text); // 别漏这行
return r.result.value;
};三个关键结论:

坑 1:
Runtime.evaluate返回undefined时,第一件事是看exceptionDetails。我曾在"为什么取不到值"上耗了半小时,结果只是表达式里一个未定义变量。
登录表单是普通的 POST。但 password 字段不是明文——前端用 CryptoJS.DES.encrypt() 处理过。
扒了一下密文格式,是 OpenSSL 的 Salted__ 结构:用 MD5 做一次 EVP_BytesToKey 派生 8 字节 key + 8 字节 IV,DES-CBC 加密,再 base64。种子字符串每次登录都会变,要么从页面全局变量读,要么调一次刷新接口拿新的。
复刻这段逻辑本身不难,坑在环境上:
坑 2:现代 OpenSSL 3 和 Node 都移除了 legacy DES,直接调会报
error:0308010C:digital envelope routines::unsupported。 解法:node --openssl-legacy-provider,或者自己手写 EVP_BytesToKey。
// des-encrypt.mjs —— node --openssl-legacy-provider des-encrypt.mjs "明文" "种子"
import crypto from 'node:crypto';
const [plain, seed] = process.argv.slice(2);
// OpenSSL 的 EVP_BytesToKey(MD5, 1 次迭代):用"种子+盐"反复 MD5 凑够 key 和 iv
const salt = crypto.randomBytes(8);
const d = [];
let prev = Buffer.alloc(0);
while (Buffer.concat(d).length < 16) {
prev = crypto.createHash('md5')
.update(Buffer.concat([prev, Buffer.from(seed), salt])).digest();
d.push(prev);
}
const keyiv = Buffer.concat(d);
const cipher = crypto.createCipheriv('des-cbc', keyiv.subarray(0, 8), keyiv.subarray(8, 16));
const out = Buffer.concat([Buffer.from('Salted__'), salt,
cipher.update(Buffer.from(plain, 'utf8')), cipher.final()]);
console.log(out.toString('base64'));连续失败几次后,页面弹出 4 位图形验证码。
这里有个思路转换:不要让脚本去识别验证码,让脚本把图截给你。在页面上下文里 fetch 图片接口、FileReader 转 base64,存成文件,然后我自己打开看一眼,把 4 位数字填回去。数字很清晰,人眼比 OCR 快也更准,而且不引入外部服务。
// 必须在页面上下文里执行;由于用到 Promise,evaluate 时要 awaitPromise: true
const r = await send('Runtime.evaluate', {
expression: `(async () => {
const res = await fetch('/verifyCodeImage.jpg?v=' + Date.now());
const blob = await res.blob();
return await new Promise(ok => {
const fr = new FileReader();
fr.onload = () => ok(fr.result.split(',')[1]); // base64
fr.readAsDataURL(blob);
});
})()`,
awaitPromise: true, returnByValue: true
});
require('node:fs').writeFileSync('captcha.png', Buffer.from(r.result.value, 'base64'));坑 3:验证码图片的 src 带时间戳
?v=<ts>,不要缓存,每次重新取;否则你识别出来的永远是上一张。
登录成功的判据不是"页面跳转了",而是标题变成了 "...协同管理软件 V8.2SP1, 某某,您好!"——用一个稳定的文本锚点做判据,比判断 URL 可靠。
登录后的门户页 HTML 有 300 多 KB,里面内嵌了完整菜单 JSON:
{"url":"/cap4/businessTemplateController.do?method=...","name":"..."}既然地址都在源码里,就没必要模拟点击导航。正则抽出来落成本地映射,后面所有跳转都直接 Page.navigate:
import re, json, requests
html = session.get(PORTAL_URL).text
menus = re.findall(r'\{"url":"(/cap4/[^"]+)","name":"([^"]+)"\}', html)
open('menus.json', 'w', encoding='utf8').write(
json.dumps(dict(menus), ensure_ascii=False, indent=2))
print(f'抓到 {len(menus)} 个业务列表入口')坑 4:别用"点开菜单 → 逐个记录链接"这种自举方式。菜单树有 hover 延迟和懒加载,某些二级项要展开才生成 DOM。从源码里静态提取,一次搞定,还能 diff 出菜单变更。
列表页的外壳 HTML 只有 5 KB。真实数据在 frames[0] 里,1.3 MB,200 行全部在 DOM 中(它不是分页请求,是整页渲染后前端分页)。
进去一数,页面上有 三张表:
序号 | 内容 |
|---|---|
| 表头,1 行 |
| 数据行,200 行 |
| 操作按钮行,200 行 |

这就带来全文最好笑的一个换算关系:
文档序 TR 索引:表头 TR0,数据 TR1..TR200,操作 TR201..TR400
所以:操作行 TR 索引 = 201 + (数据行 TR 索引 − 1)数据行自己的「操作」单元格是空 <span>,按钮全在 table[2] 里。想点第 N 行的"修改",得先算到 table[2] 的第 N 个 tr。我第一次找按钮找了一小时,就因为一直在数据行里翻。
// 取全部数据行文本(与表头一一对应)
const doc = frames[0].document;
const rows = [...doc.querySelectorAll('tr.v-easy-table-row')];
const texts = rows.map(tr => tr.innerText.split('\n'));
console.log(texts.length, '行'); // 200坑 5:记录 id ≠ 行号。行号是视图顺序,记录 id 是数据主键。要从行内往上找渲染组件对象,读它 props 上的
rowData.id,别拿行号当 id 去拼接口。 坑 6:列表总共 381 条、分 2 页。抓完第一页记得翻页或改 URL 的分页参数再抓第二页,否则你会以为数据只有 200 条。
点「修改」不会跳新页面,而是在右侧滑出一个抽屉(ant-drawer,宽 1230px),里面是嵌套 iframe:

主文档
└── frames[0] 列表页(数据在这)
└── iframe#targetFrame 抽屉里的表单外壳
└── 再嵌一层 form 页面 真正的表单 DOM ★ 字段在这里取第三层 frame 的方法:
const L3 = () => frames[0].document
.getElementById('targetFrame').contentWindow
.document.querySelector('iframe').contentWindow;
// 等它渲染出来:轮询 L3().document.querySelectorAll('input,textarea').length > 0|,选择器直接报错字段容器的 class 长这样:cap-field formmain_1399|field0152。
// ❌ 抛 "not a valid selector",因为 | 在 CSS 里是命名空间分隔符
d.querySelector('.cap-field.formmain_1399|field0152');
// ✅ 用属性包含匹配
d.querySelector('[class*="field0152"]');表单是低代码渲染的 Vue 组件,input.value = 'x' 之后框架的 model 完全不知道你改过,保存下去还是旧值。必须走原生 setter + 事件:
const w = L3();
const inp = w.document.querySelector('[class*="field0152"] input');
const setter = Object.getOwnPropertyDescriptor(
w.HTMLInputElement.prototype, 'value').set;
setter.call(inp, '新值');
for (const type of ['input', 'change', 'blur']) {
inp.dispatchEvent(new w.Event(type, { bubbles: true }));
}校验方法:往组件树上方找 __vue__,看 $props.data.value 是不是已经变成新值。变了才算写进去了,再去点保存。
保存按钮不是 <button> 而是个 <span>,得先按文本内容找到它、再往上找最近的 button:
const btn = [...w.document.querySelectorAll('span')]
.filter(e => e.children.length === 0 && e.innerText.trim() === '保存并关闭')
.map(e => e.closest('button') || e)[0];
btn.click();成功标志:出现 POST /seeyon/rest/cap4/form/saveOrUpdate 且返回 200,抽屉自动关闭、列表自动刷新。
window.open这是我花时间最久、也最值得单独拎出来讲的一个。
我当时想"拦截新窗口,好观察弹出来的页面",于是给 window.open 打了个补丁:拦截、记录、返回假对象。结果表现是——
点「修改」,毫无反应。
抽屉不出来,控制台没报错,网络没请求。所有精力都在排查"按钮是不是没绑事件""是不是权限不够""是不是这个账号不能改"。真正的答案是:抽屉流程内部真的用 window.open,我的补丁把它吞掉了。
更阴的是它的次生现象。因为补丁有时又失效(换了路径打开),会出现"独立标签页"形态,在那里你能看到:
window.opener === null
getA8ParentWindow() → Cannot read properties of undefined (reading 'isCtpTop')这两条看起来特别像"缺了宿主上下文所以打不开表单",于是你更加确信是上下文问题——其实它只是"独立标签页"的正常现象,跟抽屉流程毫无关系。
坑 9(最重要):调试时不要改宿主 API。 想观测就只读地包一层、原样转发: const orig = window.open; window.open = function (...args) { console.log('open', args0); return orig.apply(this, args); }; 而不是替换掉行为。一旦改成
return null这种"假实现",你会亲手制造一个不存在的问题,然后花几小时去修它。
补丁的清理也提醒一下:单纯恢复引用不够,页面里可能已经缓存了旧函数,最干净的办法是 Page.reload。
前面都是"怎么做到",这一段是"做到之后怎么不出事"。
跑通一次只是开始。真正让成本降下来的是把整条链路固化成技能文档:
下次的需求变成一句话:「把档案表里 A、B、C 的某个字段改成 X 并复核」。第一次做花了一整晚,第二次花了几分钟。
配套的三个习惯:
习惯 | 说明 |
|---|---|
判据用文本锚点 | 别用 URL 判断登录/加载完成,用页面里的稳定文本 |
观测不改行为 | 包一层转发,别替换宿主 API |
写前备份、写后复核 | 顺序不能反,也不能省 |
难点 | 表象 | 解法 |
|---|---|---|
页面自我清空 | DOM 变 39 字节空文档 | 宿主浏览器 + CDP 遥控,不用无头 |
密码加密 | 密文是 | 复刻 EVP_BytesToKey, |
图形验证码 | 脚本识别不稳定 | 截图给人看,4 位数字人眼更快 |
数据取不到 | 外壳只有 5 KB | 数据在 |
按钮点不到 | 数据行里没有按钮 | 三张表结构, |
写值不生效 | 保存后还是旧值 | 原生 setter + input/change/blur |
点按钮没反应 | 毫无报错 | 别 monkeypatch |
一句话总结:内部 OA 的自动化,难点不在"能不能点",而在"你以为的问题是不是真问题"。
先把环境路线选对(宿主浏览器 + CDP),再用"读日志、不加补丁"的姿势去观测,剩下的都是耐心活。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。