首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用 Agent 自动操作内部 OA:从加密登录到低代码表单写入,我踩的 9 个坑

用 Agent 自动操作内部 OA:从加密登录到低代码表单写入,我踩的 9 个坑

原创
作者头像
用户12816612
发布于 2026-10-14 11:37:53
发布于 2026-10-14 11:37:53
140
举报

用 Agent 自动操作内部 OA:从加密登录到低代码表单写入,我踩的 9 个坑

办公自动化里最难啃的从来不是 Excel,而是那个没有 API、只有网页、还带登录态的内部 OA。 这篇文章记录我用「宿主浏览器 + CDP 遥控」把一台致远 A8+(Seeyon A8+)协同平台的重复操作交给 Agent 的全过程: 密码是加密的、验证码要自己看、列表页藏在三层 iframe 里、表单是低代码渲染的。 下面 9 个坑全部来自真实调试,代码可直接抄。


〇、先说清楚要解决什么

场景很朴素,但每天都要做:

  • 核对:一张上千条的档案表,逐条比对某个字段对不对;
  • 修改:把这批人里某几个人的某个字段改成新值;
  • 导出:把列表按条件捞出来做统计。

听起来 Excel 能干,问题是数据只在 OA 里。人工操作的完整链路是:

代码语言:plaintext
复制
打开列表 → 翻页 → 找到目标行 → 点「修改」 → 等表单渲染 → 定位字段 → 改值 → 保存 → 关抽屉

单条 30 秒,一千条就是一整天,而且这种活儿的错误率跟疲劳度成正比。

理想方案是走接口。现实是内部系统不给你 API 文档,但它在浏览器里怎么跑,你就能怎么跑——这就是这篇文章的全部技术路线。


一、路线选择:别用无头浏览器,用「宿主浏览器 + 遥控」

第一反应当然是"起个无头浏览器,脚本里点一点"。我试了,两个死路:

死路 1:无头/有头 Chromium 打开的页面会自己清空。

这套低代码平台的前端脚本似乎做了环境检测。用独立浏览器打开后,页面加载完几秒内整个 DOM 变成:

代码语言:plaintext
复制
<html><head></head><body></body></html>

39 个字节。无头模式复现,有头模式也复现。你抓不到任何元素,因为页面把自己擦了。

死路 2:用现成的浏览器自动化 CLI,元素定位会"打架"。

CLI 工具在 connect 模式下,target 指针会在多个标签页之间漂移,open / eval / fill 三条命令可能命中三个不同的文档,元素的 ref 频繁失效。调试一晚上,收益是负的。

最终方案:宿主浏览器开调试端口,脚本用原生 WebSocket 直连 CDP。

代码语言:bash
复制
# 在你自己的桌面终端里执行一次;独立配置目录,不动日常浏览器
microsoft-edge-stable \
  --remote-debugging-port=9222 \
  --user-data-dir="$HOME/.config/edge-agent" \
  --no-first-run --no-default-browser-check
代码语言:javascript
复制
// 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. 登录态留在用户自己的浏览器里——不用把账号密码交给脚本之外的地方,也不碰风控;
  2. 窗口在你屏幕上可见,出问题你能立刻看见,随时接管;
  3. 按 URL 挑标签页,不要抢占用户正在用的其他标签。
图1:两条路线对比与运行时数据流
图1:两条路线对比与运行时数据流

坑 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。

代码语言:javascript
复制
// 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'));

验证码:让"自己看图"比接 OCR 更省事

连续失败几次后,页面弹出 4 位图形验证码。

这里有个思路转换:不要让脚本去识别验证码,让脚本把图截给你。在页面上下文里 fetch 图片接口、FileReader 转 base64,存成文件,然后我自己打开看一眼,把 4 位数字填回去。数字很清晰,人眼比 OCR 快也更准,而且不引入外部服务。

代码语言:javascript
复制
// 必须在页面上下文里执行;由于用到 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:

代码语言:plaintext
复制
{"url":"/cap4/businessTemplateController.do?method=...","name":"..."}

既然地址都在源码里,就没必要模拟点击导航。正则抽出来落成本地映射,后面所有跳转都直接 Page.navigate:

代码语言:python
复制
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 中(它不是分页请求,是整页渲染后前端分页)。

进去一数,页面上有 三张表:

序号

内容

table[0]

表头,1 行

table[1]

数据行,200 行

table[2]

操作按钮行,200 行

图2:列表页三张表与行号换算
图2:列表页三张表与行号换算

这就带来全文最好笑的一个换算关系:

代码语言:plaintext
复制
文档序 TR 索引:表头 TR0,数据 TR1..TR200,操作 TR201..TR400
所以:操作行 TR 索引 = 201 + (数据行 TR 索引 − 1)

数据行自己的「操作」单元格是空 <span>,按钮全在 table[2] 里。想点第 N 行的"修改",得先算到 table[2] 的第 N 个 tr。我第一次找按钮找了一小时,就因为一直在数据行里翻。

代码语言:javascript
复制
// 取全部数据行文本(与表头一一对应)
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 条。


五、第四关:写操作是"抽屉 + 三层 frame"

点「修改」不会跳新页面,而是在右侧滑出一个抽屉(ant-drawer,宽 1230px),里面是嵌套 iframe:

图3:四层嵌套结构与改值四步
图3:四层嵌套结构与改值四步
代码语言:plaintext
复制
主文档
└── frames[0]                       列表页(数据在这)
    └── iframe#targetFrame          抽屉里的表单外壳
        └── 再嵌一层 form 页面        真正的表单 DOM  ★ 字段在这里

取第三层 frame 的方法:

代码语言:javascript
复制
const L3 = () => frames[0].document
                         .getElementById('targetFrame').contentWindow
                         .document.querySelector('iframe').contentWindow;
// 等它渲染出来:轮询 L3().document.querySelectorAll('input,textarea').length > 0

坑 7:类名里有 |,选择器直接报错

字段容器的 class 长这样:cap-field formmain_1399|field0152。

代码语言:javascript
复制
// ❌ 抛 "not a valid selector",因为 | 在 CSS 里是命名空间分隔符
d.querySelector('.cap-field.formmain_1399|field0152');
// ✅ 用属性包含匹配
d.querySelector('[class*="field0152"]');

坑 8:Vue 受控输入,直接赋值不生效

表单是低代码渲染的 Vue 组件,input.value = 'x' 之后框架的 model 完全不知道你改过,保存下去还是旧值。必须走原生 setter + 事件:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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,抽屉自动关闭、列表自动刷新。


六、最大的坑:千万不要 monkeypatch window.open

这是我花时间最久、也最值得单独拎出来讲的一个。

我当时想"拦截新窗口,好观察弹出来的页面",于是给 window.open 打了个补丁:拦截、记录、返回假对象。结果表现是——

点「修改」,毫无反应。

抽屉不出来,控制台没报错,网络没请求。所有精力都在排查"按钮是不是没绑事件""是不是权限不够""是不是这个账号不能改"。真正的答案是:抽屉流程内部真的用 window.open,我的补丁把它吞掉了。

更阴的是它的次生现象。因为补丁有时又失效(换了路径打开),会出现"独立标签页"形态,在那里你能看到:

代码语言:plaintext
复制
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。


七、写操作的安全纪律(比技术细节更重要)

前面都是"怎么做到",这一段是"做到之后怎么不出事"。

  1. 只改明确指定的字段和值。 让 Agent 自己判断"该改什么"是危险的。取值、改值、目标人都要显式给。
  2. 改前必须记录原值,改后必须复核。 复核方式就是重开一次抽屉把字段读回来——不该信任"接口返回 200"。200 只说明请求通了,不说明业务成功(可能被校验拦下、可能静默忽略)。
  3. 写操作的最后一步交给人。 我的流程是:Agent 把"准备改哪一条、字段、原值→新值"列出来,人确认后才落库。
  4. 会话会中途失效。 长时间空闲后再导航,会跳回登录页。要能识别登出(页面文本里出现"找回密码""二维码登录"这类锚点),并自动重登后继续,而不是对着一个空页面反复取元素。
  5. 别把"能写"当默认能力。 这套流程最大的风险不是技术失败,是成功地改错了东西。

八、沉淀:把这套东西写成一份"说明书"

跑通一次只是开始。真正让成本降下来的是把整条链路固化成技能文档:

  • 路线选择(为什么不用无头)、登录加密细节、验证码处理、三层 frame 取法、字段定位与写值模板、保存与复核;
  • 加上那 9 个坑的现象 → 原因 → 解法;
  • 加上一批可复用的脚本模板。

下次的需求变成一句话:「把档案表里 A、B、C 的某个字段改成 X 并复核」。第一次做花了一整晚,第二次花了几分钟。

配套的三个习惯:

习惯

说明

判据用文本锚点

别用 URL 判断登录/加载完成,用页面里的稳定文本

观测不改行为

包一层转发,别替换宿主 API

写前备份、写后复核

顺序不能反,也不能省


九、小结

难点

表象

解法

页面自我清空

DOM 变 39 字节空文档

宿主浏览器 + CDP 遥控,不用无头

密码加密

密文是 Salted__ 结构

复刻 EVP_BytesToKey,--openssl-legacy-provider

图形验证码

脚本识别不稳定

截图给人看,4 位数字人眼更快

数据取不到

外壳只有 5 KB

数据在 frames[0],1.3 MB

按钮点不到

数据行里没有按钮

三张表结构,操作行 = 201 + (数据行 − 1)

写值不生效

保存后还是旧值

原生 setter + input/change/blur

点按钮没反应

毫无报错

别 monkeypatch window.open

一句话总结:内部 OA 的自动化,难点不在"能不能点",而在"你以为的问题是不是真问题"。

先把环境路线选对(宿主浏览器 + CDP),再用"读日志、不加补丁"的姿势去观测,剩下的都是耐心活。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 用 Agent 自动操作内部 OA:从加密登录到低代码表单写入,我踩的 9 个坑
    • 〇、先说清楚要解决什么
    • 一、路线选择:别用无头浏览器,用「宿主浏览器 + 遥控」
    • 二、第一关:密码是加密的,而且用的是"过时"的算法
      • 验证码:让"自己看图"比接 OCR 更省事
    • 三、第二关:不要点菜单,直接读菜单
    • 四、第三关:列表数据的"三张表"陷阱
    • 五、第四关:写操作是"抽屉 + 三层 frame"
      • 坑 7:类名里有 |,选择器直接报错
      • 坑 8:Vue 受控输入,直接赋值不生效
    • 六、最大的坑:千万不要 monkeypatch window.open
    • 七、写操作的安全纪律(比技术细节更重要)
    • 八、沉淀:把这套东西写成一份"说明书"
    • 九、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档