diff --git a/README.md b/README.md index 360d2af..33d6912 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ GET /api/sms/stats - 实机 JS 副本:`js/qdreader_sign_autman.js` - 定时运行:`cron: 15 7 * * *`,每日 07:15 自动签到 - 手动运行:`起点签到` / `起点签到 ` -- Cookie 维护:`起点ck ` / `起点ck 查询` / `起点ck 删除 ` +- Cookie 维护:`起点ck ` / `起点ck 查询` / `起点ck 导出` / `起点ck 删除 ` +- v2.2.0:增强 Autman 存储/消息/请求兼容,增加重试、签名返回兼容、定时静默开关、输出脱敏和 Node VM 测试。 Raw 导入: @@ -76,6 +77,12 @@ Raw 导入: https://gitea.chickliu.fun/Hermes/autman-plugins/raw/branch/main/plugins/qdreader/qdreader_sign_autman.js ``` +验证: + +```bash +node tests/qdreader_plugin.test.mjs +``` + ## 文档 - [Autman 插件开发规则与仓库约定](docs/autman-plugin-rules.md) diff --git a/js/qdreader_sign_autman.js b/js/qdreader_sign_autman.js index 6f84540..c34b102 100644 --- a/js/qdreader_sign_autman.js +++ b/js/qdreader_sign_autman.js @@ -4,54 +4,101 @@ //[author: Hermes] //[service: BOSS] //[class: 工具类] -//[version: 2.1.0] -//[platform: web,qq,wx,tg,tb] +//[version: 2.2.0] +//[platform: web,qq,wx,tg,tb,fs,we] //[public: false] //[price: 0] //[disable: false] -//[description: 起点读书(QDReader) Autman 签到插件:保存 QX 抓到的 H5 Cookie,支持手动“起点签到”和每日 07:15 定时签到。签名沿用上游 QDReader 的 api.120399.xyz 签名网关。] +//[description: 起点读书(QDReader) Autman 签到插件:保存 QX 抓到的 H5 Cookie,支持手动“起点签到”和每日 07:15 定时签到;带多环境存储兼容、请求重试、签名响应兼容、结果摘要和安全脱敏。] //[rule: ^起点(ck|CK|cookie|Cookie|签到|读书)(.*)$] //[rule: ^qdreader(.*)$] //[admin: true] //[priority: 600] //[cron: 15 7 * * *] //[param: {"required":false,"key":"otto.qdreader_cookie_json","bool":false,"placeholder":"{\"uid\":\"cookie\"}","name":"QDReader Cookie JSON","desc":"机器人交互维护,JSON Map 格式"}] -//[param: {"required":false,"key":"otto.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz"}] +//[param: {"required":false,"key":"otto.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz,可替换为兼容 /sign 的网关"}] +//[param: {"required":false,"key":"otto.qdreader_retry_count","bool":false,"placeholder":"1","name":"请求失败重试次数","desc":"默认 1,最大 3,仅对网络异常/非业务异常重试"}] +//[param: {"required":false,"key":"otto.qdreader_cron_silent","bool":true,"placeholder":"false","name":"定时无账号时静默","desc":"true 时定时触发且没有账号不回复"}] +var BUCKET = "otto" var STORE_KEY = "qdreader_cookie_json" var SIGN_API_KEY = "qdreader_sign_api" +var RETRY_KEY = "qdreader_retry_count" +var CRON_SILENT_KEY = "qdreader_cron_silent" var DEFAULT_SIGN_API = "https://api.120399.xyz" var QIDIAN_BASE = "https://h5.if.qidian.com" +var CHECKIN_PATH = "/argus/api/v3/checkin/checkin" + +function defined(name) { try { return typeof this[name] !== "undefined" } catch (e) { return false } } +function globalValue(name) { try { return this[name] } catch (e) { return undefined } } +function safeJsonParse(s, def) { try { return JSON.parse(s) } catch (e) { return def } } +function isObject(x) { return !!x && typeof x === "object" && !Array.isArray(x) } +function trim(s) { return String(s === undefined || s === null ? "" : s).replace(/^\s+|\s+$/g, "") } +function lower(s) { return String(s || "").toLowerCase() } + +function cfgGet(key, def) { + try { if (typeof get === "function") { var v = get(key); if (v !== undefined && v !== null && String(v) !== "") return v } } catch (e) {} + try { if (typeof bucketGet === "function") { var bv = bucketGet(BUCKET, key); if (bv !== undefined && bv !== null && String(bv) !== "") return bv } } catch (e) {} + return def +} +function cfgSet(key, value) { + var s = String(value) + var ok = false + try { if (typeof set === "function") { set(key, s); ok = true } } catch (e) {} + try { if (typeof bucketSet === "function") { bucketSet(BUCKET, key, s); ok = true } } catch (e) {} + if (!ok) throw new Error("当前 Autman 环境没有 set/bucketSet,无法保存配置") + return s +} +function getBool(key, def) { + var v = cfgGet(key, def ? "true" : "false") + if (typeof v === "boolean") return v + v = lower(trim(v)) + if (v === "1" || v === "true" || v === "yes" || v === "on" || v === "启用" || v === "是") return true + if (v === "0" || v === "false" || v === "no" || v === "off" || v === "禁用" || v === "否") return false + return !!def +} +function getInt(key, def, min, max) { + var n = parseInt(cfgGet(key, String(def)), 10) + if (isNaN(n)) n = def + if (min !== undefined && n < min) n = min + if (max !== undefined && n > max) n = max + return n +} function reply(msg) { var out = typeof msg === "string" ? msg : JSON.stringify(msg) - if (typeof sender !== "undefined" && sender.reply) sender.reply(out) - else if (typeof sendText === "function") sendText(out) - else if (typeof push === "function") push({ content: out }) - else console.log(out) + if (!out) return + try { if (typeof sender !== "undefined" && sender && sender.reply) { sender.reply(out); return } } catch (e) {} + try { if (typeof sendText === "function") { sendText(out); return } } catch (e) {} + try { if (typeof push === "function") { push({ content: out }); return } } catch (e) {} + try { console.log(out) } catch (e) {} } -function safeJsonParse(s, def) { try { return JSON.parse(s) } catch (e) { return def } } +function debug(msg) { try { if (typeof Debug === "function") Debug(String(msg)); else console.log(String(msg)) } catch (e) {} } + function getStore() { - var raw = get(STORE_KEY) || "{}" + var raw = cfgGet(STORE_KEY, "{}") var obj = safeJsonParse(raw, {}) - if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {} - return obj + if (!isObject(obj)) return {} + var clean = {} + for (var uid in obj) if (obj.hasOwnProperty(uid) && obj[uid]) clean[String(uid)] = String(obj[uid]) + return clean } -function setStore(obj) { var s = JSON.stringify(obj); set(STORE_KEY, s); return s } +function setStore(obj) { return cfgSet(STORE_KEY, JSON.stringify(obj || {})) } + function parseCookieString(cookie) { var jar = {} String(cookie || "").split(";").forEach(function (part) { var i = part.indexOf("=") if (i <= 0) return - var k = part.slice(0, i).trim() - var v = part.slice(i + 1).trim() + var k = trim(part.slice(0, i)) + var v = trim(part.slice(i + 1)) if (k) jar[k] = v }) return jar } function cookieToString(jar) { var arr = [] - for (var k in jar) if (jar[k] !== undefined && jar[k] !== null && jar[k] !== "") arr.push(k + "=" + jar[k]) + for (var k in jar) if (jar.hasOwnProperty(k) && jar[k] !== undefined && jar[k] !== null && jar[k] !== "") arr.push(k + "=" + jar[k]) return arr.join("; ") } function b64decode(s) { @@ -59,41 +106,62 @@ function b64decode(s) { try { if (typeof Buffer !== "undefined") return Buffer.from(s, "base64").toString("utf8") } catch (e) {} try { return decodeURIComponent(escape(atob(s))) } catch (e) { return "" } } -function uidFromQDHeader(qdheader) { var decoded = b64decode(qdheader); return decoded ? String(decoded.split("|")[0] || "").trim() : "" } +function uidFromQDHeader(qdheader) { + var decoded = b64decode(qdheader) + var uid = decoded ? trim(decoded.split("|")[0] || "") : "" + return /^\d+$/.test(uid) ? uid : "" +} function extractUid(cookie) { var jar = parseCookieString(cookie) var uid = uidFromQDHeader(jar.QDHeader || jar.qdheader || jar.QDHEADER || "") if (uid) return uid if (jar.uid) return String(jar.uid) + if (jar.userid) return String(jar.userid) return "" } -function maskCookie(cookie) { cookie = String(cookie || ""); return cookie.length <= 24 ? cookie : cookie.slice(0, 12) + "..." + cookie.slice(-8) } +function maskCookie(cookie) { + cookie = String(cookie || "") + if (!cookie) return "" + var jar = parseCookieString(cookie) + var keys = [] + for (var k in jar) if (jar.hasOwnProperty(k)) keys.push(k) + var prefix = keys.slice(0, 5).join(",") + return "[cookie keys:" + prefix + (keys.length > 5 ? ",..." : "") + "; len=" + cookie.length + "]" +} +function maskUid(uid) { + uid = String(uid || "") + if (uid.length <= 4) return uid + return uid.slice(0, 2) + "***" + uid.slice(-2) +} + function contentText() { var s = "" try { if (typeof getContent === "function") s = getContent() || "" } catch (e) {} try { if (!s && typeof GetContent === "function") s = GetContent() || "" } catch (e) {} - try { if (!s && typeof sender !== "undefined" && sender.getContent) s = sender.getContent() || "" } catch (e) {} - try { if (!s && typeof sender !== "undefined" && sender.getMessage) s = sender.getMessage() || "" } catch (e) {} + try { if (!s && typeof input === "function") s = input() || "" } catch (e) {} + try { if (!s && typeof sender !== "undefined" && sender && sender.getContent) s = sender.getContent() || "" } catch (e) {} + try { if (!s && typeof sender !== "undefined" && sender && sender.getMessage) s = sender.getMessage() || "" } catch (e) {} try { if (!s && typeof message !== "undefined") s = String(message) } catch (e) {} - return String(s || "").trim() + return trim(s) } function usage() { return [ - "起点读书签到插件", + "起点读书签到插件 v2.2.0", "提交CK:起点ck ", "签到全部:起点签到", "签到单号:起点签到 ", "查询CK:起点ck 查询", - "导出CK:起点ck 导出", - "删除CK:起点ck 删除 ", - "说明:QX只负责抓cookie;签到由本 Autman 插件执行。" + "导出CK:起点ck 导出(会输出完整 Cookie,仅管理员自用)", + "删除CK:起点ck 删除 ", + "配置签名网关:Autman 参数 otto.qdreader_sign_api,默认 " + DEFAULT_SIGN_API, + "定时:每日 07:15 自动执行;QX 只负责抓 cookie。" ].join("\n") } function stripCommand(text) { var s = String(text || "") - if (/^起点(?:签到|读书)(?:\s|$)/i.test(s)) return s.replace(/^起点(?:签到|读书)\s*/i, "").trim() - if (/^起点(?:ck|CK|cookie|Cookie)(?:\s|$)/i.test(s)) return s.replace(/^起点(?:ck|CK|cookie|Cookie)\s*/i, "").trim() - return s.replace(/^qdreader\s*/i, "").trim() + if (/^起点(?:签到|读书)(?:\s|$)/i.test(s)) return trim(s.replace(/^起点(?:签到|读书)\s*/i, "")) + if (/^起点(?:ck|CK|cookie|Cookie)(?:\s|$)/i.test(s)) return trim(s.replace(/^起点(?:ck|CK|cookie|Cookie)\s*/i, "")) + return trim(s.replace(/^qdreader\s*/i, "")) } function commandName(text) { if (/^起点(?:签到|读书)(?:\s|$)/i.test(text)) return "sign" @@ -103,10 +171,11 @@ function commandName(text) { function qs(obj) { var parts = [] for (var k in (obj || {})) { + if (!obj.hasOwnProperty(k)) continue var v = obj[k] if (v === undefined || v === null) continue if (Array.isArray(v)) { for (var i = 0; i < v.length; i++) parts.push(encodeURIComponent(k + "[" + i + "]") + "=" + encodeURIComponent(String(v[i]))) } - else if (typeof v === "object") { for (var sk in v) parts.push(encodeURIComponent(k + "[" + sk + "]") + "=" + encodeURIComponent(String(v[sk]))) } + else if (typeof v === "object") { for (var sk in v) if (v.hasOwnProperty(sk)) parts.push(encodeURIComponent(k + "[" + sk + "]") + "=" + encodeURIComponent(String(v[sk]))) } else parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v))) } return parts.join("&") @@ -117,9 +186,11 @@ function normalizeCookie(cookie) { if (session) jar.sessionyw = session var appid = jar.appId || jar.appid || "" if (appid) jar.appId = appid - return { jar: jar, cookie: cookieToString(jar), qdh: jar.QDHeader || jar.qdh || "", qdheader: jar.QDHeader || jar.qdheader || "", uid: extractUid(cookie), guid: jar.gwguid || jar.guid || "", qimei: jar.qimei || "", qimei36: jar.qimei36 || jar.QIMEI36 || "" } + var guid = jar.ywguid || jar.gwguid || jar.guid || "" + return { jar: jar, cookie: cookieToString(jar), qdh: jar.QDHeader || jar.qdh || "", qdheader: jar.QDHeader || jar.qdheader || "", uid: extractUid(cookie), guid: guid, qimei: jar.qimei || jar.QIMEI || "", qimei36: jar.qimei36 || jar.QIMEI36 || "" } } function httpReq(opt) { + if (!opt.headers) opt.headers = {} if (typeof request === "function") return request(opt) if (typeof $request === "function") return $request(opt) throw new Error("当前 Autman 环境没有 request/$request 方法,无法发起签到请求") @@ -129,21 +200,43 @@ function parseResp(res) { if (typeof body === "string") return safeJsonParse(body, body) return body } +function requestWithRetry(opt, retryCount) { + var lastErr = null + var attempts = Math.max(1, (retryCount || 0) + 1) + for (var i = 0; i < attempts; i++) { + try { + return httpReq(opt) + } catch (e) { + lastErr = e + debug("QDReader request retry " + (i + 1) + "/" + attempts + " failed: " + (e && e.message ? e.message : String(e))) + } + } + throw lastErr || new Error("请求失败") +} function postJson(url, data) { - return parseResp(httpReq({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })) + var retry = getInt(RETRY_KEY, 1, 0, 3) + return parseResp(requestWithRetry({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }, retry)) +} +function normalizeSignHeaders(signed) { + if (typeof signed === "string") signed = safeJsonParse(signed, {}) + if (!signed) throw new Error("签名网关无返回") + var code = signed.code !== undefined ? signed.code : (signed.retcode !== undefined ? signed.retcode : signed.status) + var ok = code === undefined || code === 0 || code === "0" || code === 200 || code === "200" || code === "ok" || code === true + var data = signed.data || signed.result || signed.headers || signed + var headers = data && (data.headers || data.header || data) + if (!ok || !isObject(headers)) throw new Error("签名失败:" + JSON.stringify(signed).slice(0, 500)) + return headers } function qidianRequest(path, method, data, cookie) { method = String(method || "GET").toUpperCase() var info = normalizeCookie(cookie) - var signApi = (get(SIGN_API_KEY) || DEFAULT_SIGN_API).replace(/\/$/, "") + if (!info.uid) throw new Error("Cookie 缺少 uid/QDHeader") + var signApi = String(cfgGet(SIGN_API_KEY, DEFAULT_SIGN_API)).replace(/\/+$/, "") var payload = { qdh: info.qdh, qdheader: info.qdheader, url: QIDIAN_BASE + path, method: method, data: data || {}, uid: info.uid, guid: info.guid, qimei: info.qimei, qimei36: info.qimei36 } - var signed = postJson(signApi + "/sign", payload) - if (typeof signed === "string") signed = safeJsonParse(signed, {}) - if (!signed || (signed.code !== 200 && signed.code !== "200" && signed.code !== 0 && signed.code !== "0") || !signed.data) throw new Error("签名失败:" + JSON.stringify(signed)) - var headers = signed.data.headers || signed.data + var headers = normalizeSignHeaders(postJson(signApi + "/sign", payload)) headers.Cookie = info.cookie if (!headers["User-Agent"]) headers["User-Agent"] = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/QDReaderiOS/5.9.456/728/QDReaderAppstore" - var req = { url: QIDIAN_BASE + path, method: method.toLowerCase(), dataType: "json", timeOut: 10000, headers: headers } + var req = { url: QIDIAN_BASE + path, method: lower(method), dataType: "json", timeOut: 10000, headers: headers } if (method === "GET") { var query = qs(data || {}) if (query) req.url += (req.url.indexOf("?") >= 0 ? "&" : "?") + query @@ -151,81 +244,113 @@ function qidianRequest(path, method, data, cookie) { req.headers["Content-Type"] = req.headers["Content-Type"] || "application/x-www-form-urlencoded" req.body = qs(data || {}) } - var out = parseResp(httpReq(req)) + var retry = getInt(RETRY_KEY, 1, 0, 3) + var out = parseResp(requestWithRetry(req, retry)) if (typeof out === "string") out = safeJsonParse(out, out) return out } +function resultCode(obj) { return obj && (obj.Result !== undefined ? obj.Result : obj.Code !== undefined ? obj.Code : obj.code !== undefined ? obj.code : obj.retcode) } function resultOk(obj) { - var code = obj && (obj.Result !== undefined ? obj.Result : obj.Code !== undefined ? obj.Code : obj.code) - return code === undefined || code === 0 || code === "0" || code === 200 || code === "200" + var code = resultCode(obj) + var status = obj && (obj.status || obj.Status) + return code === undefined || code === 0 || code === "0" || code === 200 || code === "200" || status === "ok" || status === "success" +} +function resultMsg(obj) { + if (!obj) return "" + return obj.Message || obj.Msg || obj.message || obj.msg || obj.toast || (obj.data && (obj.data.message || obj.data.msg || obj.data.toast)) || "" } -function resultMsg(obj) { return (obj && (obj.Message || obj.Msg || obj.message || obj.msg)) || "" } function doCheckin(uid, cookie) { - var res = qidianRequest("/argus/api/v3/checkin/checkin", "POST", {}, cookie) + var res = qidianRequest(CHECKIN_PATH, "POST", {}, cookie) var ok = resultOk(res) - var msg = resultMsg(res) || (ok ? "签到请求成功" : "签到失败") + var msg = resultMsg(res) || (ok ? "签到请求成功" : ("签到失败" + (resultCode(res) !== undefined ? " code=" + resultCode(res) : ""))) return { uid: uid, ok: ok, msg: msg, raw: res } } function signAll(targetUid) { var store = getStore() - var uids = Object.keys(store) + var uids = Object.keys(store).sort() if (targetUid) uids = uids.filter(function (u) { return u === targetUid }) if (!uids.length) return targetUid ? ("未找到 uid: " + targetUid) : "还没有保存起点读书CK" + var okCount = 0 + var failCount = 0 var lines = ["起点读书签到结果:"] for (var i = 0; i < uids.length; i++) { var uid = uids[i] try { var r = doCheckin(uid, store[uid]) - lines.push((r.ok ? "✅ " : "❌ ") + uid + ":" + r.msg) + if (r.ok) okCount++; else failCount++ + lines.push((r.ok ? "✅ " : "❌ ") + maskUid(uid) + ":" + r.msg) } catch (e) { - lines.push("❌ " + uid + ":" + (e && e.message ? e.message : String(e))) + failCount++ + lines.push("❌ " + maskUid(uid) + ":" + (e && e.message ? e.message : String(e))) } } + lines.push("汇总:成功 " + okCount + " / 失败 " + failCount + " / 总计 " + uids.length) return lines.join("\n") } - -function main() { - var text = contentText() - var isCronRun = !text - var cmd = isCronRun ? "sign" : commandName(text) - var arg = isCronRun ? "" : stripCommand(text).replace(/^sign\s*/i, "").trim() - if (!arg && cmd !== "sign") { reply(usage()); return } - if (/^(帮助|help|h)$/i.test(arg)) { reply(usage()); return } - - var store = getStore() - - if (cmd === "sign" || /^(签到|sign|run)(?:\s+|$)/i.test(arg)) { - var target = arg.replace(/^(签到|sign|run)\s*/i, "").trim() - reply(signAll(target)) - return +function importJson(arg, store) { + var obj = safeJsonParse(arg, null) + if (!isObject(obj)) return "JSON格式错误,示例:起点ck json {\"uid\":\"cookie\"}" + var count = 0 + for (var uid in obj) { + if (!obj.hasOwnProperty(uid) || !obj[uid]) continue + var cookie = String(obj[uid]) + var realUid = extractUid(cookie) || uid + store[String(realUid)] = cookie + count++ } - - if (/^(查询|状态|status|list)$/i.test(arg)) { - var uids = Object.keys(store) - reply(uids.length ? ("起点读书CK:共 " + uids.length + " 个账号\n" + uids.join("\n") + "\n发送 起点签到 可执行签到") : "还没有保存起点读书CK") - return - } - if (/^(导出|export)$/i.test(arg)) { reply("QDREADER_COOKIE=" + JSON.stringify(store)); return } + setStore(store) + return "起点读书CK JSON已导入/更新 " + count + " 个账号,当前共 " + Object.keys(store).length + " 个账号\n发送 起点签到 可执行签到" +} +function handleDelete(arg, store) { var del = arg.match(/^(删除|delete|del|rm)\s+(\S+)/i) - if (del) { var duid = del[2]; var existed = !!store[duid]; delete store[duid]; setStore(store); reply(existed ? ("已删除 uid: " + duid) : ("未找到 uid: " + duid)); return } - var jsonMatch = arg.match(/^json\s+([\s\S]+)$/i) - if (jsonMatch) { - var obj = safeJsonParse(jsonMatch[1], null) - if (!obj || typeof obj !== "object" || Array.isArray(obj)) { reply("JSON格式错误,示例:起点ck json {\"uid\":\"cookie\"}"); return } - var count = 0 - for (var uid in obj) { if (!obj[uid]) continue; store[uid] = String(obj[uid]); count++ } - setStore(store); reply("起点读书CK JSON已导入/更新 " + count + " 个账号,当前共 " + Object.keys(store).length + " 个账号\n发送 起点签到 可执行签到"); return + if (!del) return null + var duid = del[2] + if (/^(all|全部|所有)$/i.test(duid)) { + var n = Object.keys(store).length + setStore({}) + return "已删除全部起点读书CK,共 " + n + " 个账号" } + var existed = !!store[duid] + delete store[duid] + setStore(store) + return existed ? ("已删除 uid: " + duid) : ("未找到 uid: " + duid) +} +function handleSaveCookie(arg, store) { var cookie = arg - if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) { reply("未识别到 Cookie。请发送:起点ck "); return } + if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) return "未识别到 Cookie。请发送:起点ck " var uid = extractUid(cookie) - if (!uid) { reply("无法从 Cookie 识别 uid。请确认 QX 抓的是起点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。"); return } + if (!uid) return "无法从 Cookie 识别 uid。请确认 QX 抓的是起点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。" var isNew = !store[uid] var changed = store[uid] !== cookie store[uid] = cookie setStore(store) - reply(["起点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, "账号数: " + Object.keys(store).length, "cookie: " + maskCookie(cookie), "签到:发送 起点签到"].join("\n")) + return ["起点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, "账号数: " + Object.keys(store).length, "cookie: " + maskCookie(cookie), "签到:发送 起点签到"].join("\n") +} +function main() { + var text = contentText() + var isCronRun = !text + var cmd = isCronRun ? "sign" : commandName(text) + var arg = isCronRun ? "" : trim(stripCommand(text).replace(/^sign\s*/i, "")) + if (!arg && cmd !== "sign") { reply(usage()); return } + if (/^(帮助|help|h)$/i.test(arg)) { reply(usage()); return } + var store = getStore() + if (cmd === "sign" || /^(签到|sign|run)(?:\s+|$)/i.test(arg)) { + var target = trim(arg.replace(/^(签到|sign|run)\s*/i, "")) + var out = signAll(target) + if (!(isCronRun && getBool(CRON_SILENT_KEY, false) && out === "还没有保存起点读书CK")) reply(out) + return + } + if (/^(查询|状态|status|list)$/i.test(arg)) { + var uids = Object.keys(store).sort() + reply(uids.length ? ("起点读书CK:共 " + uids.length + " 个账号\n" + uids.map(maskUid).join("\n") + "\n发送 起点签到 可执行签到") : "还没有保存起点读书CK") + return + } + if (/^(导出|export)$/i.test(arg)) { reply("QDREADER_COOKIE=" + JSON.stringify(store)); return } + var delMsg = handleDelete(arg, store) + if (delMsg !== null) { reply(delMsg); return } + var jsonMatch = arg.match(/^json\s+([\s\S]+)$/i) + if (jsonMatch) { reply(importJson(jsonMatch[1], store)); return } + reply(handleSaveCookie(arg, store)) } main() - diff --git a/plugins/qdreader/README.md b/plugins/qdreader/README.md index 7519305..04ebb14 100644 --- a/plugins/qdreader/README.md +++ b/plugins/qdreader/README.md @@ -1,5 +1,9 @@ # 起点读书签到 Autman 插件 +起点读书(QDReader)Autman 签到插件。QX 只负责抓取 Cookie,签到由本插件在 Autman 内完成。 + +## 文件位置 + 正式插件文件: ```text @@ -22,10 +26,22 @@ https://gitea.chickliu.fun/Hermes/autman-plugins/raw/branch/main/js/qdreader_sig ## 功能 - QX 抓到 Cookie 后,把 `起点ck ` 发给 Autman 保存。 -- 手动执行:`起点签到`。 -- 手动单账号:`起点签到 `。 -- 定时执行:头注 `//[cron: 15 7 * * *]`,每日 07:15 自动签到。 -- 查询/维护:`起点ck 查询`、`起点ck 导出`、`起点ck 删除 `。 +- 手动全部签到:`起点签到`。 +- 手动单账号签到:`起点签到 `。 +- 定时签到:头注 `//[cron: 15 7 * * *]`,每日 07:15 自动签到。 +- 查询/维护:`起点ck 查询`、`起点ck 导出`、`起点ck 删除 `。 +- 签到结果按成功/失败汇总;普通状态输出 uid 会脱敏,导出命令除外。 + +## v2.2.0 优化点 + +- 存储兼容:同时兼容 `get/set` 和 `bucketGet/bucketSet("otto", key)`。 +- 消息兼容:兼容 `getContent/GetContent/input/sender.getContent/sender.getMessage`。 +- 请求兼容:兼容 `request/$request`,并统一解析 `body/data` JSON。 +- 签名兼容:兼容 `{code:0,data:{headers:{...}}}`、`{code:200,data:{...}}`、`{headers:{...}}` 等签名网关返回形态。 +- 网络健壮性:新增 `otto.qdreader_retry_count`,默认重试 1 次,最大 3 次。 +- 定时体验:新增 `otto.qdreader_cron_silent`,可让定时触发且无账号时静默。 +- 安全脱敏:保存/查询/签到结果不直接输出完整 Cookie;`导出` 是管理员主动行为,会输出完整 JSON。 +- 测试覆盖:`tests/qdreader_plugin.test.mjs` 覆盖手动、定时、bucket 存储、重试、查询脱敏、删除全部。 ## 配置 @@ -34,10 +50,44 @@ Autman 参数: ```text otto.qdreader_cookie_json = {"uid":"cookie"} otto.qdreader_sign_api = https://api.120399.xyz +otto.qdreader_retry_count = 1 +otto.qdreader_cron_silent = false ``` +说明: + +- `qdreader_cookie_json`:机器人命令维护,JSON Map 格式。 +- `qdreader_sign_api`:兼容 `/sign` 的签名网关,默认 `https://api.120399.xyz`。 +- `qdreader_retry_count`:请求异常重试次数,默认 1,范围 0-3。 +- `qdreader_cron_silent`:`true` 时,如果定时触发但没有账号,不发送提醒。 + +## 命令 + +```text +起点ck +起点ck json {"uid":"cookie"} +起点ck 查询 +起点ck 导出 +起点ck 删除 +起点ck 删除 all +起点签到 +起点签到 +qdreader sign +``` + +## 签名与签到流程 + 签名与签到流程沿用上游 QDReader: 1. 从 Cookie 解析 `QDHeader/qdh/uid/guid/qimei/qimei36`。 -2. `POST https://api.120399.xyz/sign` 获取签名头。 +2. `POST https://api.120399.xyz/sign` 获取起点请求签名头。 3. `POST https://h5.if.qidian.com/argus/api/v3/checkin/checkin` 完成签到。 +4. 汇总每个账号签到结果。 + +## 本地验证 + +```bash +node -c plugins/qdreader/qdreader_sign_autman.js +node -c js/qdreader_sign_autman.js +node tests/qdreader_plugin.test.mjs +``` diff --git a/plugins/qdreader/qdreader_sign_autman.js b/plugins/qdreader/qdreader_sign_autman.js index 6f84540..c34b102 100644 --- a/plugins/qdreader/qdreader_sign_autman.js +++ b/plugins/qdreader/qdreader_sign_autman.js @@ -4,54 +4,101 @@ //[author: Hermes] //[service: BOSS] //[class: 工具类] -//[version: 2.1.0] -//[platform: web,qq,wx,tg,tb] +//[version: 2.2.0] +//[platform: web,qq,wx,tg,tb,fs,we] //[public: false] //[price: 0] //[disable: false] -//[description: 起点读书(QDReader) Autman 签到插件:保存 QX 抓到的 H5 Cookie,支持手动“起点签到”和每日 07:15 定时签到。签名沿用上游 QDReader 的 api.120399.xyz 签名网关。] +//[description: 起点读书(QDReader) Autman 签到插件:保存 QX 抓到的 H5 Cookie,支持手动“起点签到”和每日 07:15 定时签到;带多环境存储兼容、请求重试、签名响应兼容、结果摘要和安全脱敏。] //[rule: ^起点(ck|CK|cookie|Cookie|签到|读书)(.*)$] //[rule: ^qdreader(.*)$] //[admin: true] //[priority: 600] //[cron: 15 7 * * *] //[param: {"required":false,"key":"otto.qdreader_cookie_json","bool":false,"placeholder":"{\"uid\":\"cookie\"}","name":"QDReader Cookie JSON","desc":"机器人交互维护,JSON Map 格式"}] -//[param: {"required":false,"key":"otto.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz"}] +//[param: {"required":false,"key":"otto.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz,可替换为兼容 /sign 的网关"}] +//[param: {"required":false,"key":"otto.qdreader_retry_count","bool":false,"placeholder":"1","name":"请求失败重试次数","desc":"默认 1,最大 3,仅对网络异常/非业务异常重试"}] +//[param: {"required":false,"key":"otto.qdreader_cron_silent","bool":true,"placeholder":"false","name":"定时无账号时静默","desc":"true 时定时触发且没有账号不回复"}] +var BUCKET = "otto" var STORE_KEY = "qdreader_cookie_json" var SIGN_API_KEY = "qdreader_sign_api" +var RETRY_KEY = "qdreader_retry_count" +var CRON_SILENT_KEY = "qdreader_cron_silent" var DEFAULT_SIGN_API = "https://api.120399.xyz" var QIDIAN_BASE = "https://h5.if.qidian.com" +var CHECKIN_PATH = "/argus/api/v3/checkin/checkin" + +function defined(name) { try { return typeof this[name] !== "undefined" } catch (e) { return false } } +function globalValue(name) { try { return this[name] } catch (e) { return undefined } } +function safeJsonParse(s, def) { try { return JSON.parse(s) } catch (e) { return def } } +function isObject(x) { return !!x && typeof x === "object" && !Array.isArray(x) } +function trim(s) { return String(s === undefined || s === null ? "" : s).replace(/^\s+|\s+$/g, "") } +function lower(s) { return String(s || "").toLowerCase() } + +function cfgGet(key, def) { + try { if (typeof get === "function") { var v = get(key); if (v !== undefined && v !== null && String(v) !== "") return v } } catch (e) {} + try { if (typeof bucketGet === "function") { var bv = bucketGet(BUCKET, key); if (bv !== undefined && bv !== null && String(bv) !== "") return bv } } catch (e) {} + return def +} +function cfgSet(key, value) { + var s = String(value) + var ok = false + try { if (typeof set === "function") { set(key, s); ok = true } } catch (e) {} + try { if (typeof bucketSet === "function") { bucketSet(BUCKET, key, s); ok = true } } catch (e) {} + if (!ok) throw new Error("当前 Autman 环境没有 set/bucketSet,无法保存配置") + return s +} +function getBool(key, def) { + var v = cfgGet(key, def ? "true" : "false") + if (typeof v === "boolean") return v + v = lower(trim(v)) + if (v === "1" || v === "true" || v === "yes" || v === "on" || v === "启用" || v === "是") return true + if (v === "0" || v === "false" || v === "no" || v === "off" || v === "禁用" || v === "否") return false + return !!def +} +function getInt(key, def, min, max) { + var n = parseInt(cfgGet(key, String(def)), 10) + if (isNaN(n)) n = def + if (min !== undefined && n < min) n = min + if (max !== undefined && n > max) n = max + return n +} function reply(msg) { var out = typeof msg === "string" ? msg : JSON.stringify(msg) - if (typeof sender !== "undefined" && sender.reply) sender.reply(out) - else if (typeof sendText === "function") sendText(out) - else if (typeof push === "function") push({ content: out }) - else console.log(out) + if (!out) return + try { if (typeof sender !== "undefined" && sender && sender.reply) { sender.reply(out); return } } catch (e) {} + try { if (typeof sendText === "function") { sendText(out); return } } catch (e) {} + try { if (typeof push === "function") { push({ content: out }); return } } catch (e) {} + try { console.log(out) } catch (e) {} } -function safeJsonParse(s, def) { try { return JSON.parse(s) } catch (e) { return def } } +function debug(msg) { try { if (typeof Debug === "function") Debug(String(msg)); else console.log(String(msg)) } catch (e) {} } + function getStore() { - var raw = get(STORE_KEY) || "{}" + var raw = cfgGet(STORE_KEY, "{}") var obj = safeJsonParse(raw, {}) - if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {} - return obj + if (!isObject(obj)) return {} + var clean = {} + for (var uid in obj) if (obj.hasOwnProperty(uid) && obj[uid]) clean[String(uid)] = String(obj[uid]) + return clean } -function setStore(obj) { var s = JSON.stringify(obj); set(STORE_KEY, s); return s } +function setStore(obj) { return cfgSet(STORE_KEY, JSON.stringify(obj || {})) } + function parseCookieString(cookie) { var jar = {} String(cookie || "").split(";").forEach(function (part) { var i = part.indexOf("=") if (i <= 0) return - var k = part.slice(0, i).trim() - var v = part.slice(i + 1).trim() + var k = trim(part.slice(0, i)) + var v = trim(part.slice(i + 1)) if (k) jar[k] = v }) return jar } function cookieToString(jar) { var arr = [] - for (var k in jar) if (jar[k] !== undefined && jar[k] !== null && jar[k] !== "") arr.push(k + "=" + jar[k]) + for (var k in jar) if (jar.hasOwnProperty(k) && jar[k] !== undefined && jar[k] !== null && jar[k] !== "") arr.push(k + "=" + jar[k]) return arr.join("; ") } function b64decode(s) { @@ -59,41 +106,62 @@ function b64decode(s) { try { if (typeof Buffer !== "undefined") return Buffer.from(s, "base64").toString("utf8") } catch (e) {} try { return decodeURIComponent(escape(atob(s))) } catch (e) { return "" } } -function uidFromQDHeader(qdheader) { var decoded = b64decode(qdheader); return decoded ? String(decoded.split("|")[0] || "").trim() : "" } +function uidFromQDHeader(qdheader) { + var decoded = b64decode(qdheader) + var uid = decoded ? trim(decoded.split("|")[0] || "") : "" + return /^\d+$/.test(uid) ? uid : "" +} function extractUid(cookie) { var jar = parseCookieString(cookie) var uid = uidFromQDHeader(jar.QDHeader || jar.qdheader || jar.QDHEADER || "") if (uid) return uid if (jar.uid) return String(jar.uid) + if (jar.userid) return String(jar.userid) return "" } -function maskCookie(cookie) { cookie = String(cookie || ""); return cookie.length <= 24 ? cookie : cookie.slice(0, 12) + "..." + cookie.slice(-8) } +function maskCookie(cookie) { + cookie = String(cookie || "") + if (!cookie) return "" + var jar = parseCookieString(cookie) + var keys = [] + for (var k in jar) if (jar.hasOwnProperty(k)) keys.push(k) + var prefix = keys.slice(0, 5).join(",") + return "[cookie keys:" + prefix + (keys.length > 5 ? ",..." : "") + "; len=" + cookie.length + "]" +} +function maskUid(uid) { + uid = String(uid || "") + if (uid.length <= 4) return uid + return uid.slice(0, 2) + "***" + uid.slice(-2) +} + function contentText() { var s = "" try { if (typeof getContent === "function") s = getContent() || "" } catch (e) {} try { if (!s && typeof GetContent === "function") s = GetContent() || "" } catch (e) {} - try { if (!s && typeof sender !== "undefined" && sender.getContent) s = sender.getContent() || "" } catch (e) {} - try { if (!s && typeof sender !== "undefined" && sender.getMessage) s = sender.getMessage() || "" } catch (e) {} + try { if (!s && typeof input === "function") s = input() || "" } catch (e) {} + try { if (!s && typeof sender !== "undefined" && sender && sender.getContent) s = sender.getContent() || "" } catch (e) {} + try { if (!s && typeof sender !== "undefined" && sender && sender.getMessage) s = sender.getMessage() || "" } catch (e) {} try { if (!s && typeof message !== "undefined") s = String(message) } catch (e) {} - return String(s || "").trim() + return trim(s) } function usage() { return [ - "起点读书签到插件", + "起点读书签到插件 v2.2.0", "提交CK:起点ck ", "签到全部:起点签到", "签到单号:起点签到 ", "查询CK:起点ck 查询", - "导出CK:起点ck 导出", - "删除CK:起点ck 删除 ", - "说明:QX只负责抓cookie;签到由本 Autman 插件执行。" + "导出CK:起点ck 导出(会输出完整 Cookie,仅管理员自用)", + "删除CK:起点ck 删除 ", + "配置签名网关:Autman 参数 otto.qdreader_sign_api,默认 " + DEFAULT_SIGN_API, + "定时:每日 07:15 自动执行;QX 只负责抓 cookie。" ].join("\n") } function stripCommand(text) { var s = String(text || "") - if (/^起点(?:签到|读书)(?:\s|$)/i.test(s)) return s.replace(/^起点(?:签到|读书)\s*/i, "").trim() - if (/^起点(?:ck|CK|cookie|Cookie)(?:\s|$)/i.test(s)) return s.replace(/^起点(?:ck|CK|cookie|Cookie)\s*/i, "").trim() - return s.replace(/^qdreader\s*/i, "").trim() + if (/^起点(?:签到|读书)(?:\s|$)/i.test(s)) return trim(s.replace(/^起点(?:签到|读书)\s*/i, "")) + if (/^起点(?:ck|CK|cookie|Cookie)(?:\s|$)/i.test(s)) return trim(s.replace(/^起点(?:ck|CK|cookie|Cookie)\s*/i, "")) + return trim(s.replace(/^qdreader\s*/i, "")) } function commandName(text) { if (/^起点(?:签到|读书)(?:\s|$)/i.test(text)) return "sign" @@ -103,10 +171,11 @@ function commandName(text) { function qs(obj) { var parts = [] for (var k in (obj || {})) { + if (!obj.hasOwnProperty(k)) continue var v = obj[k] if (v === undefined || v === null) continue if (Array.isArray(v)) { for (var i = 0; i < v.length; i++) parts.push(encodeURIComponent(k + "[" + i + "]") + "=" + encodeURIComponent(String(v[i]))) } - else if (typeof v === "object") { for (var sk in v) parts.push(encodeURIComponent(k + "[" + sk + "]") + "=" + encodeURIComponent(String(v[sk]))) } + else if (typeof v === "object") { for (var sk in v) if (v.hasOwnProperty(sk)) parts.push(encodeURIComponent(k + "[" + sk + "]") + "=" + encodeURIComponent(String(v[sk]))) } else parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v))) } return parts.join("&") @@ -117,9 +186,11 @@ function normalizeCookie(cookie) { if (session) jar.sessionyw = session var appid = jar.appId || jar.appid || "" if (appid) jar.appId = appid - return { jar: jar, cookie: cookieToString(jar), qdh: jar.QDHeader || jar.qdh || "", qdheader: jar.QDHeader || jar.qdheader || "", uid: extractUid(cookie), guid: jar.gwguid || jar.guid || "", qimei: jar.qimei || "", qimei36: jar.qimei36 || jar.QIMEI36 || "" } + var guid = jar.ywguid || jar.gwguid || jar.guid || "" + return { jar: jar, cookie: cookieToString(jar), qdh: jar.QDHeader || jar.qdh || "", qdheader: jar.QDHeader || jar.qdheader || "", uid: extractUid(cookie), guid: guid, qimei: jar.qimei || jar.QIMEI || "", qimei36: jar.qimei36 || jar.QIMEI36 || "" } } function httpReq(opt) { + if (!opt.headers) opt.headers = {} if (typeof request === "function") return request(opt) if (typeof $request === "function") return $request(opt) throw new Error("当前 Autman 环境没有 request/$request 方法,无法发起签到请求") @@ -129,21 +200,43 @@ function parseResp(res) { if (typeof body === "string") return safeJsonParse(body, body) return body } +function requestWithRetry(opt, retryCount) { + var lastErr = null + var attempts = Math.max(1, (retryCount || 0) + 1) + for (var i = 0; i < attempts; i++) { + try { + return httpReq(opt) + } catch (e) { + lastErr = e + debug("QDReader request retry " + (i + 1) + "/" + attempts + " failed: " + (e && e.message ? e.message : String(e))) + } + } + throw lastErr || new Error("请求失败") +} function postJson(url, data) { - return parseResp(httpReq({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })) + var retry = getInt(RETRY_KEY, 1, 0, 3) + return parseResp(requestWithRetry({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }, retry)) +} +function normalizeSignHeaders(signed) { + if (typeof signed === "string") signed = safeJsonParse(signed, {}) + if (!signed) throw new Error("签名网关无返回") + var code = signed.code !== undefined ? signed.code : (signed.retcode !== undefined ? signed.retcode : signed.status) + var ok = code === undefined || code === 0 || code === "0" || code === 200 || code === "200" || code === "ok" || code === true + var data = signed.data || signed.result || signed.headers || signed + var headers = data && (data.headers || data.header || data) + if (!ok || !isObject(headers)) throw new Error("签名失败:" + JSON.stringify(signed).slice(0, 500)) + return headers } function qidianRequest(path, method, data, cookie) { method = String(method || "GET").toUpperCase() var info = normalizeCookie(cookie) - var signApi = (get(SIGN_API_KEY) || DEFAULT_SIGN_API).replace(/\/$/, "") + if (!info.uid) throw new Error("Cookie 缺少 uid/QDHeader") + var signApi = String(cfgGet(SIGN_API_KEY, DEFAULT_SIGN_API)).replace(/\/+$/, "") var payload = { qdh: info.qdh, qdheader: info.qdheader, url: QIDIAN_BASE + path, method: method, data: data || {}, uid: info.uid, guid: info.guid, qimei: info.qimei, qimei36: info.qimei36 } - var signed = postJson(signApi + "/sign", payload) - if (typeof signed === "string") signed = safeJsonParse(signed, {}) - if (!signed || (signed.code !== 200 && signed.code !== "200" && signed.code !== 0 && signed.code !== "0") || !signed.data) throw new Error("签名失败:" + JSON.stringify(signed)) - var headers = signed.data.headers || signed.data + var headers = normalizeSignHeaders(postJson(signApi + "/sign", payload)) headers.Cookie = info.cookie if (!headers["User-Agent"]) headers["User-Agent"] = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/QDReaderiOS/5.9.456/728/QDReaderAppstore" - var req = { url: QIDIAN_BASE + path, method: method.toLowerCase(), dataType: "json", timeOut: 10000, headers: headers } + var req = { url: QIDIAN_BASE + path, method: lower(method), dataType: "json", timeOut: 10000, headers: headers } if (method === "GET") { var query = qs(data || {}) if (query) req.url += (req.url.indexOf("?") >= 0 ? "&" : "?") + query @@ -151,81 +244,113 @@ function qidianRequest(path, method, data, cookie) { req.headers["Content-Type"] = req.headers["Content-Type"] || "application/x-www-form-urlencoded" req.body = qs(data || {}) } - var out = parseResp(httpReq(req)) + var retry = getInt(RETRY_KEY, 1, 0, 3) + var out = parseResp(requestWithRetry(req, retry)) if (typeof out === "string") out = safeJsonParse(out, out) return out } +function resultCode(obj) { return obj && (obj.Result !== undefined ? obj.Result : obj.Code !== undefined ? obj.Code : obj.code !== undefined ? obj.code : obj.retcode) } function resultOk(obj) { - var code = obj && (obj.Result !== undefined ? obj.Result : obj.Code !== undefined ? obj.Code : obj.code) - return code === undefined || code === 0 || code === "0" || code === 200 || code === "200" + var code = resultCode(obj) + var status = obj && (obj.status || obj.Status) + return code === undefined || code === 0 || code === "0" || code === 200 || code === "200" || status === "ok" || status === "success" +} +function resultMsg(obj) { + if (!obj) return "" + return obj.Message || obj.Msg || obj.message || obj.msg || obj.toast || (obj.data && (obj.data.message || obj.data.msg || obj.data.toast)) || "" } -function resultMsg(obj) { return (obj && (obj.Message || obj.Msg || obj.message || obj.msg)) || "" } function doCheckin(uid, cookie) { - var res = qidianRequest("/argus/api/v3/checkin/checkin", "POST", {}, cookie) + var res = qidianRequest(CHECKIN_PATH, "POST", {}, cookie) var ok = resultOk(res) - var msg = resultMsg(res) || (ok ? "签到请求成功" : "签到失败") + var msg = resultMsg(res) || (ok ? "签到请求成功" : ("签到失败" + (resultCode(res) !== undefined ? " code=" + resultCode(res) : ""))) return { uid: uid, ok: ok, msg: msg, raw: res } } function signAll(targetUid) { var store = getStore() - var uids = Object.keys(store) + var uids = Object.keys(store).sort() if (targetUid) uids = uids.filter(function (u) { return u === targetUid }) if (!uids.length) return targetUid ? ("未找到 uid: " + targetUid) : "还没有保存起点读书CK" + var okCount = 0 + var failCount = 0 var lines = ["起点读书签到结果:"] for (var i = 0; i < uids.length; i++) { var uid = uids[i] try { var r = doCheckin(uid, store[uid]) - lines.push((r.ok ? "✅ " : "❌ ") + uid + ":" + r.msg) + if (r.ok) okCount++; else failCount++ + lines.push((r.ok ? "✅ " : "❌ ") + maskUid(uid) + ":" + r.msg) } catch (e) { - lines.push("❌ " + uid + ":" + (e && e.message ? e.message : String(e))) + failCount++ + lines.push("❌ " + maskUid(uid) + ":" + (e && e.message ? e.message : String(e))) } } + lines.push("汇总:成功 " + okCount + " / 失败 " + failCount + " / 总计 " + uids.length) return lines.join("\n") } - -function main() { - var text = contentText() - var isCronRun = !text - var cmd = isCronRun ? "sign" : commandName(text) - var arg = isCronRun ? "" : stripCommand(text).replace(/^sign\s*/i, "").trim() - if (!arg && cmd !== "sign") { reply(usage()); return } - if (/^(帮助|help|h)$/i.test(arg)) { reply(usage()); return } - - var store = getStore() - - if (cmd === "sign" || /^(签到|sign|run)(?:\s+|$)/i.test(arg)) { - var target = arg.replace(/^(签到|sign|run)\s*/i, "").trim() - reply(signAll(target)) - return +function importJson(arg, store) { + var obj = safeJsonParse(arg, null) + if (!isObject(obj)) return "JSON格式错误,示例:起点ck json {\"uid\":\"cookie\"}" + var count = 0 + for (var uid in obj) { + if (!obj.hasOwnProperty(uid) || !obj[uid]) continue + var cookie = String(obj[uid]) + var realUid = extractUid(cookie) || uid + store[String(realUid)] = cookie + count++ } - - if (/^(查询|状态|status|list)$/i.test(arg)) { - var uids = Object.keys(store) - reply(uids.length ? ("起点读书CK:共 " + uids.length + " 个账号\n" + uids.join("\n") + "\n发送 起点签到 可执行签到") : "还没有保存起点读书CK") - return - } - if (/^(导出|export)$/i.test(arg)) { reply("QDREADER_COOKIE=" + JSON.stringify(store)); return } + setStore(store) + return "起点读书CK JSON已导入/更新 " + count + " 个账号,当前共 " + Object.keys(store).length + " 个账号\n发送 起点签到 可执行签到" +} +function handleDelete(arg, store) { var del = arg.match(/^(删除|delete|del|rm)\s+(\S+)/i) - if (del) { var duid = del[2]; var existed = !!store[duid]; delete store[duid]; setStore(store); reply(existed ? ("已删除 uid: " + duid) : ("未找到 uid: " + duid)); return } - var jsonMatch = arg.match(/^json\s+([\s\S]+)$/i) - if (jsonMatch) { - var obj = safeJsonParse(jsonMatch[1], null) - if (!obj || typeof obj !== "object" || Array.isArray(obj)) { reply("JSON格式错误,示例:起点ck json {\"uid\":\"cookie\"}"); return } - var count = 0 - for (var uid in obj) { if (!obj[uid]) continue; store[uid] = String(obj[uid]); count++ } - setStore(store); reply("起点读书CK JSON已导入/更新 " + count + " 个账号,当前共 " + Object.keys(store).length + " 个账号\n发送 起点签到 可执行签到"); return + if (!del) return null + var duid = del[2] + if (/^(all|全部|所有)$/i.test(duid)) { + var n = Object.keys(store).length + setStore({}) + return "已删除全部起点读书CK,共 " + n + " 个账号" } + var existed = !!store[duid] + delete store[duid] + setStore(store) + return existed ? ("已删除 uid: " + duid) : ("未找到 uid: " + duid) +} +function handleSaveCookie(arg, store) { var cookie = arg - if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) { reply("未识别到 Cookie。请发送:起点ck "); return } + if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) return "未识别到 Cookie。请发送:起点ck " var uid = extractUid(cookie) - if (!uid) { reply("无法从 Cookie 识别 uid。请确认 QX 抓的是起点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。"); return } + if (!uid) return "无法从 Cookie 识别 uid。请确认 QX 抓的是起点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。" var isNew = !store[uid] var changed = store[uid] !== cookie store[uid] = cookie setStore(store) - reply(["起点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, "账号数: " + Object.keys(store).length, "cookie: " + maskCookie(cookie), "签到:发送 起点签到"].join("\n")) + return ["起点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, "账号数: " + Object.keys(store).length, "cookie: " + maskCookie(cookie), "签到:发送 起点签到"].join("\n") +} +function main() { + var text = contentText() + var isCronRun = !text + var cmd = isCronRun ? "sign" : commandName(text) + var arg = isCronRun ? "" : trim(stripCommand(text).replace(/^sign\s*/i, "")) + if (!arg && cmd !== "sign") { reply(usage()); return } + if (/^(帮助|help|h)$/i.test(arg)) { reply(usage()); return } + var store = getStore() + if (cmd === "sign" || /^(签到|sign|run)(?:\s+|$)/i.test(arg)) { + var target = trim(arg.replace(/^(签到|sign|run)\s*/i, "")) + var out = signAll(target) + if (!(isCronRun && getBool(CRON_SILENT_KEY, false) && out === "还没有保存起点读书CK")) reply(out) + return + } + if (/^(查询|状态|status|list)$/i.test(arg)) { + var uids = Object.keys(store).sort() + reply(uids.length ? ("起点读书CK:共 " + uids.length + " 个账号\n" + uids.map(maskUid).join("\n") + "\n发送 起点签到 可执行签到") : "还没有保存起点读书CK") + return + } + if (/^(导出|export)$/i.test(arg)) { reply("QDREADER_COOKIE=" + JSON.stringify(store)); return } + var delMsg = handleDelete(arg, store) + if (delMsg !== null) { reply(delMsg); return } + var jsonMatch = arg.match(/^json\s+([\s\S]+)$/i) + if (jsonMatch) { reply(importJson(jsonMatch[1], store)); return } + reply(handleSaveCookie(arg, store)) } main() - diff --git a/tests/qdreader_plugin.test.mjs b/tests/qdreader_plugin.test.mjs new file mode 100644 index 0000000..eff8a60 --- /dev/null +++ b/tests/qdreader_plugin.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const pluginPath = new URL('../plugins/qdreader/qdreader_sign_autman.js', import.meta.url).pathname; +const code = fs.readFileSync(pluginPath, 'utf8'); + +function runPlugin({ content = '', store = {}, bucketStore = {}, requests = [] } = {}) { + const replies = []; + const calls = []; + let requestIndex = 0; + const ctx = { + console, + Buffer, + sender: { + reply: (msg) => replies.push(String(msg)), + getMessage: () => content, + getContent: () => content, + isAdmin: () => true, + }, + GetContent: () => content, + getContent: () => content, + get: (key) => store[key] || '', + set: (key, value) => { store[key] = String(value); }, + bucketGet: (bucket, key) => bucketStore[`${bucket}.${key}`] || '', + bucketSet: (bucket, key, value) => { bucketStore[`${bucket}.${key}`] = String(value); }, + request: (req) => { + calls.push(req); + const item = requests[requestIndex++]; + if (item instanceof Error) throw item; + if (typeof item === 'function') return item(req, calls.length); + if (item !== undefined) return item; + if (String(req.url).includes('/sign')) return { body: { code: 0, data: { headers: { 'QD-Sign': 'sig' } } } }; + return { statusCode: 200, body: { code: 0, msg: '签到成功' } }; + }, + }; + vm.createContext(ctx); + vm.runInContext(code, ctx, { filename: pluginPath }); + return { replies, calls, store, bucketStore }; +} + +const cookie = 'uid=12345; QDHeader=MTIzNDV8eA==; qdh=qdh; ywguid=guid; qimei=qimei; qimei36=qimei36; sessionyw=s; appId=10'; + +function test(name, fn) { + try { + fn(); + console.log('ok -', name); + } catch (err) { + console.error('not ok -', name); + throw err; + } +} + +test('manual cookie save masks cookie and stores by uid', () => { + const r = runPlugin({ content: '起点ck ' + cookie }); + assert.match(r.replies.join('\n'), /CK新增成功/); + assert.match(r.replies.join('\n'), /uid: 12345/); + assert.doesNotMatch(r.replies.join('\n'), /sessionyw=s; appId=10/); + assert.equal(JSON.parse(r.store.qdreader_cookie_json)['12345'], cookie); +}); + +test('manual sign and cron sign both call sign gateway then qidian checkin', () => { + const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }) }; + const manual = runPlugin({ content: '起点签到', store: { ...store } }); + assert.equal(manual.calls.length, 2); + assert.match(manual.calls[0].url, /api\.120399\.xyz\/sign$/); + assert.match(manual.calls[1].url, /h5\.if\.qidian\.com\/argus\/api\/v3\/checkin\/checkin$/); + assert.match(manual.replies[0], /✅ 12\*\*\*45/); + + const cron = runPlugin({ content: '', store: { ...store } }); + assert.equal(cron.calls.length, 2); + assert.match(cron.replies[0], /起点读书签到结果/); +}); + +test('bucketGet/bucketSet fallback works when get/set are unavailable in Autman-like env', () => { + const replies = []; + const bucketStore = {}; + const ctx = { + console, + Buffer, + sender: { reply: (msg) => replies.push(String(msg)), getMessage: () => '起点ck ' + cookie }, + bucketGet: (bucket, key) => bucketStore[`${bucket}.${key}`] || '', + bucketSet: (bucket, key, value) => { bucketStore[`${bucket}.${key}`] = String(value); }, + request: () => ({ body: { code: 0, data: { headers: {} } } }), + }; + vm.createContext(ctx); + vm.runInContext(code, ctx, { filename: pluginPath }); + assert.match(replies.join('\n'), /CK新增成功/); + assert.equal(JSON.parse(bucketStore['otto.qdreader_cookie_json'])['12345'], cookie); +}); + +test('qidian request retries transient request errors', () => { + const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }), qdreader_retry_count: '2' }; + const r = runPlugin({ + content: '起点签到', + store, + requests: [ + { body: { code: 0, data: { headers: { 'QD-Sign': 'sig' } } } }, + new Error('timeout'), + { statusCode: 200, body: { code: 0, msg: '重试后成功' } }, + ], + }); + assert.equal(r.calls.length, 3); + assert.match(r.replies[0], /重试后成功/); +}); + +test('status command shows account count and masked uid list without cookies', () => { + const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }) }; + const r = runPlugin({ content: '起点ck 查询', store }); + assert.match(r.replies[0], /共 1 个账号/); + assert.match(r.replies[0], /12\*\*\*45/); + assert.doesNotMatch(r.replies[0], /QDHeader=/); +}); + +test('delete all command clears saved cookies', () => { + const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie, '67890': cookie.replace('12345', '67890') }) }; + const r = runPlugin({ content: '起点ck 删除 all', store }); + assert.match(r.replies[0], /已删除全部/); + assert.deepEqual(JSON.parse(r.store.qdreader_cookie_json), {}); +});