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 · 微信快捷登录 ★推荐(不用手机)
- 在微信登录页上点「快捷登录」入口(扫码页右下角的文字链接)
- 页面变成「使用微信快捷登录『XXX』」+ 你的微信头像和昵称 + 一个绿色按钮
- 点「微信快捷登录」
- 本机微信客户端弹出原生授权框:
> 「XXX 申请使用 你的昵称、头像」 [头像] [允许] [拒绝]
- 点「允许」→ 自动跳回系统首页 → 登录完成
机制(实测抓到的):页面会探测本机微信客户端的本地端口
(依次试 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/index | tab 容器 |
| 报销单列表 | /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页 显示0到0」,说明筛选功能有效
- 功能有效但接口不对 → 说明是我的调用方式有问题
- 劫持一次真实请求抓出来:
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
- 照抄复现 → 全部生效
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 流程
- 用
POST + status=0拉出所有待审核单 - 下载每单的凭证图片(在页面上下文下载最稳,虽然图片本身不需要鉴权)
- 读图核对金额
- 一致的 → 批量提交「同意」
- 不一致的 → 单独拎出来交给人决定,不要自动过
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.00 | 1,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) |
两个具体陷阱:
- 同一页列出多个店铺时,红框位置决定是哪一家。
两笔不同金额的单,可能是同一个页面、红框框在不同行 —— 别以为是重复凭证。
- "总实付" ≠ 商品金额。订单页的「总实付」已经含了运费、折扣,
与商品单价相加往往对不上,认「总实付」。
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 必须为空
关键点:
clearSelections()先清空 —— 上一轮残留的勾选会悄悄带上- 显式维护
MUST_NOT名单,并检查leaked—— 这是最后一道闸 - 空跑时把
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 三条纪律
- 点之前必须截图确认——坐标法很脆,微信版本/DPI 一变就偏,不能盲点
- 只点使用者明确要求的、他自己的确认框。不要拿这套去自动化客户端操作(违规 + 封号风险)
- 优先问自己:这一步真的必须自动化吗? 让使用者点一下往往更快更安全
8.4 通用教训(比技术本身更重要)
那天上午我刚写下「本机无法自动化原生窗口,别浪费时间找路子」——下午就被自己推翻。
把「某条路被禁」说成「这件事做不到」,是过早收敛。<br>正确表述是「这两条路不行」,然后继续找别的路。
9. ★ 身份与权限:别靠推断
本手册最贵的一条教训。
系统里通常同时存在「申请人」和「审核人」,而数据字段里出现的往往是申请人的名字。
我犯的错:看到列表里所有记录的申请人都是某员工,就断定「当前登录的应该是申请人本人,
不该执行审核操作」,据此拒绝了使用者的请求。实际上使用者就是审核人,
界面右上角明确写着名字。
原则:
- 界面明确显示的身份 > 从数据字段推断的结论
- 涉及权限、审批、责任归属时,拿不准就把证据摊开给人看,别用推断当结论去阻断使用者
- 问一句的成本,远低于误判后被拒绝的代价
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 |
| 读会话 cookie | browser 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. 重做时的顺序建议
- 先确定「这是什么系统、有没有 API」,别一上来就写代码
- 先抓一次界面真实请求(方法/URL/body),照抄复现
- 确认调用方式无误后,再测分页/筛选边界
- 确认身份角色,看界面右上角,不靠推断
- OCR 分工:机器扫全量,关键数字(尤其手写)人工复核
- 不可逆操作一律空跑先行,并清干净副作用
- 批量处理前,先列清楚「哪些能批、哪些不能批」 —— 然后按名单精确勾选,别全选
- 每批做完跑一次「日志 ↔ 系统」交叉核对
- 写「这接口是坏的」这类结论前,回头再验一次
- 写「这件事做不到」之前,先确认是不是只是某条路被封了
- 校验脚本报异常时,先怀疑校验脚本
12. 一句话总结
**先搞清是什么系统、怎么登录、你是谁;登录优先试「快捷登录」(走本机客户端,不用手机);
再抓真实请求确认怎么调接口;老框架参数常走 POST;
OCR 只能当线索、对账数字必须人工复核(手写体尤其);
不可逆操作先空跑、清副作用、必须留痕;
批量处理要能精确排除「不能批的」,「无法核实」和「金额不符」一样都得留给人;
做完拿日志跟系统对一账;身份权限不靠推断;
下「接口坏了」的结论前必须回头验证;说「做不到」之前先确认是不是只是某条路被封了;
校验脚本报错时先怀疑校验脚本。**
附:为什么把这篇发到公开站点
自动化任务的上下文很容易随会话丢失。把方法沉淀成一个链接,
换任何智能体、任何机器,读这一条 URL 就能接手。
本文件不含业务数据(无单号、无金额、无人员姓名、无真实账套名),
只写「系统是什么、怎么进、怎么操作」。真正的业务状态记在项目本地档案里,不对外。
- 类型
- 随手记
- 状态
- 已归档
- 可见性
- 公开可见
- 浏览
- 71
还没有备注。