把 WorkBuddy 签到脚本搬进金山文档:令牌静态加密、网关 HTML 响应与推送协议变更的三重排查

7次阅读
没有评论

半年前写的一个金山文档 AirScript 签到脚本突然不能用了,报错只有一句没头没尾的 刷新失败 code=undefined undefined。这篇文章记录了从这条报错出发,如何一层层扒开令牌静态加密、网关返回 HTML 而非 JSON、以及推送接口协议变更三个独立问题,最后交付一份能在云端表格里长期免维护运行的签到脚本。

技术栈:AirScript(ES6 子集)· 微信云开发 · 金山文档 · WorkBuddy 插件登录接口

一、问题现场:一句没头没尾的报错

原脚本跑在金山文档(kdocs)的定时任务里,每天自动给 WorkBuddy「Buddy 加油站」签到领积分。某天起它开始报:

刷新失败 code=undefined undefined(需重新取令牌填入 B1)

第一直觉是令牌过期了,去本机把新令牌填进 B1,结果依旧。但有个细节很反常——code=undefined。后端真拒绝一个令牌,应该返回一个明确的错误码(后面实测是 12153),而不是连 code 字段都没有。

这个「undefined」是整件事的突破口:它说明脚本根本没解析出后端的响应体,而不是后端返回了什么.reject 掉。于是排查方向从「令牌过期」转向「响应到底长什么样」。

二、根因拆解:三个彼此独立的问题

最终定位到三个叠加在一起的问题。任何一个单独存在都足以让脚本失败,也正因为叠加,第一次排查时才会互相干扰、看起来像一个问题。

2.1 本地令牌已被静态加密,复制粘贴这条路断了

新版 WorkBuddy 客户端启用了 AtRestEncryption。本机 %LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\workbuddy-desktop.info 里的 accessToken / refreshToken 已经不是明文,而是一个加密信封:

{
  "$wbEncrypted": 1,
  "envelope": "eyJ2ZXJzaW9uIjoic3ltLXYxIiwiYWxnIjoiQUVTLTI1Ni1HQ00i...
}

信封是 sym-v1 格式(AES-256-GCM,AAD 为 WB-AAD\0 加长度前缀编码,密钥取自 sha256(atRestSecretKey))。关键在于:解密密钥只驻留在客户端进程内存中,不落盘。

于是出现了一个看似无解的局面——脚本跑在金山文档的云端表格里,既读不到本机文件,更不可能拿到内存里的密钥。社区项目 88lin/workbuddy-auto-signin 能在 Electron 子进程内解出明文,但那条路约 28 天要重跑一次。

2.2 code=undefined 的真身:网关返回的是 HTML

用 curl 直接打上游接口做对照实验,结果很干脆:

请求 响应 Content-Type 响应体
POST /v2/plugin/auth/token/refresh(令牌无效) application/json {"code":12153,"msg":"...refresh token failed..."}
POST /v2/billing/meter/daily-checkin(鉴权失败) text/html <html>...401 Unauthorized...</html>

上游是 APISIX / openresty 网关,鉴权失败时返回 HTML 401 页面而不是 JSON。老代码用 JSON.parse() 硬解这段 HTML,必然抛异常,于是 r.code 取到 undefined。

换句话说,code=undefined 真正在说「令牌没通过鉴权」,只是被一层解析异常伪装成了未知错误。新旧代码的判断依据必须从 HTTP 状态码改成 body 里的业务 code,而对非 JSON 响应要单独判定。

2.3 推送不通:协议已经变了

签到修好后,用户反馈推送仍然不通。这次是三个小问题叠在一起,而且旧代码因为把异常整个 try/catch 吞掉、从不检查返回体,导致失败时零线索:

(1) body 格式错(致命)。旧代码声明 Content-Type: application/x-www-form-urlencoded,却把 payload 交给 postJson → JSON.stringify,实际发出去的是 JSON 字符串。Qmsg 按表单解析,取不到 msg 字段。curl 对照实验:

body 实际内容 服务端响应 含义
msg=hello key不存在 msg 解析到了,走到 key 校验
{"msg":"hello"} 消息不能为空 msg 根本没解析到

(2) 接口路径过时。Qmsg 已升级到 v3:/v3/send/{key},旧的 /send/{key} 返回体里官方明确写着「该接口即将废弃」。

(3) 失败被静默吞掉。不检查返回体,就只能知道「没收到消息」,无法定位。

三、方案设计:既然读不到本地,那就让服务端发

核心思路是一次转向:与其想办法从本机把令牌抠出来,不如直接调官方插件登录接口,让服务端直接把明文令牌下发给脚本。

参考 L0NE-6/WorkBuddy-Daily 的 workbuddy_login.py,把「短信登录」这条链路整个搬进 AirScript:

1

B4 填手机号 → 运行
POST /v2/plugin/login/send-sms
服务端真的发出验证码
↓
2

B5 填验证码 → 运行
POST /v2/plugin/login/token
服务端下发明文 AT / RT
↓
3

之后全自动
RT 每次续期都会轮换
脚本每次都回写 B1,免维护

这条路的好处是彻底摆脱了本机:不依赖客户端开机、不依赖本机文件、不需要 Python 环境,整条链路都在云端表格里闭环。两个登录接口实测都可用(send-sms 返回 code 0,login/token 错误码体系为 14704 invalid_sms_code,说明路由正确)。

3.1 接口口径对齐官方 v3.27

从官方主脚本里核对了续期接口的请求头变更。新版续期必须带:

  • X-Domain: www.workbuddy.cn —— 旧值已失效
  • X-Request-ID —— 32 位 hex,前 12 位为毫秒时间戳
  • 有当前 AT 时补 Authorization: Bearer <AT> 与 X-User-Id: <AT.sub>

签到基址也统一到 www.workbuddy.cn(旧代码用的 codebuddy.cn / copilot.tencent.com 均已过时),并且采用「先查状态、未签才领」的幂等流程:

POST /v2/billing/meter/checkin-activity-status   ← 先查
POST /v2/billing/meter/daily-checkin              ← 未签才领

四、实现要点:AirScript 沙箱里的若干硬约束

AirScript 是个 ES6 子集,沙箱限制比浏览器严格得多。以下每一条都在实现时真实踩到过。

约束 说明 本脚本的处理
无 atob / btoa 内置只有 Crypto(摘要/HMAC)与 Buffer,没有 Base64 编解码函数 自写纯 JS base64url 解码(b64urlDecode + utf8Decode)
语法子集 禁用 class、对象方法简写、?.、await、import/export 全部 var + 字符串拼接;静态检查脚本逐项校验
Response 是流式的 text() / json() 只能消费一次 统一先取 text(),再自行 JSON.parse,避免二次消费
Range 版本差异 1.0 的 Value 是属性,2.0 里是方法 优先 Value2,回落 Value(兼容两种形态)
网络限制 外部地址不能带 IP/端口;响应体 ≤ 2MB;超时上限 60s 域名全部走白名单内的完整域名;超时设 15s
HTTP.fetch 的 body 类型为 string,原样发送,不会被 JSON 化 表单场景手工拼 msg=xxx&group=123

4.1 一个值得记下来的坑

测试阶段 UID 回写曾经莫名变成 function sub() { [native code] }。排查后发现是测试夹具双重编码导致 payload 段解出的是字符串而非对象,于是 p.sub 落到了 String.prototype.sub 上。

脚本本身没错,但这次排查暴露了一个值得加固的点——jwtPayload 现在会校验解析结果确实是对象,否则属性访问会落到原型链成员上。这类 bug 在真实场景里很难靠肉眼看出来。

五、推送模块:让失败可见

推送部分的核心原则只有一条:失败必须可见。具体做法是给每个渠道做独立的结果判定,并把服务端返回原文写进 B8 单元格。

B3 写法 走哪个渠道
qmsg:<key> Qmsg酱(群推送写 qmsg:<key>@<群号>)
qmsg:<key>@qq:<QQ号> Qmsg酱 推给指定 QQ
<裸 16~64 位 hex 串> 自动识别为 Qmsg酱 key
SCTxxxx Server酱 Turbo
sctpxxxx Server酱³
pp:<token> PushPlus
<其它字符串> 按 PushPlus token 处理

多个渠道可用英文逗号 / 分号 / 换行分隔,脚本逐个推送,只要有一个成功即算成功。

Qmsg 走三级回退:v3 POST 表单 → v3 GET 查询参数 → 旧版路径,回退之间 Time.sleep(1200) 错开,因为 qmsg 限流是同一 Key 每 3 秒 1 条。

另外做了两处内容清洗:抹掉 URL、把 6 位以上连续数字替换掉。Qmsg 对 URL、IPv4、连续数字串、敏感词做违规拦截,不清洗会出现「接口返回成功但消息被拦」的情况。

还有一个容易踩的坑:Qmsg 接口返回成功 ≠ QQ 真收到了。必须在 qmsg 后台把机器人加为好友、把接收对象加为 QQ 并点确认,否则接口报成功但消息不会落地。可用 /v3/msg/status/{key}?msgId=N 查投递状态(0 未回执 / 1 成功 / 2 违规 / -1 失败)。

为了把「推送不通」和「签到不通」两件事分开定位,还加了个自检模式:B9 填 test 时只跑推送,完全不碰签到与令牌逻辑。

六、部署步骤

  1. 在 kdocs.cn 新建空白表格
  2. B4 填手机号,B3 填推送凭据(可选但强烈建议)
  3. 「效率 → 高级开发 → AirScript 脚本编辑器」新建脚本,粘贴下方完整代码
  4. 关键一步:编辑器工具栏「服务 → 添加服务」勾选网络 API,不加这个 HTTP 对象根本不存在,脚本发不出任何请求
  5. 手动运行两次完成登录(第一次发码,第二次填码换令牌)
  6. 「效率 → 高级开发 → 定时任务」建每天 09:00 与 21:00 两个点(幂等,防单次抖动断签)

七、完整代码

以下是可直接使用的完整脚本。已通过 15 个逻辑分支的模拟测试与 11 个真实网络用例的端到端验证,并通过了 AirScript 语法静态检查(无禁用语法、无危险全局依赖、括号配平)。

/**
 * WorkBuddy「Buddy 加油站」每日自动签到 —— 金山文档 AirScript v2(明文令牌自举版)
 * ============================================================================
 *
 * 【本版解决什么】
 *   WorkBuddy 5.6.2+ 客户端启用 AtRestEncryption,认证文件里的 accessToken /
 *   refreshToken 不再是明文,而是 sym-v1 加密信封:
 *       { "auth": { "refreshToken": { "$wbEncrypted": 1, "envelope": "..." } } }
 *   解密密钥只驻留在客户端进程内存里,**金山文档 AirScript 跑在云端表格环境,
 *   既读不到本机文件,也不可能解密这个信封** —— 所以"从本地文件复制 token
 *   填进 B1"这条路在新版客户端上已经彻底走不通。
 *
 *   ⇒ 本版改为 **令牌自举**:直接调用官方插件登录接口拿服务端下发的明文令牌。
 *       仓库 L0NE-6/WorkBuddy-Daily 的 workbuddy_login.py 就是这个思路
 *       (POST /v2/plugin/login/send-sms → POST /v2/plugin/login/token),
 *       本版把它搬进 AirScript,**整条链路不需要碰本机文件、不需要开电脑、
 *       也不需要任何 Python 依赖**。
 *
 * 【表格约定】
 *   B1  refreshToken   明文刷新令牌,脚本自动维护(每次续期后回写新 RT)
 *   B2  执行结果       最近一次运行结论(签到 / 连签 / 推送结论)
 *   B3  推送配置       qmsg:<key> / SCTxxx / sctpxxxx / pp:<token>(可留空)
 *   B4  手机号         仅首次登录用,填 11 位手机号
 *   B5  短信验证码     仅首次登录用,登录成功后脚本自动清空
 *   B6  accessToken    由脚本维护,续期时作为 Authorization 身份头使用
 *   B7  用户 UID       由脚本自动从 AT 中解析并回写(也可手工填)
 *   B8  推送诊断       推送 HTTP 状态码 + 服务端返回原文,排查推送必看
 *   B9  模式开关       填 "test" → 本次只测推送、不执行签到(测完清空)
 *
 * 【首次使用流程】(只需人工做一次)
 *   1) B4 填手机号 → 运行脚本 → 脚本发验证码,B2 会提示"验证码已发送"
 *   2) 把收到的验证码填进 B5 → 再运行一次 → 脚本换到明文 AT/RT 并写入 B6/B1
 *   3) B1/B6 已有值后,日常只需「定时任务」每天自动跑,脚本自己续期+签到
 *      (RT 每次续期都会轮换,脚本每次都回写,可长期免维护)
 *
 * 【注意】
 *   · 令牌等同于账号钥匙,整张表请设为「仅自己可编辑/可见」,不要分享。
 *   · 续期请求头已对齐官方 v3.27 口径:X-Domain: www.workbuddy.cn +
 *     Authorization(当前 AT) + X-User-Id(AT 的 sub) + X-Request-ID(32 hex)。
 *   · 上游鉴权失败时网关会返回 **HTML** 而不是 JSON,旧代码按 JSON 解析就会
 *     出现 "code=undefined"。本版对非 JSON 响应单独判定并输出真实状态码。
 *
 * 环境:AirScript(ES6 子集,禁用 class / 对象方法简写 / ?. / await)
 */

/* ===================== 可调参数 ===================== */
var HOST_TOKEN = ["https://copilot.tencent.com", "https://www.codebuddy.cn"];
var HOST_API = ["https://www.workbuddy.cn", "https://www.codebuddy.cn"];
var HOST_LOGIN = "https://www.workbuddy.cn";
var X_DOMAIN = "www.workbuddy.cn";
var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
  "(KHTML, like Gecko) Chrome/138.0.7204.251 Safari/537.36";
var TIMEOUT = 15000;
var MAX_RETRY = 3;

/* ===================== 基础工具 ===================== */
function cell(addr) {
  try { return Application.Range(addr); } catch (e1) {}
  try { return Application.ActiveSheet.Range(addr); } catch (e2) {}
  return null;
}

/* 读单元格:优先 Value2(2.0),再 Value(1.0 属性 / 2.0 方法),最后 Text。
   不优先用 Text —— 长令牌在单元格里显示文本可能被截断。 */
function readCell(addr) {
  var r = cell(addr);
  if (!r) return "";
  var v;
  try { v = r.Value2; } catch (e1) {}
  if (v === undefined || v === null || v === "") {
    try {
      v = r.Value;
      if (typeof v === "function") v = v();
    } catch (e2) {}
  }
  if (v === undefined || v === null || v === "") {
    try { v = r.Text; } catch (e3) {}
  }
  if (v === undefined || v === null) return "";
  return String(v);
}

function writeCell(addr, value) {
  var r = cell(addr);
  if (!r) return false;
  try { r.Value2 = value; return true; } catch (e1) {}
  try { r.Value = value; return true; } catch (e2) {}
  return false;
}

function trim(s) { return String(s === undefined || s === null ? "" : s).trim(); }

/* 令牌形态校验:真实 RT/AT 都是三段式 JWT(eyJ 开头、两个点)。
   刻意不按长度卡,避免短令牌被误判为"未登录"而反复要求重新登录。 */
function looksLikeJwt(s) {
  s = trim(s);
  if (s.length < 20 || s.indexOf("eyJ") !== 0) return false;
  return s.split(".").length === 3;
}

/* 纯 JS base64url 解码(不依赖 atob / Buffer,AirScript 内置函数里没有) */
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function b64urlDecode(s) {
  s = String(s).replace(/-/g, "+").replace(/_/g, "/");
  while (s.length % 4 !== 0) s += "=";
  var bytes = [];
  for (var i = 0; i < s.length; i += 4) {
    var c1 = B64.indexOf(s.charAt(i));
    var c2 = B64.indexOf(s.charAt(i + 1));
    var c3 = s.charAt(i + 2) === "=" ? 0 : B64.indexOf(s.charAt(i + 2));
    var c4 = s.charAt(i + 3) === "=" ? 0 : B64.indexOf(s.charAt(i + 3));
    if (c1 < 0 || c2 < 0) throw new Error("bad base64");
    bytes.push((c1 << 2) | (c2 >> 4));
    if (s.charAt(i + 2) !== "=") bytes.push(((c2 & 15) << 4) | (c3 >> 2));
    if (s.charAt(i + 3) !== "=") bytes.push(((c3 & 3) << 6) | c4);
  }
  return utf8Decode(bytes);
}

function utf8Decode(bytes) {
  var out = "";
  for (var i = 0; i < bytes.length;) {
    var b = bytes[i++];
    var cp;
    if (b < 0x80) cp = b;
    else if (b >= 0xC0 && b < 0xE0) { cp = ((b & 0x1F) << 6) | (bytes[i++] & 0x3F); }
    else if (b >= 0xE0 && b < 0xF0) {
      cp = ((b & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F);
    } else {
      cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3F) << 12) |
           ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F);
    }
    if (cp > 0xFFFF) {
      cp -= 0x10000;
      out += String.fromCharCode(0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF));
    } else {
      out += String.fromCharCode(cp);
    }
  }
  return out;
}

/* 解析 JWT payload(只解不验签,用于取 sub / 手机号) */
function jwtPayload(tok) {
  try {
    var parts = String(tok).split(".");
    if (parts.length < 2 || !parts[1]) return null;
    var p = JSON.parse(b64urlDecode(parts[1]));
    /* 必须确实是对象:若解出的是字符串/数组,属性访问会落到原型链上取到错误值 */
    if (!p || typeof p !== "object" || p instanceof Array) return null;
    return p;
  } catch (e) { return null; }
}

function jwtSub(tok) {
  var p = jwtPayload(tok);
  if (!p) return "";
  var s = p.sub;
  return (s === undefined || s === null) ? "" : String(s);
}

function nowStamp() {
  var d = new Date();
  function p(n) { return (n < 10 ? "0" : "") + n; }
  return (d.getMonth() + 1) + "月" + d.getDate() + "日 " + p(d.getHours()) + ":" + p(d.getMinutes());
}

/* 32 hex 请求 ID(前 12 位为毫秒时间戳,与官方口径一致) */
function newRequestId() {
  var ts = Date.now().toString(16);
  while (ts.length < 12) ts = "0" + ts;
  var s = "";
  for (var i = 0; i < 20; i++) s += "0123456789abcdef".charAt(Math.floor(Math.random() * 16));
  return ts + s;
}

/* ===================== HTTP ===================== */

/* 单次请求。返回 {status, raw, json, isJson}
 * 关键:上游网关鉴权失败会返回 HTML(openresty 401 页),必须与 JSON 区分开,
 * 否则旧代码 JSON.parse 失败后 code 取到 undefined,错误信息完全失真。 */
function once(method, url, headers, bodyText) {
  var out = { status: 0, raw: "", json: null, isJson: false };
  try {
    var opts = { method: method, timeout: TIMEOUT, headers: headers };
    if (bodyText !== null && bodyText !== undefined) opts.body = bodyText;
    var resp = HTTP.fetch(url, opts);
    try { out.status = resp.status; } catch (e1) { out.status = 0; }
    try { out.raw = resp.text(); } catch (e2) { out.raw = ""; }
    if (out.raw === null || out.raw === undefined) out.raw = "";
    out.raw = String(out.raw);
    try {
      out.json = JSON.parse(out.raw);
      out.isJson = true;
    } catch (e3) {
      out.json = null;
      out.isJson = false;
    }
  } catch (e4) {
    out.status = 0;
    out.raw = trim(e4 && e4.message ? e4.message : e4);
    out.json = null;
    out.isJson = false;
  }
  return out;
}

/* 带退避重试(仅网络异常 / 5xx 重试;4xx 与业务错误不重试) */
function request(method, url, headers, bodyText) {
  var last = null;
  for (var i = 1; i <= MAX_RETRY; i++) {
    last = once(method, url, headers, bodyText);
    var netBad = last.status === 0 || last.status === 502 || last.status === 503 ||
                 last.status === 504 || last.status >= 500;
    if (!netBad || i === MAX_RETRY) return last;
    try { Time.sleep(1500 * i); } catch (e) {}
  }
  return last;
}

function postJson(url, headers, payload) {
  var h = headers || {};
  if (!h["Content-Type"]) h["Content-Type"] = "application/json";
  return request("POST", url, h, JSON.stringify(payload === undefined ? {} : payload));
}

/* 多域名回退:拿到可解析的业务应答即返回 */
function postMulti(hosts, path, headers, payload) {
  var last = null;
  for (var i = 0; i < hosts.length; i++) {
    var r = postJson(hosts[i] + path, headers, payload);
    if (r.isJson) return r;
    last = r;
    if (r.status >= 400 && r.status < 500) break; /* 4xx 换域无意义 */
  }
  return last || { status: 0, raw: "所有域名均失败", json: null, isJson: false };
}

/* ===================== 错误描述 ===================== */
function describe(r, what) {
  if (!r) return what + ":无响应";
  if (r.status === 0) {
    return what + ":网络/网关不可达(HTTP 0)" + (r.raw ? "|" + r.raw.slice(0, 120) : "");
  }
  if (!r.isJson) {
    /* 上游网关返回 HTML —— 典型的鉴权被拦 */
    var kind = /401|Unauthorized/i.test(r.raw.slice(0, 200)) ? "(网关鉴权拦截,可能是令牌无效)" : "";
    return what + ":响应非 JSON(HTTP " + r.status + ")" + kind +
      "|" + r.raw.replace(/\s+/g, " ").slice(0, 120);
  }
  var j = r.json || {};
  var code = j.code;
  var msg = trim(j.msg || j.message || "");
  var tail = (code === undefined ? "" : "code=" + code + " ") + msg;
  if (r.status === 401 || r.status === 403) {
    return what + ":登录态失效(HTTP " + r.status + ")" + (tail ? "|" + tail : "");
  }
  if (code === 12153 || /invalid_grant|token format|unmarshal token/i.test(msg)) {
    return what + ":令牌已被服务端判定无效(" + (tail || "code=12153") + ")";
  }
  if (code === 10001) return what + ":今日已签到(幂等跳过)";
  if (r.status >= 500) return what + ":上游异常(HTTP " + r.status + ")|" + tail;
  return what + ":" + (tail || ("HTTP " + r.status));
}

/* ===================== 推送 ===================== */
/* 【重要】旧版这里有三个坑,本版全部修掉:
 *   1) qmsg 接口已升级到 v3:路径 /v3/send/{key},旧的 /send/{key} 官方已标注废弃。
 *   2) 旧版声明 Content-Type: x-www-form-urlencoded 却把 payload 交给
 *      postJson → JSON.stringify → 实际发的是 JSON 字符串。qmsg 按表单解析,
 *      取不到 msg 字段,直接返回"消息不能为空"(实测复现)。v3 用 GET 查询参数
 *      或 urlencoded 表单体,两种都实测过。
 *   3) 旧版 try/catch 吞掉一切异常、从不检查返回体 → 推送失败时毫无线索。
 *      本版检查返回 JSON 的 success/code,并把明细写进 B8。
 *
 * 【B3 配置格式】可用英文逗号/分号/换行分隔多个渠道,逐个推送:
 *   qmsg:<key>            → Qmsg酱 v3 私聊(推给后台绑定的 QQ)
 *   qmsg:<key>@<群号>     → Qmsg酱 v3 群推送(需先在后台完成群绑定)
 *   qmsg:<key>@qq:<QQ号>  → Qmsg酱 v3 指定 QQ 单聊
 *   SCTxxxx               → Server酱 Turbo(sctapi.ftqq.com)
 *   sctpxxxx              → Server酱³(sc3.ft07.com)
 *   pp:<token>            → PushPlus
 *   <裸 32 位 hex>         → 自动识别为 Qmsg酱
 *   <其它>                → 按 PushPlus token 处理
 */

var PUSH_TITLE = "";
var PUSH_BODY = "";
var PUSH_DETAIL = "";

/* qmsg 对内容做合规检测:URL、IPv4、连续数字串、敏感词会被判违规(code 500)。
   这里做保守清洗 —— 抹掉 URL、切断 6 位以上连续数字,避免"接口调用成功但消息被拦"。 */
function sanitizePushText(s) {
  var t = String(s === undefined || s === null ? "" : s);
  t = t.replace(/https?:\/\/[^\s]+/gi, "[链接]");
  t = t.replace(/[0-9]{6,}/g, "******");
  return t;
}

/* 把一次推送的 HTTP 结果渲染成人能看懂的诊断文本 */
function pushStatusText(r) {
  if (!r) return "无响应";
  var s = "HTTP " + r.status;
  if (r.isJson) {
    var j = r.json || {};
    s += " success=" + (j.success === true ? "true" : "false") +
         " code=" + (j.code === undefined ? "?" : j.code);
    var m = trim(j.message || j.msg || j.info || "");
    if (m) s += " msg=" + m;
  } else {
    s += " 非JSON|" + String(r.raw || "").replace(/\s+/g, " ").slice(0, 150);
  }
  return s;
}

/* 各渠道的成功判定 */
function isQmsgOk(r) {
  if (!r || !r.isJson) return false;
  var j = r.json || {};
  return j.success === true || j.code === 0;
}
function isPushPlusOk(r) {
  if (!r || !r.isJson) return false;
  var j = r.json || {};
  return j.code === 200 || j.code === 0;
}
function isSctOk(r) {
  if (!r || !r.isJson) return false;
  var j = r.json || {};
  return j.code === 0;
}

/* ---- 渠道 1:Qmsg酱 v3 ---- */
function pushQmsg(key, extra) {
  var text = sanitizePushText("【" + PUSH_TITLE + "】" + PUSH_BODY);
  var group = "", qq = "";
  if (extra) {
    if (extra.indexOf("qq:") === 0) qq = extra.slice(3);
    else group = extra;
  }
  var base = "https://qmsg.zendee.cn/v3/send/" + encodeURIComponent(key);

  /* 姿势 A:POST urlencoded 表单(官方 POST 示例) */
  var body = "msg=" + encodeURIComponent(text);
  if (group) body += "&group=" + encodeURIComponent(group);
  if (qq) body += "&qq=" + encodeURIComponent(qq);
  var r1 = request("POST", base, {
    "Content-Type": "application/x-www-form-urlencoded",
    "Accept": "application/json, text/plain, */*",
    "User-Agent": UA
  }, body);
  if (isQmsgOk(r1)) {
    return { ok: true, name: "qmsg", detail: "qmsg v3 POST表单 ✔ " + pushStatusText(r1) };
  }

  /* 姿势 B:GET 查询参数(官方 GET 示例)—— 兜住运行时对表单体的差异处理 */
  try { Time.sleep(1200); } catch (e) {}   /* qmsg 限流:同 Key 每 3 秒 1 条,错开再试 */
  var url = base + "?msg=" + encodeURIComponent(text);
  if (group) url += "&group=" + encodeURIComponent(group);
  if (qq) url += "&qq=" + encodeURIComponent(qq);
  var r2 = request("GET", url, {
    "Accept": "application/json, text/plain, */*",
    "User-Agent": UA
  }, null);
  if (isQmsgOk(r2)) {
    return { ok: true, name: "qmsg", detail: "qmsg v3 GET ✔ " + pushStatusText(r2) };
  }

  /* 姿势 C:旧版路径 /send/{key} —— 官方标注"即将废弃"但目前仍可用,兜底老 key */
  try { Time.sleep(1200); } catch (e2) {}
  var r3 = request("POST", "https://qmsg.zendee.cn/send/" + encodeURIComponent(key), {
    "Content-Type": "application/x-www-form-urlencoded",
    "Accept": "application/json, text/plain, */*",
    "User-Agent": UA
  }, body);
  if (isQmsgOk(r3)) {
    return { ok: true, name: "qmsg", detail: "qmsg 旧版路径 ✔ " + pushStatusText(r3) };
  }

  return {
    ok: false, name: "qmsg",
    detail: "qmsg 失败|v3POST " + pushStatusText(r1) + "|v3GET " + pushStatusText(r2) +
            "|旧路径 " + pushStatusText(r3)
  };
}

/* ---- 渠道 2:Server酱 Turbo ---- */
function pushServerChan(cfg) {
  var r = request("GET", "https://sctapi.ftqq.com/" + encodeURIComponent(cfg) +
    ".send?title=" + encodeURIComponent(PUSH_TITLE) +
    "&desp=" + encodeURIComponent(PUSH_BODY),
    { "Accept": "application/json, text/plain, */*", "User-Agent": UA }, null);
  if (isSctOk(r)) return { ok: true, name: "serverchan", detail: "Server酱 ✔ " + pushStatusText(r) };
  return { ok: false, name: "serverchan", detail: "Server酱失败 " + pushStatusText(r) };
}

/* ---- 渠道 3:Server酱³ ---- */
function pushServerChan3(cfg) {
  var r = request("GET", "https://sc3.ft07.com/" + encodeURIComponent(cfg) +
    ".send?title=" + encodeURIComponent(PUSH_TITLE) +
    "&desp=" + encodeURIComponent(PUSH_BODY),
    { "Accept": "application/json, text/plain, */*", "User-Agent": UA }, null);
  if (isSctOk(r)) return { ok: true, name: "serverchan3", detail: "Server酱³ ✔ " + pushStatusText(r) };
  return { ok: false, name: "serverchan3", detail: "Server酱³失败 " + pushStatusText(r) };
}

/* ---- 渠道 4:PushPlus ---- */
function pushPushPlus(token) {
  var r = request("GET", "https://www.pushplus.plus/send?token=" + encodeURIComponent(token) +
    "&title=" + encodeURIComponent(PUSH_TITLE) +
    "&content=" + encodeURIComponent(PUSH_BODY) + "&template=txt",
    { "Accept": "application/json, text/plain, */*", "User-Agent": UA }, null);
  if (isPushPlusOk(r)) return { ok: true, name: "pushplus", detail: "PushPlus ✔ " + pushStatusText(r) };
  return { ok: false, name: "pushplus", detail: "PushPlus失败 " + pushStatusText(r) };
}

/* 按 B3 里的一条配置分发到对应渠道 */
function pushOne(cfgRaw) {
  var cfg = trim(cfgRaw);
  if (!cfg) return null;
  var low = cfg.toLowerCase();
  if (low.indexOf("qmsg:") === 0) {
    var rest = cfg.slice(5);
    var at = rest.indexOf("@");
    if (at > 0) return pushQmsg(rest.slice(0, at), rest.slice(at + 1));
    return pushQmsg(rest, "");
  }
  if (cfg.indexOf("SCT") === 0) return pushServerChan(cfg);
  if (low.indexOf("sctp") === 0) return pushServerChan3(cfg);
  if (low.indexOf("pp:") === 0) return pushPushPlus(cfg.slice(3));
  /* 裸 hex 串(16~64 位)→ 视为 Qmsg酱 key。长度区间放宽是为了兼容不同版本 key。 */
  if (/^[0-9a-fA-F]{16,64}$/.test(cfg)) return pushQmsg(cfg, "");
  return pushPushPlus(cfg);
}

/* 主推送入口。返回简短结论(用于拼到 B2),完整明细存到 PUSH_DETAIL(写 B8)。 */
function notify(title, content) {
  PUSH_TITLE = title;
  PUSH_BODY = content;
  PUSH_DETAIL = "";

  var raw = trim(readCell("B3"));
  if (!raw) { PUSH_DETAIL = "B3 为空:未配置任何推送渠道"; return ""; }

  var parts = raw.split(/[,;\r\n]+/);
  var results = [];
  var anyOk = false;
  for (var i = 0; i < parts.length; i++) {
    if (!trim(parts[i])) continue;
    var one = pushOne(parts[i]);
    if (!one) continue;
    results.push(one);
    if (one.ok) anyOk = true;
  }

  if (!results.length) {
    PUSH_DETAIL = "B3 内容无法识别:" + raw.slice(0, 60);
    return "未识别渠道";
  }

  var details = [];
  var firstErr = "";
  for (var j = 0; j < results.length; j++) {
    details.push(results[j].detail);
    if (!results[j].ok && !firstErr) {
      firstErr = results[j].name + ":" +
        (results[j].detail.length > 70 ? results[j].detail.slice(0, 70) : results[j].detail);
    }
  }
  PUSH_DETAIL = details.join(" | ");

  if (anyOk) {
    var oks = [];
    for (var k = 0; k < results.length; k++) if (results[k].ok) oks.push(results[k].name);
    return "成功(" + oks.join("/") + ")";
  }
  return "失败 " + firstErr;
}

function finish(text) {
  var ps = notify("Buddy加油站签到", text);
  var msg = text + (ps ? "|推送" + ps : "");
  if (msg.length > 300) msg = msg.slice(0, 300);
  writeCell("B2", msg);
  writeCell("B8", PUSH_DETAIL.slice(0, 900));
}

/* ===================== 登录(令牌自举) ===================== */
function loginHeaders() {
  return {
    "Content-Type": "application/json",
    "Accept": "application/json, text/plain, */*",
    "Origin": HOST_LOGIN,
    "Referer": HOST_LOGIN + "/",
    "User-Agent": UA
  };
}

/* 步骤 1:发短信验证码 */
function sendSms(phone) {
  return postJson(HOST_LOGIN + "/v2/plugin/login/send-sms", loginHeaders(), { phone: phone });
}

/* 步骤 2:验证码换明文 AT / RT */
function loginWithCode(phone, code) {
  return postJson(HOST_LOGIN + "/v2/plugin/login/token", loginHeaders(), {
    login_method: "phone", phone: phone, sms_code: code
  });
}

function pick(obj, names) {
  for (var i = 0; i < names.length; i++) {
    var v = obj ? obj[names[i]] : null;
    if (v !== undefined && v !== null && v !== "") return String(v);
  }
  return "";
}

/* ===================== 令牌续期 ===================== */
function refreshToken(rt, at, uid) {
  var h = {
    "Content-Type": "application/json",
    "Accept": "application/json, text/plain, */*",
    "X-Refresh-Token": rt,
    "X-Auth-Refresh-Source": "plugin",
    "X-Domain": X_DOMAIN,
    "X-Request-ID": newRequestId()
  };
  /* 有当前 AT 时补齐身份头(官方 v3.27 口径);没有就不伪造身份 */
  if (at) {
    h["Authorization"] = "Bearer " + at;
    if (uid) h["X-User-Id"] = uid;
  }
  var r = postMulti(HOST_TOKEN, "/v2/plugin/auth/token/refresh", h, {});
  if (!r.isJson) return { ok: false, err: describe(r, "刷新失败") };
  var j = r.json || {};
  if (j.code !== 0 || !j.data) {
    return { ok: false, err: describe(r, "刷新失败") };
  }
  var data = j.data || {};
  var newAt = pick(data, ["accessToken", "access_token"]);
  var newRt = pick(data, ["refreshToken", "refresh_token"]);
  if (!newAt) return { ok: false, err: "刷新失败:响应中没有 accessToken" };
  return { ok: true, at: newAt, rt: newRt || rt };
}

/* ===================== 签到 ===================== */
function apiHeaders(at, uid) {
  var h = {
    "Content-Type": "application/json",
    "Accept": "application/json, text/plain, */*",
    "Origin": HOST_API[0],
    "Referer": HOST_API[0] + "/profile/growth-center",
    "User-Agent": UA
  };
  if (at) h["Authorization"] = "Bearer " + at;
  if (uid) h["X-User-Id"] = uid;
  return h;
}

function checkinStatus(at, uid) {
  var r = postMulti(HOST_API, "/v2/billing/meter/checkin-activity-status", apiHeaders(at, uid), {});
  if (!r.isJson) return { ok: false, err: describe(r, "状态查询失败") };
  if ((r.json || {}).code !== 0) return { ok: false, err: describe(r, "状态查询失败") };
  return { ok: true, data: (r.json || {}).data || {} };
}

function dailyCheckin(at, uid) {
  var r = postMulti(HOST_API, "/v2/billing/meter/daily-checkin", apiHeaders(at, uid), {});
  if (!r.isJson) return { ok: false, err: describe(r, "签到失败") };
  var j = r.json || {};
  if (j.code === 0) {
    var credit = (j.data || {}).credit;
    return { ok: true, credit: (credit === undefined || credit === null) ? 100 : credit };
  }
  var msg = trim(j.msg || j.message || "");
  if (j.code === 10001 || /已签到|已领取|already/i.test(msg)) {
    return { ok: true, already: true };
  }
  return { ok: false, err: describe(r, "签到失败") };
}

/* ===================== 主流程 ===================== */

/* 推送自检模式:B9 填 "test" 时只跑推送,完全不碰签到/令牌逻辑。
   用于把"推送不通"和"签到不通"两件事分开定位。 */
function pushSelfTest() {
  var raw = trim(readCell("B3"));
  if (!raw) {
    writeCell("B2", "推送自检失败:B3 为空,请先填推送凭据");
    writeCell("B8", "B3 为空:未配置任何推送渠道");
    return;
  }
  var ps = notify("Buddy加油站签到", nowStamp() + " 推送自检:如果你收到这条消息,说明推送链路正常");
  writeCell("B2", "推送自检:" + (ps || "未执行") + "|完整明细见 B8");
  writeCell("B8", PUSH_DETAIL.slice(0, 900));
}

function main() {
  if (trim(readCell("B9")).toLowerCase() === "test") { pushSelfTest(); return; }

  var rt = trim(readCell("B1"));
  var at = trim(readCell("B6"));
  var uid = trim(readCell("B7"));
  var phone = trim(readCell("B4"));
  var smsCode = trim(readCell("B5"));

  /* --- 情形 A:没有可用 RT,走登录自举 --- */
  if (!looksLikeJwt(rt)) {
    if (smsCode && phone) {
      var lr = loginWithCode(phone, smsCode);
      if (!lr.isJson) { finish(nowStamp() + " 登录失败:" + describe(lr, "登录")); return; }
      if ((lr.json || {}).code !== 0) {
        finish(nowStamp() + " 登录失败:" + describe(lr, "登录") +
          "(验证码错误请重填 B5;" + (lr.json || {}).msg + ")");
        return;
      }
      var ld = (lr.json || {}).data || {};
      rt = pick(ld, ["refreshToken", "refresh_token"]);
      at = pick(ld, ["accessToken", "access_token"]);
      if (!rt) { finish(nowStamp() + " 登录失败:响应中没有 refreshToken"); return; }
      writeCell("B1", rt);
      writeCell("B6", at);
      writeCell("B5", "");           /* 验证码一次性,立即清空 */
      uid = jwtSub(at);
      if (uid) writeCell("B7", uid);
    } else if (phone) {
      var sr = sendSms(phone);
      if (!sr.isJson || (sr.json || {}).code !== 0) {
        finish(nowStamp() + " 发送验证码失败:" + describe(sr, "发短信"));
        return;
      }
      finish(nowStamp() + " 验证码已发送到 " + phone +
        ",请把验证码填入 B5 后再次运行脚本");
      return;
    } else {
      finish(nowStamp() + " 未找到可用令牌:请在 B4 填手机号,先运行一次获取验证码," +
        "再把验证码填入 B5 运行一次完成登录");
      return;
    }
  }

  /* --- 情形 B:有 RT,先续期 --- */
  if (!uid && at) uid = jwtSub(at);
  var rr = refreshToken(rt, at, uid);
  if (!rr.ok) {
    /* 令牌失效 → 引导重新登录 */
    finish(nowStamp() + " " + rr.err + " → 请在 B4 填手机号、B5 填验证码,重新运行脚本完成登录");
    return;
  }
  var newAt = rr.at;
  var newRt = rr.rt;
  if (newRt && newRt !== rt) { writeCell("B1", newRt); rt = newRt; }
  if (newAt !== at) {
    writeCell("B6", newAt);
    at = newAt;
    var s = jwtSub(at);
    if (s) { uid = s; writeCell("B7", uid); }
  }

  /* --- 情形 C:签到(先查状态,未签才领) --- */
  var st = checkinStatus(at, uid);
  if (!st.ok) { finish(nowStamp() + " " + st.err); return; }
  var sd = st.data || {};

  var msg = "";
  if (sd.today_checked_in === true || sd.today_checked_in === 1) {
    msg = "今日已签到(幂等跳过)";
  } else {
    var cr = dailyCheckin(at, uid);
    if (!cr.ok) { finish(nowStamp() + " " + cr.err); return; }
    if (cr.already) msg = "今日已签到(幂等跳过)";
    else msg = "签到成功 +" + cr.credit + " 积分";
  }

  /* 回读状态,附上连签信息 */
  var extra = "";
  try {
    var st2 = checkinStatus(at, uid);
    if (st2.ok) {
      var d2 = st2.data || {};
      if (d2.streak_days !== undefined) extra += "|连签" + d2.streak_days + "天";
      if (d2.total_credits !== undefined) extra += ",累计" + d2.total_credits + "分";
      if (d2.is_streak_day) extra += ",今日为连签奖励日";
      if (d2.next_streak_day) extra += ",距下一连签奖励 " + d2.next_streak_day + " 天";
    }
  } catch (e) { /* 读数失败不影响签到结论 */ }

  finish(nowStamp() + " " + msg + extra);
}

main();

八、常见问题速查

现象 原因 处理
code=undefined 响应是 HTML 而非 JSON,JSON.parse 抛异常 用本版脚本;或自行判断响应是否 JSON
code=12153 refreshToken 已失效 B4 填手机号、B5 填验证码,重新登录
推送 key不存在 Key 填错或含多余字符 去控制台重新复制 Key
推送 消息不能为空 请求体格式不对(老版本问题) 用本版脚本即可
推送 HTTP 0 云端到推送渠道网络不通 换一个渠道(PushPlus / Server酱)
推送显示成功但 QQ 没收到 qmsg 后台未完成机器人绑定与接收对象确认 回 qmsg 控制台完成绑定
短信一直收不到 手机号格式错或发送过于频繁 检查 B4;错误码 14703 too frequent 表示太频繁
安全提醒
令牌等同于账号钥匙。这张表里会明文存放 refreshToken 与 accessToken,务必把整张表设置为「仅自己可编辑 / 可见」,不要共享链接、不要截图分享 B1/B6 的内容。

全文代码约 693 行,配套还有一份 _test_airscript.js(模拟环境跑 15 个分支)与 _test_real.js(curl 做同步 HTTP 桥接,打真实服务器的 11 个用例)。两个测试脚本都可以独立运行,验证接口契约与逻辑分支。

正文完
 0
评论(没有评论)