跨站任务判定的三层状态不同步:headless 自动化里的 external_check 陷阱
现象:动作做完了,页面也说成功了,积分却没到
有个每天跑一次的浏览器自动化脚本,其中一项任务是「去第三方代码托管平台 Star 一个指定组织下的仓库」。脚本按官方链路走完:从任务中心点「去完成」跳到实践中心,再点「前往 Star 案例仓」进组织项目列表,挑一个没收藏过的项目点 Star。按钮当场从 Star 变成 Starred,刷新页面还是 Starred。
但回到成长中心,任务卡片依旧显示「去完成」,积分一分没涨。
第一反应通常是「是不是没走官方链路」「是不是 Star 被回滚了」。这两天的排查结论是:动作确实成功了,只是「成功」这件事分散在三个互相不同步的状态源里,而自动化脚本最容易被其中某一个骗到。
先拆清楚:一次跨站任务有三个状态源
这类任务的判定链路和纯站内点击不一样,它跨了两个域:
| 层 | 状态载体 | 更新时机 | 可信度 |
|---|---|---|---|
| ① 外部平台 | 代码托管服务端的 star 记录 | 点击即落库 | 最高,但脚本通常读不到 |
| ② 业务站前端 | 任务卡「已完成 / 去完成」、按钮文案 | 依赖接口回包 + 本地渲染 | 会被乐观更新和缓存污染 |
| ③ 业务站服务端 | 积分明细里的一笔进项 | 定时轮询外部平台后补发 | 最终判据(钱到账才算数) |
三者之间都是最终一致,不是强一致。脚本如果拿 ② 当判据,就会在还没同步时误判「没做」而重复提交;如果拿 ① 当判据,又会因为读不到而干脆放弃核对。
坑一:headless 下 visibilitychange 回调根本不跑
翻官方前端 bundle 才发现,实践中心不是在你点 Star 的瞬间上报的,而是有一段定时校验:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
// 每 60s POST 一次服务端校验
checkExternalTask("gitcode-star");
}
});
也就是说,判定动作由「标签页重新可见」驱动,间隔约 60 秒。而在 Playwright / CDP 驱动的无头浏览器里,多标签页场景下只有当前 active 的那个是 visible,其余一直是 hidden;再加上脚本本身不会真的把标签切来切去,这个回调可能一次都不触发。
结果就是:Star 做完了,业务站服务端压根没被通知去查。前端卡片自然一直「去完成」。
解法不是伪造数据,而是按官方自己的参数补一次校验。从 bundle 里能定位到上报接口与 body:
await fetch("/api/growth/tasks/gitcode-star/submit", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ case_id: null, payload: { action: "check" } })
});
注意 action: "check" 和外层 {case_id, payload} 结构必须从前端代码里抄,猜一个 {} 过去只会拿到参数错误。这属于「替页面补触发它本该自己触发的调用」,不是绕过校验。
坑二:服务端的 last_star_at 会滞后一整天
手动补了校验,回包长这样:
{
"task_key": "gitcode-star",
"period_key": "2026-11-11",
"completed": false,
"progress": {
"today": "2026-11-11",
"need_daily_star": true,
"last_star_at": "2026-11-10T10:25:55+08:00",
"matched": [],
"missing": ["org:xxx"]
}
}
last_star_at 停在昨天,说明业务站服务端缓存的是上一次轮询到的外部平台快照,今天这次 Star 还没同步过来。这不是判定失败,是同步窗口没到。
这里最容易犯的错是「再 Star 一次」。任务规则明确要求每天 Star 不同项目且不可取消,重复点同一个项目不产生新的有效行为,反而把状态弄脏。正确做法是把这次判定标记为「待同步」,退出本次流程,隔天用积分明细复核——实测这笔 +30 会在后续轮询里自动补发,明细里能看到带时间戳的进项。
所以这类任务的正确判据是积分明细,不是任务卡片:
// 只认明细,卡片状态只作参考
const rows = await readPointDetails(); // 每笔带时间 + 来源 + 积分
const done = rows.some(r =>
r.date === today && /Star.*代码仓/.test(r.source));
if (!done) markPending("awaiting_external_sync"); // 不重做
坑三:同一个站点,两个页面的按钮状态不一致
另一个更隐蔽的坑出现在选项目环节。组织主页的精选项目卡片上,所有仓库的按钮都显示 Star;但切到项目标签页,同样的仓库里已经有好几个是 Starred。
// 错误:在组织主页上找第一个 "Star" 按钮
const btn = [...document.querySelectorAll(".status-btn")]
.find(b => b.innerText.trim() === "Star"); // 可能命中已收藏的仓库
// 正确:切到权威列表页,按仓库名精确归属再判断
const card = [...document.querySelectorAll("li,div[class*=card]")]
.find(c => c.innerText.includes(repoName));
const target = [...card.querySelectorAll(".status-btn")]
.find(b => b.innerText.trim() === "Star");
target.click();
原因是主页卡片是「精选项目」推荐位,按钮只渲染默认态,不回读当前用户的收藏关系;项目列表页才会带真实状态。自动化脚本必须先确认自己在哪个数据源上,再决定读哪个字段。凭 class 名相同就假定语义相同,是这类脚本的高频翻车点。
另外,外部平台的开放接口也不总是可用的兜底:试过直接查 /api/v5/user/starred,返回 400 要求 private-token 头。没有 token 时,页面按钮状态反而比 API 更可靠——前提是用对页面。
坑四:积分明细弹窗本身有缓存
即便切回成长中心,直接点开积分明细也可能看到旧数据。弹窗是前端缓存,不刷新页面不会重新拉。多次实测下来,稳定流程是:
switch回成长中心标签;- 先
reload,等页面重新拿到登录态与积分; - 再点
div.user-message-view-textButton-item-point开明细; - 用正则
/(\d+)\s*我的积分/读当前积分(反着写会误读「已连续签到」附近的数字)。
漏掉第 2 步,会一直看着旧余额得出「没到账」的结论,然后触发一轮本不该有的重试。
把结论收敛成几条规则
| 规则 | 为什么 |
|---|---|
| 跨站任务以积分明细为唯一终态判据 | 卡片与按钮都会滞后或乐观更新 |
| 动作与核验解耦,核验可补触发 | 页面可见性回调在 headless 下不可靠 |
| 补触发必须抄官方参数,不构造业务数据 | 既避免绕过校验,也不污染账号状态 |
| 判定为「待同步」时绝不重做 | 不可逆动作(Star / 提交作品 / 发文)重复会消耗配额 |
| 读取前先确认数据源页面 | 同站不同视图的字段语义可能不同 |
| 对账前强制 reload | 明细弹窗有前端缓存 |
本质上,自动化脚本面对的不是一个「做完就返回成功」的同步世界,而是一组带不同延迟的状态机。把「动作成功」和「判定入账」当成同一件事,是所有重复提交和误判的起点。写成显式的 done / pending / failed 三态,再配一份带时间戳的明细对账,比在 UI 上反复刷「去完成」有用得多。
- 点赞
- 收藏
- 关注作者
评论(0)