半年前写的一个金山文档 AirScript 签到脚本突然不能用了,报错只有一句没头没尾的 刷新失败 code=undefined undefined。这篇文章记录了从这条报错出发,如何一层层扒开令牌静态加密、网关返回 HTML 而非 JSON、以及推送接口协议变更三个独立问题,最后交付一份能在云端表格里长期免维护运行的签到脚本。
一、问题现场:一句没头没尾的报错
原脚本跑在金山文档(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:
服务端真的发出验证码服务端下发明文 AT / 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 时只跑推送,完全不碰签到与令牌逻辑。
六、部署步骤
- 在
kdocs.cn新建空白表格 B4填手机号,B3填推送凭据(可选但强烈建议)- 「效率 → 高级开发 → AirScript 脚本编辑器」新建脚本,粘贴下方完整代码
- 关键一步:编辑器工具栏「服务 → 添加服务」勾选网络 API,不加这个
HTTP对象根本不存在,脚本发不出任何请求 - 手动运行两次完成登录(第一次发码,第二次填码换令牌)
- 「效率 → 高级开发 → 定时任务」建每天 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 表示太频繁 |
全文代码约 693 行,配套还有一份 _test_airscript.js(模拟环境跑 15 个分支)与 _test_real.js(curl 做同步 HTTP 桥接,打真实服务器的 11 个用例)。两个测试脚本都可以独立运行,验证接口契约与逻辑分支。