◍ 我的收藏箱自己攒的东西,自己说了算
← 技术 ⚙️ 技术 2026-10-13 19:10 · admin

TaskOA 报销单核验:完全接手手册

登录(快捷登录+保活+重启不掉会话实测)、必须POST的接口、「接口要登录≠所有URL都要登录」、判据只看金额一致且申请人无关、「无法核实」也要拦、按凭证类型核对、读图铁律(手写体陷阱)、保活自愈六步(拉起进程≠服务可用)、空跑清副作用、按单号精确勾选、完成后交叉对账、桌面弹框用ctypes点、身份不靠推断

TaskOA 报销单核验:完全接手手册

给任何接手者的完整上手文档。从「这是什么、怎么进」开始,一路写到「怎么核、怎么提交」。

0. 这是什么系统

项值
名称TaskOA(在线 OA / 办公系统)
类型托管 SaaS —— 代码在厂商服务器,拿不到源码,没有 API 文档
地址https://x.taskoa.com
用途公司报销单的提交与审核,另含任务工单、代播、货款核对等模块
技术栈服务端未知;前端是 jQuery 1.12.4 + EasyUI 的老式架构

先记住一条:因为是托管系统,「从底层看代码」通常做不到。
能做的只有——通过浏览器自动化去读页面和接口。能读到接口就已经是运气好。


1. 怎么登录

1.1 打开

浏览器访问 https://x.taskoa.com,未登录会跳到 /public/login。

1.2 三种登录方式

登录页上有三条路,按方便程度排序:

方式 A · 微信快捷登录 ★推荐(不用手机)

  1. 在微信登录页上点「快捷登录」入口(扫码页右下角的文字链接)
  2. 页面变成「使用微信快捷登录『XXX』」+ 你的微信头像和昵称 + 一个绿色按钮
  3. 点「微信快捷登录」
  4. 本机微信客户端弹出原生授权框:

> 「XXX 申请使用 你的昵称、头像」 [头像] [允许] [拒绝]

  1. 点「允许」→ 自动跳回系统首页 → 登录完成

机制(实测抓到的):页面会探测本机微信客户端的本地端口
(依次试 14013 14014 14015 13013 13014 13015),
连上后向它发 authorize 请求,由客户端弹框确认。全程不碰手机。

⚠️ 三个坑:

  • 那个「允许/拒绝」是微信客户端的原生窗口,不在浏览器里,CDP 点不到

→ 又快又省事的做法是让人点一下;
确实需要自动化时,可以用 Python ctypes 点它 —— 完整方法见 §8

  • 若看到 errcode 10050 / 拒绝登录,不是真失败,那是「需要用户确认」,紧接着原生框就会弹出
  • 快捷登录视图和扫码视图在同一个页面上各渲染一份,同名元素有两套。

程序化定位时必须过滤 rect.width > 0,否则会点到隐藏那份(点了没反应)

分工参考:AI 可以全自动完成「打开登录页 → 切快捷登录视图 → 点『微信快捷登录』→ 触发微信弹框」,<br>卡点只在最后那个「允许」。所以重登 = AI 触发 + 使用者点一下(或 §8 的 ctypes 方案)。
⚠️ 微信不记住这次授权。 实测(连续两天重登):<br>每次会话过期后重登,那个「允许」框都会重新弹一次,不会因为「上次点过允许」就免掉。<br>所以别指望「授权一次之后就能全自动」——要免掉这一步,只能靠 §1.5 的保活。

方式 B · 微信扫码(手机扫)

  • 登录页点「使用微信扫码登陆」→ 跳到微信开放平台的二维码页 → 手机扫码
  • ⚠️ 那个链接带 target="_blank",在内嵌浏览器里会被甩到外部浏览器。

绕法:别点链接,直接把地址栏指向 /h5/we-chat/code-url?type=2(同窗口打开)

  • 二维码页没有 X-Frame-Options,理论上能被 iframe 内嵌;但不推荐 ——

扫码后微信会 302 跳回本站,属于跨站导航,会话 cookie 若是 SameSite=Lax
在 iframe 里种不上,会出现「扫了但没登进去」

  • 另有一条「使用微信手机端直接登陆」(/h5/we-chat/pub-login),

要求在手机微信里打开链接,PC 上用不了

方式 C · 账号 + 密码

  • 账号形如 <账套编号>:<账套名>(编号是纯数字,冒号后是账套/公司简称)
  • 登录表单填的用户名是使用者姓名,不是那串编号
  • 账号和密码由实际使用者提供,不写在本文件里

❌ 页面上另外两个入口是死的,别试:
「记住我」和「短信验证登录」都被注释掉了(<!-- --> 包着),不能用。

1.3 登录后第一件事:确认你是谁

这一步不能省。 系统里有两类角色,搞错会做出完全错误的判断:

角色特征
审核人能看到列表里所有单据
申请人主要看到自己提交的单据

判断方法:看页面右上角显示的用户名。
⚠️ 不要根据「列表里单据的申请人是谁」来推断当前登录者——那是别人的名字。

我犯过这个错:看到列表里所有单的申请人都是同一个人,就断定「当前登录的是申请人,不该执行审核」,<br>结果错误地拒绝了使用者的合理请求。界面右上角明明白白写着我是审核人。

1.4 ★ 会话会自动续期(12 小时)

实测:每一次请求都会把登录 cookie 重置为「当前时间 + 12 小时」。
连续访问三次,到期时间依次往后推,每次都跟着访问时间走。

推论:

情况结果
浏览器不关 + 定期有访问登录态不会过期(挂个保活就行,可以很久不用重登)
关掉浏览器进程会话 cookie 可能丢,也可能不丢 —— 取决于 Chrome 的会话恢复设置(见下)
关标签页完全不影响
重启电脑★ 实测没丢(见下)

所以让登录不掉的关键只有一条:别关那个浏览器。
另外可以据此判断会话还剩多久 —— 读 cookie 的 expires 就行,比猜靠谱。

★ 更正一条我原先写死的结论(2026-10-18 实测)<br><br>我原来说「关掉浏览器进程,会话 cookie 随之消失,需要重新登录」。<br>重启电脑实测:advanced-frontend(会话 cookie)没丢,_identity-frontend 也还在。<br>重新拉起浏览器后直接就登录着,接口正常返回,一次都没重登。<br><br>原因:Chrome 若开启了「继续浏览上次打开的网页」,会把会话 cookie 一并恢复。<br><br>→ 别预设"重启一定要重登"。先拉起浏览器查一下,很可能还活着。<br>这个预设一旦写进文档,接手的人会去做一堆没必要的登录操作。

1.5 会话过期后怎么重登(照这个顺序做)

会话过期的表现:请求被 302 到 /public/login,cookie 里 _identity-frontend 消失。

重登三步:

步骤谁做
1. 打开 /h5/we-chat/code-url?type=2,切到「快捷登录」视图,点「微信快捷登录」✅ AI 可自动
2. 微信弹出「允许/拒绝」框 → 点「允许」人工点一下(或 §8 的 ctypes)
3. 自动跳回系统、会话建立✅ 自动

验证是否登录成功:在页面上下文发一个请求看会不会被重定向

const r = await fetch('/finance/company/index-baoxiao', {credentials:'include'});
r.redirected   // true = 还没登录;false 且能取到数据 = 成功
⚠️ 那个框每次重登都会弹(微信不记住授权,见 §1.2)。<br>想彻底免掉重登这回事,只有一条路:别关浏览器(挂保活)。

1.6 ★ 保活:配一次,三天不用管登录

做法:挂一个定时任务,每几小时访问一次系统,把 12 小时会话一直续下去。

实测效果(连续 3 天):

  • 22 次运行,0 失败
  • 浏览器连续在线 70 小时
  • 会话一次都没掉,一次都不用重新登录

间隔取 4 小时足够(会话 12 小时,余量 3 倍)。触发器建议「登录时 + 每 4 小时」,双保险。

几个实操坑:

坑说明
schtasks.exe 可能被安全策略禁用报 PROGRAM BLOCKED BY SECURITY POLICY。改用 PowerShell 的 Register-ScheduledTask 就能绕过
浏览器被关 ≠ 会话过期保活救不了"被关"。任务里加一步:探测不到调试端口就自动把浏览器拉起来
同一 profile 只能开一个实例用 --user-data-dir 指定的代码目录,第二个实例会秒退。排查"启动失败"时先看有没有 lockfile / 旧实例
端口会变同一个 profile 换个 --remote-debugging-port 就是"换了个端口"。脚本要自动探测多个端口,别写死

保活的副产品:日志里带一句「待审核 N 条」,等于顺带成了一个"有没有新单"的探测器。
→ 再进一步,待审核 > 0 时应该主动告警,而不是等人来问。

一个反面提醒:保活会把一个本该过期的会话长期维持,属于安全权衡,要让使用者知情。

★★ 「自愈」写错会造出「僵尸浏览器」—— 拉起进程 ≠ 服务可用

这是我踩得最实在的一个坑。

症状:电脑重启后,保活的自愈逻辑报告"启动成功"(Win32_Process.Create 返回 0,拿到了 PID),
Get-Process 也能看到 Chrome 在跑 —— 但调试端口从头到尾没开。
计划任务返回 0xC000013A(= 进程被终止),而且日志里看不出哪里错了。

根因:那段自愈代码只做了「拉起 + 等 10 秒」四件事缺了三件:

缺什么后果
拉起前不清残留进程profile 被上一次的僵尸实例占着,新实例拿不到就静默退出
不删 SingletonLock 等锁文件同上,Chrome 认为"已有人在用这个 profile"
拉起后不验证端口等 10 秒就撒手,失败也不报错、不重试

正确的六步(顺序不能乱):

1. 端口已经能连 → 直接返回(这一步保证幂等,可以反复调用)
2. 杀掉所有命令行里含该 profile 的进程
3. 删 SingletonLock / SingletonSocket / SingletonCookie / lockfile
4. WMI 拉起(不能用 Start-Process —— 会被会话回收)
5. 轮询等端口,最多 40 秒
6. 复查:端口通了才算成功,否则退出码非 0 并打印诊断

通用教训(比这个具体场景更重要):

凡是"启动一个后台服务"的逻辑,验证标准必须是「端口/接口能响应」,<br>而不是「进程起来了」。<br><br>Create 返回 0 只代表命令发出去了。进程存在也可能是个空壳。<br>没有验证的成功,等于没成功。

另一个细节:自愈脚本要幂等 —— 第一行就查"是不是已经好了"。
不然定时任务每跑一次就多杀一次、多重启一次,反而制造故障。


2. 功能在哪(菜单路径)

登录后,左侧菜单 → 「【财务】记账报销」

页面路径说明
财务总入口/finance/company/indextab 容器
报销单列表/finance/company/index-baoxiao★ 日常就操作这一页

列表页顶部有一排筛选器:主体 / 申请日期区间 / 报销人 / 报销状态 / 报销分类。
这个筛选器是好用的——只想看待审核的,就在这里选「报销状态 = 待审核」。

菜单结构:主菜单只有「【财务】记账报销」一个报销相关入口,列表是它的 tab 子页。<br>页面用 iframe 嵌套,接口藏在子页面的 HTML 里,不在主框架。

3. 数据怎么读(核心接口)

3.1 ★★ 必须用 POST,用 GET 会得到完全错误的结论

这是本手册最重要的一条。我在这里栽了大跟头。

❌ 错误做法(会误导你)

GET /finance/company/list-baoxiao?page=2&rows=50&status=0
→ 永远返回最新 20 条。所有参数被忽略。

如果就此下结论「这接口是坏的 / 只能看 20 条 / 拿不到历史数据」——全错。

✅ 正确做法

POST /finance/company/list-baoxiao
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
body:
  company_id=&start_date=&end_date=&user_id=&category_id=
  &status=0&page=1&rows=20

实测全部生效:

能力参数结果
分页page=2✅ 与第1页 0 重复
大页rows=500✅ 真返回 500 条
日期区间start_date / end_date✅ 生效
状态筛选status=0✅ 生效
拉全量循环翻页✅ 全库可拉

status 取值:

值含义
0待审核
1待打款
2已拒绝
10已结款
空全部
⚠️ 列表显示的 status_name 文字和筛选项文字对不上——<br>status=1 在列表里显示成「同意」,status=2 显示成「拒绝」。认数值,别认文字。

3.2 怎么发现「其实要用 POST」(可复用的方法)

  1. 先在界面上操作一遍:选「报销状态 = 待审核」→ 点查询

→ 分页器变「共1页 显示0到0」,说明筛选功能有效

  1. 功能有效但接口不对 → 说明是我的调用方式有问题
  2. 劫持一次真实请求抓出来:
   const o = XMLHttpRequest.prototype.open;
   XMLHttpRequest.prototype.open = function (m, u) { console.log(m, u); return o.apply(this, arguments); };

→ 打印出 POST /finance/company/list-baoxiao

  1. 照抄复现 → 全部生效

3.3 其他接口

接口说明
POST /finance/company/baoxiao-check提交审核。body:ids[](数组)+ status(1=同意 2=拒绝)+ check_memo
GET /finance/company/baoxiao-one?id=X单据详情。对已审核单据返回「不能修改此记录」→ 不能用来补历史数据
/uploads/finance_company/xxxxx.png凭证图片地址,必须带登录态下载

3.4 在页面上下文里取数

只读操作用页面内的 fetch(url, {credentials:'include'}) 即可,自动带 cookie。
在 Node 端直接 fetch 不带 cookie,会被 302 跳登录 —— 列表和提交接口都是这样。

⚠️ 但凭证图片不一样:/uploads/ 下的图不带 cookie 也能直接取到(实测 200)。<br>详见 §5.1。别把"接口要登录"推广成"所有请求都要登录"。

4. 日常任务:核验待审核报销单

4.1 目标

逐单核对:凭证票面金额 vs 报账金额 是否一致。
顺带查:重复凭证、疑似拆单、金额异常。

4.2 ★ 判据只有一条:金额是否一致 —— 与申请人无关

不要因为下面这些去请示使用者(都是多余的):

  • ❌ 「换了新申请人,跟上次不是一个人」
  • ❌ 「金额比上次大得多」
  • ❌ 「第一次见这个人的单」

只有规则没覆盖的情况才需要问:金额不一致、发现重复凭证。
规则已覆盖的,直接执行。

这条是被明确纠正过的:我曾因为「新申请人 + 金额大」多问了一句,得到回复——<br>「不管是谁啊,申请人是不会变动的,只要数据一致就可以同意啊」。<br>使用者定的规则就是规则,别自作聪明给它加"额外谨慎"条件。

★★ 但「无法核实」必须留给人 —— 别把"没矛盾"当成"核实过"

规则只说"金额不一致要单独列",这是不够的。实践中还会撞到两种同样不能自动过的情况:

情况长什么样为什么必须拦
附件 0 个单据里一张凭证都没传压根没东西可核
凭证加不出报账金额传了一堆截图,但都是汇总页/明细页,没有合计有东西,但它不支持这个数字

「看起来没矛盾」≠「核实过」。 没有凭证就批,等于闭眼签字。

实例:一张 4,933 元的单,附件 0 个;另一张 322.04 元的单,两张都是 65 条的明细页,<br>通篇没出现 322.04,只有零散的 -3.00 / -59.89 / +88.88。两张都留给人了。

判据升级为:能算出「凭证金额 == 报账金额」才批;
算不出来(不论是因为没凭证、还是凭证不给合计)→ 留给人。

4.3 流程

  1. 用 POST + status=0 拉出所有待审核单
  2. 下载每单的凭证图片(在页面上下文下载最稳,虽然图片本身不需要鉴权)
  3. 读图核对金额
  4. 一致的 → 批量提交「同意」
  5. 不一致的 → 单独拎出来交给人决定,不要自动过

4.4 提交审核的正确姿势(不可逆操作)

// 1. 先在界面勾选目标行(★ 取选中行必须用 datagrid('getSelections'),见 §6.4)
// 2. 点「审核」按钮,弹窗出现
// 3. 设置审核结果(combobox 必须走 API,不能直接赋 value)
jQuery(sel).combobox('setValue', '1');   // 1=同意
// 4. ★ 先空跑验证载荷,不要直接发(完整步骤见 §7)
const orig = jQuery.postJSON;
jQuery.postJSON = function (opts) { console.log(opts.url, opts.data); if (opts.success) opts.success({status:0,msg:'dry'}); return null; };
okBtn.click();                // 打印载荷,确认 ids/status 正确
jQuery.postJSON = orig;
// 5. 确认无误后真发,并独立复核服务端返回

4.5 ★ 每次审核都必须留操作日志

审批类操作必须留痕,否则以后对账发现钱不对,什么都追不回来。

时机:提交成功、并独立复核之后立刻写。不攒批、不事后补。

记什么:时间、单号、主体、报账金额、凭证票面、是否一致、结论、
执行方式(AI 批量 / 人工)、操作人、凭证文件名、疑点。

★ 关键:日志要「人能看懂」,不是给机器看的。 这一点最容易做错:

❌ 别这么写(工程语言)✅ 要这么写(大白话)
提交载荷 {ids:[...],status:"1"}批准了这 3 张
服务端返回 操作成功 3个(不用写,或者写「系统已确认」)
CDP 驱动页面执行 postJSON(内部实现,不必提)
金额 1234.001,234 元
疑点「见下方」编号 1./2./3. 一步步说清楚:凭证原文 → 怎么推断的 → 最后怎么处理

有疑点的单要单独摊开写,把凭证原文、推断过程、实际结论都写进去。
(下面数字是虚构示例,只为说明格式)

单号 XXXXX —— 这张有问题(留着备查)<br>1. 报的是 2,000 元,但转账凭证上是 5,000 元 —— 对不上。<br>2. 凭证里的聊天记录说明:这 5,000 里有 2,000 是之前已经报过的,新的一笔应该是 3,000 元。<br>3. 按凭证算,这张应该是 3,000 元,不该是 2,000 元。<br><br>实际结果:这张单最后还是按 2,000 元批准了。

双份存储:

  • 审核操作日志.md —— 人看,按批次分块的表格 + 疑点展开
  • 审核操作日志.jsonl —— 机器检索用,每行一条 JSON

日志里要留下凭证文件名,出问题时能直接翻回原图对质,不用重新去系统里拉。

4.6 ★ 核验你自己的核验:把日志拿去跟系统对账

每批做完,跑一次「日志 ↔ 系统」交叉核对。 这是唯一能证明"你记的和实际的一致"的办法。

对三个字段:

比什么怎么比
金额日志里记的报账金额 vs 系统里的单据金额
审核人应为本账号
审核时间精确到秒,应与日志的 ts 一致
系统里找不到的:     0
金额对不上的:       0
审核时间对不上的:   0
✅ 全部一致

⚠️ 但别拿「日志的结论」去比「系统的当前状态」

这是我自己踩的坑,而且很隐蔽:

第一版校验器就是这么写的,结果报了「结论不一致 10 条」,看着像出了大事。
实际是校验逻辑错了 —— 我拿日志记的「同意」去比系统当前状态,
但那些老单早已推进到「已结款」(已打款)。那是更往后的状态,不是错。

在状态会流转的系统里,「不等于就是错」这种判断必然误报。<br>正确做法是比不可变的事实(审核人 + 审核时间),而不是比会流动的状态。

→ 校验器报异常时,先怀疑校验器本身,再怀疑数据。

⚠️ 金额合计一律用脚本算,不要心算

我在对话里手算过一批 10 单的合计,报出去是 30,095.23,实际 26,187.73——错了 3,900 多。
金额这种数字,宁可多敲一条命令,也别心算。


5. 凭证图片处理

凭证是微信聊天截图 + 银行回执 + 平台后台截图。

5.1 下载

// 页面上下文内:→ arrayBuffer → base64 → 带出
const b = await (await fetch(url, {credentials:'include'})).arrayBuffer()
★ 更正一个我先前写错的结论(2026-10-17 实测)<br><br>我原先写的是"下载凭证图片必须在页面上下文 fetch,Node 端不带 cookie 会失败"。<br>实测下来这是错的 —— 把两件不同的事混成了一件:<br><br>| 对象 | 不带登录 | 结论 |<br>|---|---|---|<br>| 列表 / 提交接口 | 302 跳登录 | ✅ 确实需要登录 |<br>| 凭证图片 /uploads/xxx/文件名.jpg | 200,image/jpeg,真实图片字节 | ❌ 不需要登录,公开可访问 |<br>| 目录 /uploads/xxx/ | 403 | 不能枚举 |<br>| 不存在的文件名 | 404 | — |<br><br>所以:接口要登录,图片不用。 图片之所以要"下载到本地",<br>不是因为鉴权,而是因为读图工具吃的是本地文件路径,不吃 URL。<br><br>⚠️ 顺带一个安全问题:凭证图(转账截图里常带收款人姓名、手机号、金额)<br>只要能拿到 URL 就能看。文件名是 13 位十六进制,猜不出来,但会泄露 ——<br>截屏、日志、浏览器历史、Referer、转发链接都可能把它带出去。<br>这个要跟系统供应商提,不是使用者这边能改的。

5.2 ★ 读图的四条铁律

铁律一:OCR 结果只能当线索,不能当证据。
让子代理批量读图,它会把不同图里相同模式的描述归拢,产出看起来整齐但可能有偏差的结果。
涉及对账的数字,关键几张必须自己亲眼复核。

★ 尤其警惕手写体。 实测:一张图上的红字手写合计写的是 88.80,<br>子代理读成 85.80,还据此写了一长篇分析,论证「差额 3.00 的来源」——纯属空中楼阁。<br>我放大原图一看,字就是 88.80。<br><br>手写数字、小字号、连笔是 OCR 的重灾区。凡涉及这类数字,必须放大 3–4 倍自己看。
★ 一个实用的分辨法:<br>子代理给的汇总数字相对可信(多个错误恰好凑成正确总额的概率很低);<br>但它主动标出来的「疑点/异常」,必须自己复核再下结论 ——<br>顺着它的"发现"往下推理,会把一个不存在的差异越论证越"确凿"。

铁律二:金额常不在你以为的位置。
票据截图经常把关键金额切在下一张图的下半部分,第一眼容易漏。
对策:整图看完 → 没找到 → 4x 放大各区域重看 → 专门检查底部被截断处。

铁律三:重复凭证靠哈希,不靠肉眼。
多单引用同一张凭证图时,算 MD5 比对。实测一批 46 个文件里只有 31 张唯一图——
不去重,金额汇总会严重失真。

铁律四:交叉验证。
同一笔业务可能同时出现在「聊天记录」和「转账凭证」里,两者金额要对得上才算核实。

5.3 读图工具的哈希缓存

连续读同一张图,第二次可能返回 Image unchanged,看不到内容。

  • ✅ 绕过:转成 JPEG 再读 —— Image.open(x).convert('RGB').save(y, quality=92)
  • ❌ 无效且危险:追加字节破坏哈希 —— 会破坏 PNG 结构导致完全无法解析

5.4 按凭证类型核对(实战套路,能省很多来回)

凭证不是只有"微信转账"一种。不同类型有不同的核对姿势:

凭证类型长什么样核对要点
微信/支付宝转账截图单个金额直接比
银行回执 + 银行流水回执只有本金,流水多一笔★ 备注写「+手续费」的,一定要去流水里找那笔手续费,两笔相加才是报账额
广告后台消耗页表格,某列被红框标出★ 看红框框的是哪一行,不是看第一个数
平台保证金明细页一长串 +/- 明细,没有合计⚠️ 常常加不出报账额 → 见 §4.2,留给人
保证金赔付单单条 -¥X 赔付记录直接比
1688/淘宝订单页「总实付」比「总实付」,不是比商品单价
多附件拆单一个单挂 N 张逐张加总,注意有没有同一张传两遍(MD5)

两个具体陷阱:

  1. 同一页列出多个店铺时,红框位置决定是哪一家。

两笔不同金额的单,可能是同一个页面、红框框在不同行 —— 别以为是重复凭证。

  1. "总实付" ≠ 商品金额。订单页的「总实付」已经含了运费、折扣,

与商品单价相加往往对不上,认「总实付」。


6. 自动化操作:EasyUI 的反直觉点

6.1 数据不在你以为的 table 里

  • .datagrid-btable → 只有行号列,找不到业务数据
  • .datagrid-view → 真正的数据在这里

单号在 .datagrid-cell-c1-id。从单元格反查行:cell.closest('tr')

⚠️ 但行的 checkbox 不在同一个 tr 里 —— datagrid 是分栏渲染的,<br>复选框在冻结列、业务数据在数据列,是两个不同的 tr。<br>取已勾选的行必须走官方 API,见 §6.4。(我一开始就是在这里想当然,踩了坑)

6.2 受控组件不能直接赋值

EasyUI 的 combobox 会把原始 <select> 设为 display:none。
直接 select.value = x 完全无效,必须:

jQuery(sel).combobox('setValue', '1')

6.3 通用赋值法(React/Vue 同理)

const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));

6.4 ★ 取「已勾选的行」必须走官方 API,别查 DOM

这是个真坑,我的提交脚本因此一直是坏的。

EasyUI 的 datagrid 是分栏渲染的:复选框在冻结列的 tr 里,业务数据(ID 等)在数据列的 tr 里。
所以在同一个 tr 里找不到「复选框 + ID」的组合 —— 用 DOM 遍历永远返回空。

// ❌ 错:DOM 查找,永远找不到
view.querySelectorAll('tr') → filter(有 checkbox) → 取 .datagrid-cell-c1-id

// ✅ 对:官方 API
jQuery('#tt').datagrid('getSelections')   // → [{id:'123456', money:...}, ...]

顺带可读到的配置:jQuery('#tt').datagrid('options') → 里面能看到真实的 url 和 method(是 post)。

判据:如果你手动在界面上勾了行、脚本却报「没有选中行」,八成就是这个原因。

6.5 ★★ 批量处理里混着"不能批"的单,怎么精确勾选

场景:一批 15 条待审核,其中 13 条对得上、2 条有问题。
你不能"全选" —— 全选就把有问题的也批了。

正确做法:按单号逐个 selectRow,不要靠 DOM 点复选框:

const dg = jQuery('#tt');
dg.datagrid('clearSelections');            // 先清空,避免残留

const WANT     = ['123450', '123451', /* …要批的 */];
const MUST_NOT = ['123452', /* …不批的 */];

const rows = dg.datagrid('getData').rows;   // 拿到当前页所有行
WANT.forEach(id => {
  const idx = rows.findIndex(r => String(r.id) === id);
  if (idx >= 0) dg.datagrid('selectRow', idx);
});

// ★ 提交前必须复核:选中数对、漏选为空、**有没有混进不该批的**
const sel    = dg.datagrid('getSelections').map(r => String(r.id));
const leaked = sel.filter(s => MUST_NOT.includes(s));
console.log({ 选中: sel.length, 应有: WANT.length, leaked });  // leaked 必须为空

关键点:

  1. clearSelections() 先清空 —— 上一轮残留的勾选会悄悄带上
  2. 显式维护 MUST_NOT 名单,并检查 leaked —— 这是最后一道闸
  3. 空跑时把 ids 数组完整打出来看一遍 —— 别只看 count,count 对不代表名单对

7. ★ 不可逆操作:先空跑,再提交

审核、审批、支付、删除、发消息这类不可逆且有外部影响的操作,一律走四步:

Step 1 — 劫持请求,打印载荷,不真发 + 收拾现场

const orig = jQuery.postJSON;
jQuery.postJSON = function (opts) {
  console.log('载荷预览', opts.url, opts.data);
  if (opts.success) opts.success({status: 0, msg: 'dry-run'});
  return null;
};
okBtn.click();
jQuery.postJSON = orig;          // ★ 立刻还原,别留着
// ★ 并关掉伪造回调引发的提示框,别让界面卡住(见下方说明)
document.querySelectorAll('.messager-window').forEach(w => { try { jQuery(w).window('close'); } catch(e){} w.remove(); });
document.querySelectorAll('.window-shadow, .window-mask').forEach(w => w.remove());

Step 2 — 人工核对载荷:ID 列表对不对?状态对不对?有没有多带字段?
Step 3 — 真提交,并捕获服务端响应(别只看前端有没有报错)
Step 4 — 独立复核:重新拉一次数据,确认状态真的变了。

⚠️ 为什么 Step 1 里要「收拾现场」

空跑不只是"不真发"。 伪造成功回调会触发页面的正常后续行为——
弹提示框、刷新列表、关闭弹窗……这些都会真实发生。

我踩的坑:空跑时伪造了 success({msg:'dry-run'}),页面照常弹了个提示框,
验证完我没关它 —— 结果使用者看到的界面就卡在那个框上,以为出问题了
(其实真提交早已成功,只是被一个假提示框挡着)。

原则:每次空跑前先问一句 ——「它会留下什么?」 留下的东西必须一并清掉。


8. ★ 网页流程卡在「桌面客户端的弹框」?—— 用 ctypes 点它

这条纠正了我自己的错误判断。 我曾断言「本机无法用程序点原生窗口」——
错的。禁用的只是 Add-Type 和 COM 两条特定路径,ctypes 一直是通的。

8.1 症状

网页流程走着走着,弹出一个不属于浏览器的窗口(如微信客户端的「允许/拒绝」授权框)。
CDP(Runtime.evaluate / Input.dispatchMouseEvent)只能操作浏览器内的内容,碰不到它。

8.2 正确做法:Python + ctypes 直接调 user32

Python 标准库 ctypes 无需安装任何东西,也不走 Add-Type / COM,不会被安全策略拦。

① 找到那个窗口(枚举 + 按特征筛)

import ctypes, ctypes.wintypes as wt
user32 = ctypes.windll.user32
# EnumWindows 遍历 → GetWindowTextW 取标题、GetClassNameW 取类名、GetWindowRect 取矩形
# GetWindowLongW(hwnd, -20) 取扩展样式,0x8 = WS_EX_TOPMOST

命中特征(微信授权框实测):进程 Weixin.exe、标题 微信、尺寸约 376×268、带 TOPMOST。

② 确认是它(别急着点,先看一眼)
用 ctypes 的 GDI(BitBlt + GetDIBits)截该区域存成 PNG,亲眼确认是哪个框。
(PIL 可用时直接存 JPEG;不可用就写 BMP。)

③ 点击

user32.SetCursorPos(x, y)          # x,y = 窗口矩形 × 按钮相对比例
user32.mouse_event(0x0002, 0,0,0,0)  # 左键按下
user32.mouse_event(0x0004, 0,0,0,0)  # 左键抬起

「允许」按钮中心约在对话框的 28.7% 宽 / 80.6% 高。

8.3 三条纪律

  1. 点之前必须截图确认——坐标法很脆,微信版本/DPI 一变就偏,不能盲点
  2. 只点使用者明确要求的、他自己的确认框。不要拿这套去自动化客户端操作(违规 + 封号风险)
  3. 优先问自己:这一步真的必须自动化吗? 让使用者点一下往往更快更安全

8.4 通用教训(比技术本身更重要)

那天上午我刚写下「本机无法自动化原生窗口,别浪费时间找路子」——下午就被自己推翻。

把「某条路被禁」说成「这件事做不到」,是过早收敛。<br>正确表述是「这两条路不行」,然后继续找别的路。

9. ★ 身份与权限:别靠推断

本手册最贵的一条教训。

系统里通常同时存在「申请人」和「审核人」,而数据字段里出现的往往是申请人的名字。

我犯的错:看到列表里所有记录的申请人都是某员工,就断定「当前登录的应该是申请人本人,
不该执行审核操作」,据此拒绝了使用者的请求。实际上使用者就是审核人,
界面右上角明确写着名字。

原则:

  1. 界面明确显示的身份 > 从数据字段推断的结论
  2. 涉及权限、审批、责任归属时,拿不准就把证据摊开给人看,别用推断当结论去阻断使用者
  3. 问一句的成本,远低于误判后被拒绝的代价

10. 工具链速查

环节做法
测接口前★ 先劫持一次界面真实请求,拿到方法/URL/body 再照抄。老框架大概率用 POST
起带调试端口的浏览器必须用 WMI 起(父进程不归当前会话才能跨命令存活);不能用 Start-Process
多标签页按 URL 关键字匹配 target,别拿「第一个 page」
执行 JS从文件读再送进 Runtime.evaluate,命令行拼多行会被 shell 吃引号
带鉴权取数页面上下文内 fetch(..., {credentials:'include'})
提交表单POST + application/x-www-form-urlencoded; charset=UTF-8,参数放 body
下载附件页面内取 base64 → 调试协议传回 → 本地写文件
截图先 scrollTo(0,0) 再截
读图被缓存转 JPEG 绕过
点不动按钮站点校验 isTrusted 时 .click() 会被静默忽略 → 用真实鼠标事件(Input.dispatchMouseEvent,取 getBoundingClientRect() 中心)
点了没反应开控制台监听(Runtime.enable + Log.enable),再派发点击,看它卡在哪一步
同名元素多套视图各渲染一份,隐藏那份 rect 全 0 → 必须过滤 rect.width > 0
读会话 cookiebrowser target 用 Storage.getCookies(Network.getCookies 在 browser target 上不存在)
测会话续期读 cookie expires → 发几个请求 → 再读,看是否刷新
Failed to fetch多半是跨域重定向被拦,不是网络故障 → redirect:'manual' 验证
取已勾选行(EasyUI)必须 jQuery('#tt').datagrid('getSelections')。DOM 查不到——复选框在冻结列、数据在数据列,不在同一个 tr
桌面客户端弹框CDP 碰不到。用 Python ctypes 调 user32:EnumWindows 定位(看 WS_EX_TOPMOST)→ GDI BitBlt 截图确认 → SetCursorPos + mouse_event 点击。点前必须截图确认
不确定能不能做先想「是不是只有这一条路被封了」。ctypes 不走 Add-Type/COM,很多「做不到」其实只是「那两条路不行」
不可逆操作空跑 → 验证载荷 → 真发 → 独立复核 → 写日志留痕
空跑之后别忘了清副作用(伪造回调会弹框/刷新)——先问「它会留下什么」
要批的和不能批的混在一批用 datagrid('selectRow', idx) 按单号逐个选,并检查 leaked(混进不该批的)为空。别全选
配保活定时任务每 4h 访问一次续期。schtasks.exe 被禁就用 Register-ScheduledTask
批量读图照片多时派子代理,但手写体/关键数字必须自己放大复核;子代理标的"疑点"尤其要复核
金额合计一律用脚本算,绝不心算
每批做完跑「日志 ↔ 系统」交叉核对:比金额 / 审核人 / 审核时间,不比会流转的状态

11. 重做时的顺序建议

  1. 先确定「这是什么系统、有没有 API」,别一上来就写代码
  2. 先抓一次界面真实请求(方法/URL/body),照抄复现
  3. 确认调用方式无误后,再测分页/筛选边界
  4. 确认身份角色,看界面右上角,不靠推断
  5. OCR 分工:机器扫全量,关键数字(尤其手写)人工复核
  6. 不可逆操作一律空跑先行,并清干净副作用
  7. 批量处理前,先列清楚「哪些能批、哪些不能批」 —— 然后按名单精确勾选,别全选
  8. 每批做完跑一次「日志 ↔ 系统」交叉核对
  9. 写「这接口是坏的」这类结论前,回头再验一次
  10. 写「这件事做不到」之前,先确认是不是只是某条路被封了
  11. 校验脚本报异常时,先怀疑校验脚本

12. 一句话总结

**先搞清是什么系统、怎么登录、你是谁;登录优先试「快捷登录」(走本机客户端,不用手机);
再抓真实请求确认怎么调接口;老框架参数常走 POST;
OCR 只能当线索、对账数字必须人工复核(手写体尤其);
不可逆操作先空跑、清副作用、必须留痕;
批量处理要能精确排除「不能批的」,「无法核实」和「金额不符」一样都得留给人;
做完拿日志跟系统对一账;身份权限不靠推断;
下「接口坏了」的结论前必须回头验证;说「做不到」之前先确认是不是只是某条路被封了;
校验脚本报错时先怀疑校验脚本。**


附:为什么把这篇发到公开站点

自动化任务的上下文很容易随会话丢失。把方法沉淀成一个链接,
换任何智能体、任何机器,读这一条 URL 就能接手。

本文件不含业务数据(无单号、无金额、无人员姓名、无真实账套名),
只写「系统是什么、怎么进、怎么操作」。真正的业务状态记在项目本地档案里,不对外。

类型
随手记
状态
已归档
可见性
公开可见
浏览
71

我的备注 0

登录后才能写备注。

还没有备注。