From bcba339fd89af1d37a9c63a2596b8c7dc29da358 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 17 May 2026 10:42:13 +0800 Subject: [PATCH] docs: capture qdreader plugin menu patterns --- docs/autman-plugin-rules.md | 62 +++++ js/qdreader_sign_autman.js | 331 ++++++++++++++++++----- plugins/qdreader/README.md | 134 +++++---- plugins/qdreader/qdreader_sign_autman.js | 331 ++++++++++++++++++----- tests/qdreader_plugin.test.mjs | 155 +++++++++-- 5 files changed, 809 insertions(+), 204 deletions(-) diff --git a/docs/autman-plugin-rules.md b/docs/autman-plugin-rules.md index 50efd0d..abb86ff 100644 --- a/docs/autman-plugin-rules.md +++ b/docs/autman-plugin-rules.md @@ -523,3 +523,65 @@ Content-Type: application/json - SimAdmin API 业务失败要检查 JSON 内 `status === "error"`。 - 发送短信必须 token 或管理员控制。 - Base URL 默认不带尾部 `/`,代码内统一 trim。 +## 13. 账号/Cookie 类插件菜单设计经验(QDReader 复盘) + +最近制作 `plugins/qdreader/qdreader_sign_autman.js` 时沉淀出一套适用于账号/Cookie 管理类插件的交互与数据规则,后续新插件优先复用: + +### 13.1 交互结构 + +- 优先做“一级菜单 + 二级编号选择”,不要堆很多平铺子命令。 +- 账号管理和执行查询分离: + - `xxx账号`:只做账号列表、添加/更新、详情/备注、删除、清空。 + - `xxx查询`:只做最近执行情况、单账号执行情况、立即执行等执行侧动作。 +- 高频输入可以保留快速通道,例如 `xxxck `,但快速通道只做快速提交,不再混入查询/导出/批量导入。 +- 用户选择账号时展示编号列表,让用户回复编号;不要让用户手动复制/输入 UID。 +- 添加/更新账号成功后立刻询问是否添加备注;备注修改放在账号详情里,不单独做一个割裂的备注菜单。 + +### 13.2 数据结构 + +账号/Cookie 类插件不要把 Cookie、用户绑定、备注、执行状态混在一起,建议拆成独立 map: + +```json +// cookie_json +{ "uid": "full cookie" } + +// user_bind_json +{ "uid": "sender.getUserID()" } + +// remark_json +{ "uid": "主号" } + +// last_result_json +{ "uid": "{\"ok\":true,\"msg\":\"成功\",\"time\":\"...\"}" } +``` + +这样删除、过滤、展示和权限校验更安全。 + +### 13.3 数据桶 + +- 新插件应创建自己的 bucket,例如 `hermes_qidian`,不要所有插件都塞进 `otto`。 +- 如需兼容旧数据,读顺序建议: + +```text +bucketGet("插件专属bucket", key) + ↓ fallback +get(key) + ↓ fallback +bucketGet("otto", key) +``` + +- 写入新数据时写到专属 bucket,可同时写普通 `set(key)` 保持兼容。 + +### 13.4 多用户隔离 + +- 保存账号时绑定当前用户 ID。 +- 账号列表、详情、删除、手动执行只处理当前用户绑定的账号。 +- `删除全部` 只删除当前用户账号,不清空全局数据。 +- cron/系统触发通常没有用户上下文,是否执行全部账号要显式传 `allUsers` 参数,并在代码和测试里说明。 + +### 13.5 安全与测试 + +- 普通回复永远不要输出完整 Cookie;只展示脱敏 UID、备注、Cookie key 摘要或长度。 +- 高风险的导出/批量导入功能默认不做,除非 BOSS 明确需要。 +- Node VM 测试应模拟:`sender.reply`、`sender.listen`、`sender.getUserID`、`get/set`、`bucketGet/bucketSet`、`request`。 +- 测试必须覆盖:快速提交、菜单添加、备注提示、详情改备注、编号选择删除/查询、旧 bucket 兼容、新 bucket 写入、用户隔离、手动执行只跑当前用户、cron 跑全部账号。 diff --git a/js/qdreader_sign_autman.js b/js/qdreader_sign_autman.js index c34b102..791bd5d 100644 --- a/js/qdreader_sign_autman.js +++ b/js/qdreader_sign_autman.js @@ -1,27 +1,30 @@ //[open_source: true] //[create_at: 2026-05-17 03:00:00] -//[title: 起点读书签到] +//[title: 启点读书签到] //[author: Hermes] //[service: BOSS] //[class: 工具类] -//[version: 2.2.0] +//[version: 2.3.0] //[platform: web,qq,wx,tg,tb,fs,we] //[public: false] //[price: 0] //[disable: false] -//[description: 起点读书(QDReader) Autman 签到插件:保存 QX 抓到的 H5 Cookie,支持手动“起点签到”和每日 07:15 定时签到;带多环境存储兼容、请求重试、签名响应兼容、结果摘要和安全脱敏。] -//[rule: ^起点(ck|CK|cookie|Cookie|签到|读书)(.*)$] +//[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,可替换为兼容 /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 时定时触发且没有账号不回复"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_cookie_json","bool":false,"placeholder":"{\"uid\":\"cookie\"}","name":"QDReader Cookie JSON","desc":"机器人交互维护,JSON Map 格式;兼容读取旧 otto bucket"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz,可替换为兼容 /sign 的网关"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_retry_count","bool":false,"placeholder":"1","name":"请求失败重试次数","desc":"默认 1,最大 3,仅对网络异常/非业务异常重试"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_cron_silent","bool":true,"placeholder":"false","name":"定时无账号时静默","desc":"true 时定时触发且没有账号不回复"}] -var BUCKET = "otto" +var BUCKET = "hermes_qidian" +var LEGACY_BUCKET = "otto" var STORE_KEY = "qdreader_cookie_json" +var BIND_KEY = "qdreader_user_bind_json" +var REMARK_KEY = "qdreader_remark_json" var SIGN_API_KEY = "qdreader_sign_api" var RETRY_KEY = "qdreader_retry_count" var CRON_SILENT_KEY = "qdreader_cron_silent" @@ -37,15 +40,16 @@ function trim(s) { return String(s === undefined || s === null ? "" : s).replace 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) {} + 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 lv = bucketGet(LEGACY_BUCKET, key); if (lv !== undefined && lv !== null && String(lv) !== "") return lv } } 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) {} + try { if (typeof set === "function") { set(key, s); ok = true } } catch (e) {} if (!ok) throw new Error("当前 Autman 环境没有 set/bucketSet,无法保存配置") return s } @@ -75,15 +79,36 @@ function reply(msg) { } function debug(msg) { try { if (typeof Debug === "function") Debug(String(msg)); else console.log(String(msg)) } catch (e) {} } -function getStore() { - var raw = cfgGet(STORE_KEY, "{}") - var obj = safeJsonParse(raw, {}) +function cleanStringMap(obj) { if (!isObject(obj)) return {} var clean = {} - for (var uid in obj) if (obj.hasOwnProperty(uid) && obj[uid]) clean[String(uid)] = String(obj[uid]) + for (var k in obj) if (obj.hasOwnProperty(k) && obj[k] !== undefined && obj[k] !== null && String(obj[k]) !== "") clean[String(k)] = String(obj[k]) return clean } +function getStore() { return cleanStringMap(safeJsonParse(cfgGet(STORE_KEY, "{}"), {})) } function setStore(obj) { return cfgSet(STORE_KEY, JSON.stringify(obj || {})) } +function getBindStore() { return cleanStringMap(safeJsonParse(cfgGet(BIND_KEY, "{}"), {})) } +function setBindStore(obj) { return cfgSet(BIND_KEY, JSON.stringify(obj || {})) } +function getRemarkStore() { return cleanStringMap(safeJsonParse(cfgGet(REMARK_KEY, "{}"), {})) } +function setRemarkStore(obj) { return cfgSet(REMARK_KEY, JSON.stringify(obj || {})) } +function currentUserId() { + var v = "" + try { if (typeof sender !== "undefined" && sender && sender.getUserID) v = sender.getUserID() || "" } catch (e) {} + try { if (!v && typeof sender !== "undefined" && sender && sender.getUserId) v = sender.getUserId() || "" } catch (e) {} + try { if (!v && typeof userId !== "undefined") v = String(userId || "") } catch (e) {} + try { if (!v && typeof getUserID === "function") v = getUserID() || "" } catch (e) {} + return trim(v) +} +function boundToCurrent(uid, bind) { + var owner = bind && bind[String(uid)] ? String(bind[String(uid)]) : "" + var me = currentUserId() + return !owner || !me || owner === me +} +function visibleUids(store, bind, allUsers) { + var uids = Object.keys(store || {}).sort() + if (allUsers) return uids + return uids.filter(function (uid) { return boundToCurrent(uid, bind || {}) }) +} function parseCookieString(cookie) { var jar = {} @@ -146,26 +171,32 @@ function contentText() { } function usage() { return [ - "起点读书签到插件 v2.2.0", - "提交CK:起点ck ", - "签到全部:起点签到", - "签到单号:起点签到 ", - "查询CK:起点ck 查询", - "导出CK:起点ck 导出(会输出完整 Cookie,仅管理员自用)", - "删除CK:起点ck 删除 ", - "配置签名网关:Autman 参数 otto.qdreader_sign_api,默认 " + DEFAULT_SIGN_API, + "启点读书签到插件 v2.3.0", + "【一级菜单】", + "账号管理:启点账号", + "执行查询:启点查询", + "【快速通道】", + "快速提交CK:启点ck ", + "直接签到:启点签到", + "指定签到:启点签到 ", + "【配置】", + "签名网关:Autman 参数 hermes_qidian.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 trim(s.replace(/^起点(?:签到|读书)\s*/i, "")) - if (/^起点(?:ck|CK|cookie|Cookie)(?:\s|$)/i.test(s)) return trim(s.replace(/^起点(?:ck|CK|cookie|Cookie)\s*/i, "")) + if (/^启点(?:签到|读书)(?:\s|$)/i.test(s)) return trim(s.replace(/^启点(?:签到|读书)\s*/i, "")) + if (/^启点(?:查询)(?:\s|$)/i.test(s)) return trim(s.replace(/^启点(?:查询)\s*/i, "")) + 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" + if (/^启点(?:签到|读书)(?:\s|$)/i.test(text)) return "sign" if (/^qdreader\s+sign(?:\s|$)/i.test(text)) return "sign" + if (/^启点(?:查询)(?:\s|$)/i.test(text)) return "status" + if (/^启点(?:账号)(?:\s|$)/i.test(text)) return "account" return "ck" } function qs(obj) { @@ -259,20 +290,34 @@ 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 setLastResult(uid, ok, msg) { + var key = "qdreader_last_result_json" + var obj = cleanStringMap(safeJsonParse(cfgGet(key, "{}"), {})) + obj[String(uid)] = JSON.stringify({ ok: !!ok, msg: String(msg || ""), time: new Date().toISOString() }) + cfgSet(key, JSON.stringify(obj)) +} +function getLastResults() { + var raw = cleanStringMap(safeJsonParse(cfgGet("qdreader_last_result_json", "{}"), {})) + var out = {} + for (var uid in raw) if (raw.hasOwnProperty(uid)) out[uid] = safeJsonParse(raw[uid], { msg: raw[uid] }) + return out +} function doCheckin(uid, cookie) { var res = qidianRequest(CHECKIN_PATH, "POST", {}, cookie) var ok = resultOk(res) var msg = resultMsg(res) || (ok ? "签到请求成功" : ("签到失败" + (resultCode(res) !== undefined ? " code=" + resultCode(res) : ""))) + setLastResult(uid, ok, msg) return { uid: uid, ok: ok, msg: msg, raw: res } } -function signAll(targetUid) { +function signAll(targetUid, allUsers) { var store = getStore() - var uids = Object.keys(store).sort() + var bind = getBindStore() + var uids = visibleUids(store, bind, !!allUsers) if (targetUid) uids = uids.filter(function (u) { return u === targetUid }) - if (!uids.length) return targetUid ? ("未找到 uid: " + targetUid) : "还没有保存起点读书CK" + if (!uids.length) return targetUid ? ("未找到 uid: " + targetUid) : "还没有保存启点读书CK" var okCount = 0 var failCount = 0 - var lines = ["起点读书签到结果:"] + var lines = ["启点读书签到结果:"] for (var i = 0; i < uids.length; i++) { var uid = uids[i] try { @@ -287,70 +332,218 @@ function signAll(targetUid) { lines.push("汇总:成功 " + okCount + " / 失败 " + failCount + " / 总计 " + uids.length) return lines.join("\n") } -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++ - } - setStore(store) - return "起点读书CK JSON已导入/更新 " + count + " 个账号,当前共 " + Object.keys(store).length + " 个账号\n发送 起点签到 可执行签到" + +function formatStatusLine(uid, last, remarks) { + var name = maskUid(uid) + (remarks && remarks[uid] ? "(" + remarks[uid] + ")" : "") + var r = last[uid] + if (!r) return "- " + name + ":暂无执行记录" + return "- " + name + ":" + (r.ok ? "✅ " : "❌ ") + (r.msg || "无结果") + (r.time ? " @ " + r.time : "") } -function handleDelete(arg, store) { +function queryStatus(targetUid) { + var store = getStore() + var bind = getBindStore() + var last = getLastResults() + var remarks = getRemarkStore() + var uids = visibleUids(store, bind, false) + if (targetUid) { + if (uids.indexOf(targetUid) < 0) return store[targetUid] ? ("无权查询 uid: " + targetUid) : ("未找到 uid: " + targetUid) + uids = [targetUid] + } + if (!uids.length) return "当前用户还没有保存启点读书CK" + var lines = [targetUid ? "启点单账号执行情况:" : "启点执行情况:"] + for (var i = 0; i < uids.length; i++) lines.push(formatStatusLine(uids[i], last, remarks)) + return lines.join("\n") +} + +function listenInput(timeoutMs) { + try { if (typeof sender !== "undefined" && sender && sender.listen) return trim(sender.listen(timeoutMs || 60000)) } catch (e) {} + return "" +} +function accountMenuText() { + return [ + "启点账号管理", + "1. 查看账号", + "2. 添加/更新账号", + "3. 账号详情/修改备注", + "4. 删除账号", + "5. 清空账号", + "q. 退出", + "请回复序号" + ].join("\n") +} +function queryMenuText() { + return [ + "启点查询", + "1. 查看最近执行情况", + "2. 查询单个账号", + "3. 立即执行签到", + "q. 退出", + "请回复序号" + ].join("\n") +} +function accountListText(store, bind) { + var uids = visibleUids(store, bind, false) + var remarks = getRemarkStore() + if (!uids.length) return "当前用户还没有保存启点读书CK" + var lines = ["启点账号:当前用户共 " + uids.length + " 个账号"] + for (var i = 0; i < uids.length; i++) { + var uid = uids[i] + lines.push(maskUid(uid) + (remarks[uid] ? "(" + remarks[uid] + ")" : "")) + } + lines.push("发送 启点签到 可执行签到") + return lines.join("\n") +} + + +function accountSelectText(store, bind, title) { + var uids = visibleUids(store, bind, false) + var remarks = getRemarkStore() + if (!uids.length) return { text: "当前用户还没有保存启点读书CK", uids: [] } + var lines = [title || "请选择账号:"] + for (var i = 0; i < uids.length; i++) { + var uid = uids[i] + lines.push((i + 1) + ". " + maskUid(uid) + (remarks[uid] ? "(" + remarks[uid] + ")" : "")) + } + lines.push("q. 退出") + lines.push("请回复编号") + return { text: lines.join("\n"), uids: uids } +} +function chooseUid(store, bind, title) { + var sel = accountSelectText(store, bind, title) + reply(sel.text) + if (!sel.uids.length) return "" + var op = listenInput(60000) + if (!op || /^(q|退出|取消)$/i.test(op)) return "" + var n = parseInt(op, 10) + if (isNaN(n) || n < 1 || n > sel.uids.length) { reply("编号无效,已退出"); return "" } + return sel.uids[n - 1] +} +function maybePromptRemark(uid) { + if (!uid) return "" + reply("是否为该账号添加备注?回复备注名,发送 n/跳过 则不添加") + var remark = listenInput(60000) + if (!remark || /^(n|no|否|不|跳过|q|退出|取消)$/i.test(remark)) return "" + var remarks = getRemarkStore() + remarks[uid] = remark.slice(0, 30) + setRemarkStore(remarks) + return "\n备注: " + remarks[uid] +} +function accountDetailText(uid, store, bind) { + var uids = visibleUids(store, bind, false) + if (uids.indexOf(uid) < 0) return store[uid] ? ("无权查看 uid: " + uid) : ("未找到 uid: " + uid) + var remarks = getRemarkStore() + var last = getLastResults() + return [ + "启点账号详情:", + "uid: " + maskUid(uid), + "备注: " + (remarks[uid] || "未设置"), + "cookie: " + maskCookie(store[uid]), + formatStatusLine(uid, last, remarks), + "修改备注请回复新备注名,发送 清空 可删除备注,发送 q 退出" + ].join("\n") +} +function accountDetailMenu(store, bind) { + var uid = chooseUid(store, bind, "请选择要查看/修改备注的账号:") + if (!uid) return reply("已退出") + var detail = accountDetailText(uid, store, bind) + reply(detail) + if (/^(无权|未找到)/.test(detail)) return + var remark = listenInput(60000) + if (!remark || /^(q|退出|取消)$/i.test(remark)) return reply("已退出") + var remarks = getRemarkStore() + if (/^(清空|删除|none)$/i.test(remark)) delete remarks[uid]; else remarks[uid] = remark.slice(0, 30) + setRemarkStore(remarks) + return reply("已更新备注:" + maskUid(uid) + (remarks[uid] ? "(" + remarks[uid] + ")" : "")) +} +function accountMenu(store, bind) { + reply(accountMenuText()) + var op = listenInput(60000) + if (!op || /^(q|退出|取消)$/i.test(op)) return reply("已退出") + if (/^(1|查看|查询|列表|账号)$/i.test(op)) return reply(accountListText(store, bind)) + if (/^(2|添加|新增|更新)$/i.test(op)) { + reply("请在60秒内发送 QX 抓到的完整 Cookie(q:退出)") + var ck = listenInput(60000) + if (!ck || /^(q|退出|取消)$/i.test(ck)) return reply("已退出") + var uid = extractUid(ck) + var msg = handleSaveCookie(ck, store, bind) + var remarkMsg = uid ? maybePromptRemark(uid) : "" + return reply(msg + remarkMsg) + } + if (/^(3|详情|备注|改名|名称)$/i.test(op)) return accountDetailMenu(store, bind) + if (/^(4|删除)$/i.test(op)) { + var uid = chooseUid(store, bind, "请选择要删除的账号:") + if (!uid) return reply("已退出") + return reply(handleDelete("删除 " + uid, store, bind)) + } + if (/^(5|清空|全部)$/i.test(op)) return reply(handleDelete("删除 all", store, bind)) + return reply("未识别选项,已退出") +} +function queryMenu() { + reply(queryMenuText()) + var op = listenInput(60000) + if (!op || /^(q|退出|取消)$/i.test(op)) return reply("已退出") + if (/^(1|查看|查询|状态|结果)$/i.test(op)) return reply(queryStatus()) + if (/^(2|单个|单号|账号)$/i.test(op)) { + var store = getStore() + var bind = getBindStore() + var uid = chooseUid(store, bind, "请选择要查询的账号:") + if (!uid) return reply("已退出") + return reply(queryStatus(uid)) + } + if (/^(3|执行|签到|立即)$/i.test(op)) return reply(signAll("", false)) + return reply("未识别选项,已退出") +} +function handleDelete(arg, store, bind) { var del = arg.match(/^(删除|delete|del|rm)\s+(\S+)/i) if (!del) return null var duid = del[2] + var uids = visibleUids(store, bind, false) if (/^(all|全部|所有)$/i.test(duid)) { - var n = Object.keys(store).length - setStore({}) - return "已删除全部起点读书CK,共 " + n + " 个账号" + for (var i = 0; i < uids.length; i++) { delete store[uids[i]]; delete bind[uids[i]] } + var remarks = getRemarkStore(); for (var j = 0; j < uids.length; j++) delete remarks[uids[j]] + setStore(store); setBindStore(bind); setRemarkStore(remarks) + return "已删除全部当前用户绑定的启点读书CK,共 " + uids.length + " 个账号" } - var existed = !!store[duid] - delete store[duid] - setStore(store) - return existed ? ("已删除 uid: " + duid) : ("未找到 uid: " + duid) + if (uids.indexOf(duid) < 0) return store[duid] ? ("无权删除 uid: " + duid) : ("未找到 uid: " + duid) + delete store[duid]; delete bind[duid] + var remarks = getRemarkStore(); delete remarks[duid] + setStore(store); setBindStore(bind); setRemarkStore(remarks) + return "已删除 uid: " + duid } -function handleSaveCookie(arg, store) { +function handleSaveCookie(arg, store, bind) { var cookie = arg - if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) return "未识别到 Cookie。请发送:起点ck " + if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) return "未识别到 Cookie。请发送:启点ck " var uid = extractUid(cookie) - if (!uid) return "无法从 Cookie 识别 uid。请确认 QX 抓的是起点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。" + if (!uid) return "无法从 Cookie 识别 uid。请确认 QX 抓的是启点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。" var isNew = !store[uid] var changed = store[uid] !== cookie + var me = currentUserId() store[uid] = cookie - setStore(store) - return ["起点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, "账号数: " + Object.keys(store).length, "cookie: " + maskCookie(cookie), "签到:发送 起点签到"].join("\n") + if (me) bind[uid] = me + setStore(store); setBindStore(bind) + return ["启点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, me ? ("绑定用户: " + maskUid(me)) : "绑定用户: 未识别", "账号数: " + visibleUids(store, bind, false).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 (!arg && cmd !== "sign" && cmd !== "account" && cmd !== "status") { reply(usage()); return } if (/^(帮助|help|h)$/i.test(arg)) { reply(usage()); return } var store = getStore() + var bind = getBindStore() 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) + var out = signAll(target, isCronRun) + 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 (cmd === "account") { accountMenu(store, bind); return } + if (cmd === "status") { queryMenu(); return } + if (cmd === "ck" && /^(查询|状态|status|list|账号|管理)$/i.test(arg)) { reply("请使用一级菜单:启点账号 或 启点查询;快速提交CK:启点ck "); return } + var delMsg = handleDelete(arg, store, bind) 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)) + reply(handleSaveCookie(arg, store, bind)) } main() diff --git a/plugins/qdreader/README.md b/plugins/qdreader/README.md index 04ebb14..908487e 100644 --- a/plugins/qdreader/README.md +++ b/plugins/qdreader/README.md @@ -1,6 +1,6 @@ -# 起点读书签到 Autman 插件 +# 启点读书 QDReader 签到 Autman 插件 -起点读书(QDReader)Autman 签到插件。QX 只负责抓取 Cookie,签到由本插件在 Autman 内完成。 +QDReader/起点读书 Autman 签到插件。用户侧触发词统一使用“启点”,避免平台关键词麻烦;QX 只负责抓取 Cookie,账号管理、执行查询和签到都在 Autman 插件内完成。 ## 文件位置 @@ -23,66 +23,104 @@ https://gitea.chickliu.fun/Hermes/autman-plugins/raw/branch/main/plugins/qdreade https://gitea.chickliu.fun/Hermes/autman-plugins/raw/branch/main/js/qdreader_sign_autman.js ``` -## 功能 +## 当前交互设计 -- QX 抓到 Cookie 后,把 `起点ck ` 发给 Autman 保存。 -- 手动全部签到:`起点签到`。 -- 手动单账号签到:`起点签到 `。 -- 定时签到:头注 `//[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 存储、重试、查询脱敏、删除全部。 - -## 配置 - -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 -起点签到 -起点签到 +启点账号管理 +1. 查看账号 +2. 添加/更新账号 +3. 账号详情/修改备注 +4. 删除账号 +5. 清空账号 +q. 退出 +请回复序号 +``` + +`启点查询` 只负责执行情况和执行动作: + +```text +启点查询 +1. 查看最近执行情况 +2. 查询单个账号 +3. 立即执行签到 +q. 退出 +请回复序号 +``` + +### 快速通道 + +```text +启点ck +启点签到 +启点签到 qdreader sign ``` +`启点ck` 只保留快速提交 Cookie,不再承担查询/导出/批量导入等管理功能。 + +## 账号与备注 + +- 保存 Cookie 后自动绑定当前 `sender.getUserID()`。 +- 查询、删除、手动签到默认只操作当前用户绑定的账号。 +- 账号详情、删除、单账号查询都使用编号选择,不要求用户复制/输入 UID。 +- 添加/更新账号后会提示是否添加备注。 +- 修改备注在“账号详情/修改备注”中完成。 +- 删除账号或清空当前用户账号时,会同步清理对应备注。 +- 普通回复只展示脱敏 UID 和备注,不输出完整 Cookie。 + +## 存储 + +主数据桶: + +```text +hermes_qidian +``` + +主要 key: + +```text +qdreader_cookie_json # uid -> full cookie +qdreader_user_bind_json # uid -> sender.getUserID() +qdreader_remark_json # uid -> 备注名 +qdreader_last_result_json # uid -> 最近执行结果 JSON 字符串 +qdreader_sign_api # 签名网关 +qdreader_retry_count # 请求失败重试次数 +qdreader_cron_silent # 定时无账号静默 +``` + +读取兼容顺序: + +```text +bucketGet("hermes_qidian", key) + ↓ fallback +get(key) + ↓ fallback +bucketGet("otto", key) # 旧数据兼容 +``` + +写入顺序: + +```text +bucketSet("hermes_qidian", key, value) +set(key, value) +``` + ## 签名与签到流程 -签名与签到流程沿用上游 QDReader: - 1. 从 Cookie 解析 `QDHeader/qdh/uid/guid/qimei/qimei36`。 -2. `POST https://api.120399.xyz/sign` 获取起点请求签名头。 +2. `POST /sign` 获取请求签名头。 3. `POST https://h5.if.qidian.com/argus/api/v3/checkin/checkin` 完成签到。 -4. 汇总每个账号签到结果。 +4. 写入 `qdreader_last_result_json`。 +5. 汇总每个账号签到结果。 ## 本地验证 diff --git a/plugins/qdreader/qdreader_sign_autman.js b/plugins/qdreader/qdreader_sign_autman.js index c34b102..791bd5d 100644 --- a/plugins/qdreader/qdreader_sign_autman.js +++ b/plugins/qdreader/qdreader_sign_autman.js @@ -1,27 +1,30 @@ //[open_source: true] //[create_at: 2026-05-17 03:00:00] -//[title: 起点读书签到] +//[title: 启点读书签到] //[author: Hermes] //[service: BOSS] //[class: 工具类] -//[version: 2.2.0] +//[version: 2.3.0] //[platform: web,qq,wx,tg,tb,fs,we] //[public: false] //[price: 0] //[disable: false] -//[description: 起点读书(QDReader) Autman 签到插件:保存 QX 抓到的 H5 Cookie,支持手动“起点签到”和每日 07:15 定时签到;带多环境存储兼容、请求重试、签名响应兼容、结果摘要和安全脱敏。] -//[rule: ^起点(ck|CK|cookie|Cookie|签到|读书)(.*)$] +//[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,可替换为兼容 /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 时定时触发且没有账号不回复"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_cookie_json","bool":false,"placeholder":"{\"uid\":\"cookie\"}","name":"QDReader Cookie JSON","desc":"机器人交互维护,JSON Map 格式;兼容读取旧 otto bucket"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz,可替换为兼容 /sign 的网关"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_retry_count","bool":false,"placeholder":"1","name":"请求失败重试次数","desc":"默认 1,最大 3,仅对网络异常/非业务异常重试"}] +//[param: {"required":false,"key":"hermes_qidian.qdreader_cron_silent","bool":true,"placeholder":"false","name":"定时无账号时静默","desc":"true 时定时触发且没有账号不回复"}] -var BUCKET = "otto" +var BUCKET = "hermes_qidian" +var LEGACY_BUCKET = "otto" var STORE_KEY = "qdreader_cookie_json" +var BIND_KEY = "qdreader_user_bind_json" +var REMARK_KEY = "qdreader_remark_json" var SIGN_API_KEY = "qdreader_sign_api" var RETRY_KEY = "qdreader_retry_count" var CRON_SILENT_KEY = "qdreader_cron_silent" @@ -37,15 +40,16 @@ function trim(s) { return String(s === undefined || s === null ? "" : s).replace 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) {} + 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 lv = bucketGet(LEGACY_BUCKET, key); if (lv !== undefined && lv !== null && String(lv) !== "") return lv } } 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) {} + try { if (typeof set === "function") { set(key, s); ok = true } } catch (e) {} if (!ok) throw new Error("当前 Autman 环境没有 set/bucketSet,无法保存配置") return s } @@ -75,15 +79,36 @@ function reply(msg) { } function debug(msg) { try { if (typeof Debug === "function") Debug(String(msg)); else console.log(String(msg)) } catch (e) {} } -function getStore() { - var raw = cfgGet(STORE_KEY, "{}") - var obj = safeJsonParse(raw, {}) +function cleanStringMap(obj) { if (!isObject(obj)) return {} var clean = {} - for (var uid in obj) if (obj.hasOwnProperty(uid) && obj[uid]) clean[String(uid)] = String(obj[uid]) + for (var k in obj) if (obj.hasOwnProperty(k) && obj[k] !== undefined && obj[k] !== null && String(obj[k]) !== "") clean[String(k)] = String(obj[k]) return clean } +function getStore() { return cleanStringMap(safeJsonParse(cfgGet(STORE_KEY, "{}"), {})) } function setStore(obj) { return cfgSet(STORE_KEY, JSON.stringify(obj || {})) } +function getBindStore() { return cleanStringMap(safeJsonParse(cfgGet(BIND_KEY, "{}"), {})) } +function setBindStore(obj) { return cfgSet(BIND_KEY, JSON.stringify(obj || {})) } +function getRemarkStore() { return cleanStringMap(safeJsonParse(cfgGet(REMARK_KEY, "{}"), {})) } +function setRemarkStore(obj) { return cfgSet(REMARK_KEY, JSON.stringify(obj || {})) } +function currentUserId() { + var v = "" + try { if (typeof sender !== "undefined" && sender && sender.getUserID) v = sender.getUserID() || "" } catch (e) {} + try { if (!v && typeof sender !== "undefined" && sender && sender.getUserId) v = sender.getUserId() || "" } catch (e) {} + try { if (!v && typeof userId !== "undefined") v = String(userId || "") } catch (e) {} + try { if (!v && typeof getUserID === "function") v = getUserID() || "" } catch (e) {} + return trim(v) +} +function boundToCurrent(uid, bind) { + var owner = bind && bind[String(uid)] ? String(bind[String(uid)]) : "" + var me = currentUserId() + return !owner || !me || owner === me +} +function visibleUids(store, bind, allUsers) { + var uids = Object.keys(store || {}).sort() + if (allUsers) return uids + return uids.filter(function (uid) { return boundToCurrent(uid, bind || {}) }) +} function parseCookieString(cookie) { var jar = {} @@ -146,26 +171,32 @@ function contentText() { } function usage() { return [ - "起点读书签到插件 v2.2.0", - "提交CK:起点ck ", - "签到全部:起点签到", - "签到单号:起点签到 ", - "查询CK:起点ck 查询", - "导出CK:起点ck 导出(会输出完整 Cookie,仅管理员自用)", - "删除CK:起点ck 删除 ", - "配置签名网关:Autman 参数 otto.qdreader_sign_api,默认 " + DEFAULT_SIGN_API, + "启点读书签到插件 v2.3.0", + "【一级菜单】", + "账号管理:启点账号", + "执行查询:启点查询", + "【快速通道】", + "快速提交CK:启点ck ", + "直接签到:启点签到", + "指定签到:启点签到 ", + "【配置】", + "签名网关:Autman 参数 hermes_qidian.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 trim(s.replace(/^起点(?:签到|读书)\s*/i, "")) - if (/^起点(?:ck|CK|cookie|Cookie)(?:\s|$)/i.test(s)) return trim(s.replace(/^起点(?:ck|CK|cookie|Cookie)\s*/i, "")) + if (/^启点(?:签到|读书)(?:\s|$)/i.test(s)) return trim(s.replace(/^启点(?:签到|读书)\s*/i, "")) + if (/^启点(?:查询)(?:\s|$)/i.test(s)) return trim(s.replace(/^启点(?:查询)\s*/i, "")) + 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" + if (/^启点(?:签到|读书)(?:\s|$)/i.test(text)) return "sign" if (/^qdreader\s+sign(?:\s|$)/i.test(text)) return "sign" + if (/^启点(?:查询)(?:\s|$)/i.test(text)) return "status" + if (/^启点(?:账号)(?:\s|$)/i.test(text)) return "account" return "ck" } function qs(obj) { @@ -259,20 +290,34 @@ 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 setLastResult(uid, ok, msg) { + var key = "qdreader_last_result_json" + var obj = cleanStringMap(safeJsonParse(cfgGet(key, "{}"), {})) + obj[String(uid)] = JSON.stringify({ ok: !!ok, msg: String(msg || ""), time: new Date().toISOString() }) + cfgSet(key, JSON.stringify(obj)) +} +function getLastResults() { + var raw = cleanStringMap(safeJsonParse(cfgGet("qdreader_last_result_json", "{}"), {})) + var out = {} + for (var uid in raw) if (raw.hasOwnProperty(uid)) out[uid] = safeJsonParse(raw[uid], { msg: raw[uid] }) + return out +} function doCheckin(uid, cookie) { var res = qidianRequest(CHECKIN_PATH, "POST", {}, cookie) var ok = resultOk(res) var msg = resultMsg(res) || (ok ? "签到请求成功" : ("签到失败" + (resultCode(res) !== undefined ? " code=" + resultCode(res) : ""))) + setLastResult(uid, ok, msg) return { uid: uid, ok: ok, msg: msg, raw: res } } -function signAll(targetUid) { +function signAll(targetUid, allUsers) { var store = getStore() - var uids = Object.keys(store).sort() + var bind = getBindStore() + var uids = visibleUids(store, bind, !!allUsers) if (targetUid) uids = uids.filter(function (u) { return u === targetUid }) - if (!uids.length) return targetUid ? ("未找到 uid: " + targetUid) : "还没有保存起点读书CK" + if (!uids.length) return targetUid ? ("未找到 uid: " + targetUid) : "还没有保存启点读书CK" var okCount = 0 var failCount = 0 - var lines = ["起点读书签到结果:"] + var lines = ["启点读书签到结果:"] for (var i = 0; i < uids.length; i++) { var uid = uids[i] try { @@ -287,70 +332,218 @@ function signAll(targetUid) { lines.push("汇总:成功 " + okCount + " / 失败 " + failCount + " / 总计 " + uids.length) return lines.join("\n") } -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++ - } - setStore(store) - return "起点读书CK JSON已导入/更新 " + count + " 个账号,当前共 " + Object.keys(store).length + " 个账号\n发送 起点签到 可执行签到" + +function formatStatusLine(uid, last, remarks) { + var name = maskUid(uid) + (remarks && remarks[uid] ? "(" + remarks[uid] + ")" : "") + var r = last[uid] + if (!r) return "- " + name + ":暂无执行记录" + return "- " + name + ":" + (r.ok ? "✅ " : "❌ ") + (r.msg || "无结果") + (r.time ? " @ " + r.time : "") } -function handleDelete(arg, store) { +function queryStatus(targetUid) { + var store = getStore() + var bind = getBindStore() + var last = getLastResults() + var remarks = getRemarkStore() + var uids = visibleUids(store, bind, false) + if (targetUid) { + if (uids.indexOf(targetUid) < 0) return store[targetUid] ? ("无权查询 uid: " + targetUid) : ("未找到 uid: " + targetUid) + uids = [targetUid] + } + if (!uids.length) return "当前用户还没有保存启点读书CK" + var lines = [targetUid ? "启点单账号执行情况:" : "启点执行情况:"] + for (var i = 0; i < uids.length; i++) lines.push(formatStatusLine(uids[i], last, remarks)) + return lines.join("\n") +} + +function listenInput(timeoutMs) { + try { if (typeof sender !== "undefined" && sender && sender.listen) return trim(sender.listen(timeoutMs || 60000)) } catch (e) {} + return "" +} +function accountMenuText() { + return [ + "启点账号管理", + "1. 查看账号", + "2. 添加/更新账号", + "3. 账号详情/修改备注", + "4. 删除账号", + "5. 清空账号", + "q. 退出", + "请回复序号" + ].join("\n") +} +function queryMenuText() { + return [ + "启点查询", + "1. 查看最近执行情况", + "2. 查询单个账号", + "3. 立即执行签到", + "q. 退出", + "请回复序号" + ].join("\n") +} +function accountListText(store, bind) { + var uids = visibleUids(store, bind, false) + var remarks = getRemarkStore() + if (!uids.length) return "当前用户还没有保存启点读书CK" + var lines = ["启点账号:当前用户共 " + uids.length + " 个账号"] + for (var i = 0; i < uids.length; i++) { + var uid = uids[i] + lines.push(maskUid(uid) + (remarks[uid] ? "(" + remarks[uid] + ")" : "")) + } + lines.push("发送 启点签到 可执行签到") + return lines.join("\n") +} + + +function accountSelectText(store, bind, title) { + var uids = visibleUids(store, bind, false) + var remarks = getRemarkStore() + if (!uids.length) return { text: "当前用户还没有保存启点读书CK", uids: [] } + var lines = [title || "请选择账号:"] + for (var i = 0; i < uids.length; i++) { + var uid = uids[i] + lines.push((i + 1) + ". " + maskUid(uid) + (remarks[uid] ? "(" + remarks[uid] + ")" : "")) + } + lines.push("q. 退出") + lines.push("请回复编号") + return { text: lines.join("\n"), uids: uids } +} +function chooseUid(store, bind, title) { + var sel = accountSelectText(store, bind, title) + reply(sel.text) + if (!sel.uids.length) return "" + var op = listenInput(60000) + if (!op || /^(q|退出|取消)$/i.test(op)) return "" + var n = parseInt(op, 10) + if (isNaN(n) || n < 1 || n > sel.uids.length) { reply("编号无效,已退出"); return "" } + return sel.uids[n - 1] +} +function maybePromptRemark(uid) { + if (!uid) return "" + reply("是否为该账号添加备注?回复备注名,发送 n/跳过 则不添加") + var remark = listenInput(60000) + if (!remark || /^(n|no|否|不|跳过|q|退出|取消)$/i.test(remark)) return "" + var remarks = getRemarkStore() + remarks[uid] = remark.slice(0, 30) + setRemarkStore(remarks) + return "\n备注: " + remarks[uid] +} +function accountDetailText(uid, store, bind) { + var uids = visibleUids(store, bind, false) + if (uids.indexOf(uid) < 0) return store[uid] ? ("无权查看 uid: " + uid) : ("未找到 uid: " + uid) + var remarks = getRemarkStore() + var last = getLastResults() + return [ + "启点账号详情:", + "uid: " + maskUid(uid), + "备注: " + (remarks[uid] || "未设置"), + "cookie: " + maskCookie(store[uid]), + formatStatusLine(uid, last, remarks), + "修改备注请回复新备注名,发送 清空 可删除备注,发送 q 退出" + ].join("\n") +} +function accountDetailMenu(store, bind) { + var uid = chooseUid(store, bind, "请选择要查看/修改备注的账号:") + if (!uid) return reply("已退出") + var detail = accountDetailText(uid, store, bind) + reply(detail) + if (/^(无权|未找到)/.test(detail)) return + var remark = listenInput(60000) + if (!remark || /^(q|退出|取消)$/i.test(remark)) return reply("已退出") + var remarks = getRemarkStore() + if (/^(清空|删除|none)$/i.test(remark)) delete remarks[uid]; else remarks[uid] = remark.slice(0, 30) + setRemarkStore(remarks) + return reply("已更新备注:" + maskUid(uid) + (remarks[uid] ? "(" + remarks[uid] + ")" : "")) +} +function accountMenu(store, bind) { + reply(accountMenuText()) + var op = listenInput(60000) + if (!op || /^(q|退出|取消)$/i.test(op)) return reply("已退出") + if (/^(1|查看|查询|列表|账号)$/i.test(op)) return reply(accountListText(store, bind)) + if (/^(2|添加|新增|更新)$/i.test(op)) { + reply("请在60秒内发送 QX 抓到的完整 Cookie(q:退出)") + var ck = listenInput(60000) + if (!ck || /^(q|退出|取消)$/i.test(ck)) return reply("已退出") + var uid = extractUid(ck) + var msg = handleSaveCookie(ck, store, bind) + var remarkMsg = uid ? maybePromptRemark(uid) : "" + return reply(msg + remarkMsg) + } + if (/^(3|详情|备注|改名|名称)$/i.test(op)) return accountDetailMenu(store, bind) + if (/^(4|删除)$/i.test(op)) { + var uid = chooseUid(store, bind, "请选择要删除的账号:") + if (!uid) return reply("已退出") + return reply(handleDelete("删除 " + uid, store, bind)) + } + if (/^(5|清空|全部)$/i.test(op)) return reply(handleDelete("删除 all", store, bind)) + return reply("未识别选项,已退出") +} +function queryMenu() { + reply(queryMenuText()) + var op = listenInput(60000) + if (!op || /^(q|退出|取消)$/i.test(op)) return reply("已退出") + if (/^(1|查看|查询|状态|结果)$/i.test(op)) return reply(queryStatus()) + if (/^(2|单个|单号|账号)$/i.test(op)) { + var store = getStore() + var bind = getBindStore() + var uid = chooseUid(store, bind, "请选择要查询的账号:") + if (!uid) return reply("已退出") + return reply(queryStatus(uid)) + } + if (/^(3|执行|签到|立即)$/i.test(op)) return reply(signAll("", false)) + return reply("未识别选项,已退出") +} +function handleDelete(arg, store, bind) { var del = arg.match(/^(删除|delete|del|rm)\s+(\S+)/i) if (!del) return null var duid = del[2] + var uids = visibleUids(store, bind, false) if (/^(all|全部|所有)$/i.test(duid)) { - var n = Object.keys(store).length - setStore({}) - return "已删除全部起点读书CK,共 " + n + " 个账号" + for (var i = 0; i < uids.length; i++) { delete store[uids[i]]; delete bind[uids[i]] } + var remarks = getRemarkStore(); for (var j = 0; j < uids.length; j++) delete remarks[uids[j]] + setStore(store); setBindStore(bind); setRemarkStore(remarks) + return "已删除全部当前用户绑定的启点读书CK,共 " + uids.length + " 个账号" } - var existed = !!store[duid] - delete store[duid] - setStore(store) - return existed ? ("已删除 uid: " + duid) : ("未找到 uid: " + duid) + if (uids.indexOf(duid) < 0) return store[duid] ? ("无权删除 uid: " + duid) : ("未找到 uid: " + duid) + delete store[duid]; delete bind[duid] + var remarks = getRemarkStore(); delete remarks[duid] + setStore(store); setBindStore(bind); setRemarkStore(remarks) + return "已删除 uid: " + duid } -function handleSaveCookie(arg, store) { +function handleSaveCookie(arg, store, bind) { var cookie = arg - if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) return "未识别到 Cookie。请发送:起点ck " + if (cookie.indexOf("=") < 0 || cookie.indexOf(";") < 0) return "未识别到 Cookie。请发送:启点ck " var uid = extractUid(cookie) - if (!uid) return "无法从 Cookie 识别 uid。请确认 QX 抓的是起点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。" + if (!uid) return "无法从 Cookie 识别 uid。请确认 QX 抓的是启点读书福利中心 getlogininfo 请求,Cookie 内应包含 QDHeader 或 uid。" var isNew = !store[uid] var changed = store[uid] !== cookie + var me = currentUserId() store[uid] = cookie - setStore(store) - return ["起点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, "账号数: " + Object.keys(store).length, "cookie: " + maskCookie(cookie), "签到:发送 起点签到"].join("\n") + if (me) bind[uid] = me + setStore(store); setBindStore(bind) + return ["启点读书CK" + (isNew ? "新增" : (changed ? "更新" : "未变化")) + "成功", "uid: " + uid, me ? ("绑定用户: " + maskUid(me)) : "绑定用户: 未识别", "账号数: " + visibleUids(store, bind, false).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 (!arg && cmd !== "sign" && cmd !== "account" && cmd !== "status") { reply(usage()); return } if (/^(帮助|help|h)$/i.test(arg)) { reply(usage()); return } var store = getStore() + var bind = getBindStore() 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) + var out = signAll(target, isCronRun) + 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 (cmd === "account") { accountMenu(store, bind); return } + if (cmd === "status") { queryMenu(); return } + if (cmd === "ck" && /^(查询|状态|status|list|账号|管理)$/i.test(arg)) { reply("请使用一级菜单:启点账号 或 启点查询;快速提交CK:启点ck "); return } + var delMsg = handleDelete(arg, store, bind) 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)) + reply(handleSaveCookie(arg, store, bind)) } main() diff --git a/tests/qdreader_plugin.test.mjs b/tests/qdreader_plugin.test.mjs index eff8a60..cdc67fd 100644 --- a/tests/qdreader_plugin.test.mjs +++ b/tests/qdreader_plugin.test.mjs @@ -5,10 +5,11 @@ 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 = [] } = {}) { +function runPlugin({ content = '', store = {}, bucketStore = {}, requests = [], userId = 'user-a', listens = [] } = {}) { const replies = []; const calls = []; let requestIndex = 0; + let listenIndex = 0; const ctx = { console, Buffer, @@ -17,6 +18,8 @@ function runPlugin({ content = '', store = {}, bucketStore = {}, requests = [] } getMessage: () => content, getContent: () => content, isAdmin: () => true, + getUserID: () => userId, + listen: () => (listenIndex < listens.length ? listens[listenIndex++] : 'q'), }, GetContent: () => content, getContent: () => content, @@ -52,16 +55,19 @@ function test(name, fn) { } test('manual cookie save masks cookie and stores by uid', () => { - const r = runPlugin({ content: '起点ck ' + cookie }); + 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); + assert.equal(JSON.parse(r.bucketStore['hermes_qidian.qdreader_cookie_json'])['12345'], cookie); + assert.equal(JSON.parse(r.store.qdreader_user_bind_json)['12345'], 'user-a'); + assert.equal(JSON.parse(r.bucketStore['hermes_qidian.qdreader_user_bind_json'])['12345'], 'user-a'); }); 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 } }); + 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$/); @@ -69,7 +75,7 @@ test('manual sign and cron sign both call sign gateway then qidian checkin', () const cron = runPlugin({ content: '', store: { ...store } }); assert.equal(cron.calls.length, 2); - assert.match(cron.replies[0], /起点读书签到结果/); + assert.match(cron.replies[0], /启点读书签到结果/); }); test('bucketGet/bucketSet fallback works when get/set are unavailable in Autman-like env', () => { @@ -78,7 +84,7 @@ test('bucketGet/bucketSet fallback works when get/set are unavailable in Autman- const ctx = { console, Buffer, - sender: { reply: (msg) => replies.push(String(msg)), getMessage: () => '起点ck ' + cookie }, + 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: {} } } }), @@ -86,13 +92,24 @@ test('bucketGet/bucketSet fallback works when get/set are unavailable in Autman- 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); + assert.equal(JSON.parse(bucketStore['hermes_qidian.qdreader_cookie_json'])['12345'], cookie); + assert.equal(JSON.parse(bucketStore['hermes_qidian.qdreader_user_bind_json'])['12345'], undefined); +}); + +test('reads legacy otto bucket but writes new hermes_qidian bucket', () => { + const bucketStore = { + 'otto.qdreader_cookie_json': JSON.stringify({ '12345': cookie }), + 'otto.qdreader_user_bind_json': JSON.stringify({ '12345': 'user-a' }), + }; + const r = runPlugin({ content: '启点账号', bucketStore, userId: 'user-a', listens: ['3', '1', '主号'] }); + assert.match(r.replies.join('\n'), /已更新备注/); + assert.equal(JSON.parse(r.bucketStore['hermes_qidian.qdreader_remark_json'])['12345'], '主号'); }); test('qidian request retries transient request errors', () => { const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }), qdreader_retry_count: '2' }; const r = runPlugin({ - content: '起点签到', + content: '启点签到', store, requests: [ { body: { code: 0, data: { headers: { 'QD-Sign': 'sig' } } } }, @@ -104,17 +121,119 @@ test('qidian request retries transient request errors', () => { 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('account command shows only current user bound accounts without cookies', () => { + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie, '67890': cookie.replace(/12345/g, '67890') }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a', '67890': 'user-b' }), + qdreader_remark_json: JSON.stringify({ '12345': '主号', '67890': '副号' }), + }; + const r = runPlugin({ content: '启点账号', store, userId: 'user-a', listens: ['1'] }); + assert.match(r.replies[0], /启点账号管理/); + assert.match(r.replies[1], /当前用户共 1 个账号/); + assert.match(r.replies[1], /12\*\*\*45/); + assert.match(r.replies[1], /主号/); + assert.doesNotMatch(r.replies[1], /67\*\*\*90/); + assert.doesNotMatch(r.replies[1], /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), {}); +test('query command shows execution status not account management', () => { + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie, '67890': cookie.replace(/12345/g, '67890') }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a', '67890': 'user-b' }), + qdreader_remark_json: JSON.stringify({ '12345': '主号', '67890': '副号' }), + qdreader_last_result_json: JSON.stringify({ + '12345': JSON.stringify({ ok: true, msg: '签到成功', time: '2026-05-17T00:00:00.000Z' }), + '67890': JSON.stringify({ ok: false, msg: '失败', time: '2026-05-17T00:00:00.000Z' }), + }), + }; + const r = runPlugin({ content: '启点查询', store, userId: 'user-a', listens: ['1'] }); + assert.match(r.replies[0], /启点查询/); + assert.match(r.replies[1], /启点执行情况/); + assert.match(r.replies[1], /12\*\*\*45/); + assert.match(r.replies[1], /主号/); + assert.match(r.replies[1], /签到成功/); + assert.doesNotMatch(r.replies[1], /67\*\*\*90/); +}); + + +test('query menu can inspect a single bound account', () => { + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie, '67890': cookie.replace(/12345/g, '67890') }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a', '67890': 'user-a' }), + qdreader_remark_json: JSON.stringify({ '12345': '主号', '67890': '副号' }), + qdreader_last_result_json: JSON.stringify({ + '12345': JSON.stringify({ ok: true, msg: '签到成功', time: '2026-05-17T00:00:00.000Z' }), + '67890': JSON.stringify({ ok: false, msg: '失败', time: '2026-05-17T00:00:00.000Z' }), + }), + }; + const r = runPlugin({ content: '启点查询', store, userId: 'user-a', listens: ['2', '2'] }); + assert.match(r.replies[0], /启点查询/); + assert.match(r.replies[1], /请选择要查询的账号/); + assert.match(r.replies[1], /1\. 12\*\*\*45/); + assert.match(r.replies[1], /2\. 67\*\*\*90/); + assert.match(r.replies[2], /启点单账号执行情况/); + assert.match(r.replies[2], /67\*\*\*90/); + assert.match(r.replies[2], /副号/); + assert.doesNotMatch(r.replies[2], /12\*\*\*45/); +}); + +test('account menu prompts remark after add and can edit from detail', () => { + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a' }), + }; + const add = runPlugin({ content: '启点账号', store: {}, userId: 'user-a', listens: ['2', cookie, '主号'] }); + assert.match(add.replies.join('\n'), /是否为该账号添加备注/); + assert.equal(JSON.parse(add.store.qdreader_remark_json)['12345'], '主号'); + const clear = runPlugin({ content: '启点账号', store: add.store, userId: 'user-a', listens: ['3', '1', '清空'] }); + assert.match(clear.replies.join('\n'), /请选择要查看\/修改备注的账号/); + assert.match(clear.replies.join('\n'), /启点账号详情/); + assert.equal(JSON.parse(clear.store.qdreader_remark_json)['12345'], undefined); +}); + +test('legacy ck query points to separated commands', () => { + const r = runPlugin({ content: '启点ck 查询' }); + assert.match(r.replies[0], /请使用一级菜单:启点账号 或 启点查询/); +}); + + +test('delete account uses numbered selection instead of uid input', () => { + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie, '67890': cookie.replace(/12345/g, '67890') }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a', '67890': 'user-a' }), + qdreader_remark_json: JSON.stringify({ '12345': '主号', '67890': '副号' }), + }; + const r = runPlugin({ content: '启点账号', store, userId: 'user-a', listens: ['4', '2'] }); + assert.match(r.replies[1], /请选择要删除的账号/); + assert.match(r.replies[1], /1\. 12\*\*\*45/); + assert.match(r.replies[1], /2\. 67\*\*\*90/); + assert.match(r.replies[2], /已删除 uid: 67890/); + assert.deepEqual(JSON.parse(r.store.qdreader_cookie_json), { '12345': cookie }); +}); + +test('delete all command clears only current user saved cookies', () => { + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie, '67890': cookie.replace(/12345/g, '67890') }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a', '67890': 'user-b' }), + qdreader_remark_json: JSON.stringify({ '12345': '主号', '67890': '副号' }), + }; + const r = runPlugin({ content: '启点账号', store, userId: 'user-a', listens: ['5'] }); + assert.match(r.replies[0], /启点账号管理/); + assert.match(r.replies[1], /已删除全部/); + assert.deepEqual(JSON.parse(r.store.qdreader_cookie_json), { '67890': cookie.replace(/12345/g, '67890') }); + assert.deepEqual(JSON.parse(r.store.qdreader_user_bind_json), { '67890': 'user-b' }); + assert.deepEqual(JSON.parse(r.store.qdreader_remark_json), { '67890': '副号' }); +}); + +test('manual sign only runs current user accounts while cron runs all accounts', () => { + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie, '67890': cookie.replace(/12345/g, '67890') }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a', '67890': 'user-b' }), + }; + const manual = runPlugin({ content: '启点签到', store: { ...store }, userId: 'user-a' }); + assert.equal(manual.calls.length, 2); + assert.match(manual.replies[0], /总计 1/); + const cron = runPlugin({ content: '', store: { ...store }, userId: 'cron' }); + assert.equal(cron.calls.length, 4); + assert.match(cron.replies[0], /总计 2/); });