From 0e985c790a14657b76b078fdb6e6519095c5ce7b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 19 May 2026 15:05:55 +0800 Subject: [PATCH] fix(qdreader): archive daily sign logs at midnight --- docs/autman-plugin-rules.md | 4 ++ js/qdreader_sign_autman.js | 74 +++++++++++++++++++++--- plugins/qdreader/README.md | 21 ++++--- plugins/qdreader/qdreader_sign_autman.js | 74 +++++++++++++++++++++--- tests/qdreader_plugin.test.mjs | 52 +++++++++++++++++ 5 files changed, 202 insertions(+), 23 deletions(-) diff --git a/docs/autman-plugin-rules.md b/docs/autman-plugin-rules.md index 606cdbf..2e30b54 100644 --- a/docs/autman-plugin-rules.md +++ b/docs/autman-plugin-rules.md @@ -556,6 +556,9 @@ Content-Type: application/json // last_run_json:最近一次执行批次日志 { "source": "cron", "finishedAt": "...", "okCount": 1, "failCount": 0, "total": 1, "items": [] } + +// archive_json:按日期归档后的批次日志,用于每日结算/历史判断 +{ "2026-05-18": { "source": "cron", "finishedAt": "...", "okCount": 1, "failCount": 0, "total": 1, "items": [] } } ``` 这样删除、过滤、展示和权限校验更安全。 @@ -616,6 +619,7 @@ QDReader 这次踩坑说明:账号 ID 识别成功,不代表 Autman 会话 - `xxx查询` 的数据来源必须写清楚:如果没有实时接口,就命名为“最近签到记录/最后执行日志”,读取 `last_run_json` 和 `last_result_json`,不要让用户误以为是实时查询。 - 执行动作要同时写批次日志和单账号日志;成功、业务失败、请求异常都要落日志,异常不能只显示在当次回复里。 +- 如果插件需要按天判断执行情况,cron 建议放在 `0 0 * * *`;零点触发时先将上一份批次日志按日期写入 `archive_json`,再清空 `last_run_json` / `last_result_json` 并执行新一天任务。查询菜单优先展示当日/当前批次;当日日志为空时回退最近归档,按归档结果反馈成功/失败。 - 普通回复永远不要输出完整 Cookie;只展示脱敏 UID、备注、Cookie key 摘要或长度。 - 高风险的导出/批量导入功能默认不做,除非 BOSS 明确需要。 - Node VM 测试应模拟:`sender.reply`、`sender.listen`、`sender.getUserID`、`get/set`、`bucketGet/bucketSet`、`request`。 diff --git a/js/qdreader_sign_autman.js b/js/qdreader_sign_autman.js index 7f16874..ad26a1c 100644 --- a/js/qdreader_sign_autman.js +++ b/js/qdreader_sign_autman.js @@ -4,7 +4,7 @@ //[author: Hermes] //[service: BOSS] //[class: 工具类] -//[version: 2.4.2] +//[version: 2.5.0] //[platform: web,qq,wx,tg,tb,fs,we] //[public: false] //[price: 0] @@ -14,7 +14,7 @@ //[rule: ^qdreader(.*)$] //[admin: true] //[priority: 600] -//[cron: 15 7 * * *] +//[cron: 0 0 * * *] var BUCKET = "hermes_qidian" @@ -26,6 +26,7 @@ var RETRY_KEY = "qdreader_retry_count" var CRON_SILENT_KEY = "qdreader_cron_silent" var LAST_RESULT_KEY = "qdreader_last_result_json" var LAST_RUN_KEY = "qdreader_last_run_json" +var ARCHIVE_KEY = "qdreader_archive_json" var DEFAULT_SIGN_API = "https://api.120399.xyz/qdreader" var QIDIAN_BASE = "https://h5.if.qidian.com" var CHECKIN_PATH = "/argus/api/v3/checkin/checkin" @@ -259,7 +260,7 @@ function contentText() { } function usage() { return [ - "启点读书签到插件 v2.4.2", + "启点读书签到插件 v2.5.0", "【一级菜单】", "账号管理:启点账号", "执行查询:启点查询", @@ -269,7 +270,7 @@ function usage() { "指定签到:启点签到 ", "【配置】", "签名网关:Autman 参数 hermes_qidian.qdreader_sign_api,默认 " + DEFAULT_SIGN_API, - "定时:每日 07:15 自动执行;QX 只负责抓 cookie。" + "定时:每日 00:00 自动执行、归档上一日日志并清空当日日志;QX 只负责抓 cookie。" ].join("\n") } function stripCommand(text) { @@ -405,8 +406,50 @@ function getLastResults() { } function setLastRunLog(log) { cfgSet(LAST_RUN_KEY, JSON.stringify(log || {})) } function getLastRunLog() { return safeJsonParse(cfgGet(LAST_RUN_KEY, "{}"), {}) || {} } +function getArchiveStore() { return safeJsonParse(cfgGet(ARCHIVE_KEY, "{}"), {}) || {} } +function setArchiveStore(obj) { cfgSet(ARCHIVE_KEY, JSON.stringify(obj || {})) } +function localDateKey(iso) { + var d = iso ? new Date(iso) : new Date() + if (!d || isNaN(d.getTime())) d = new Date() + var y = d.getFullYear() + var m = String(d.getMonth() + 1); if (m.length < 2) m = "0" + m + var day = String(d.getDate()); if (day.length < 2) day = "0" + day + return y + "-" + m + "-" + day +} +function archiveRunLog(reason) { + var run = getLastRunLog() + var archive = getArchiveStore() + var archived = false + var key = "" + if (run && run.finishedAt && run.total !== undefined) { + key = localDateKey(run.finishedAt) + archive[key] = run + archive[key].archivedAt = nowIso() + archive[key].archiveReason = reason || "daily-rollover" + archived = true + } + setArchiveStore(archive) + cfgSet(LAST_RESULT_KEY, "{}") + cfgSet(LAST_RUN_KEY, "{}") + return { archived: archived, key: key, archive: archive, run: run || {} } +} +function latestArchiveForVisible(uids) { + var archive = getArchiveStore() + var keys = [] + for (var k in archive) if (archive.hasOwnProperty(k)) keys.push(k) + keys.sort() + for (var i = keys.length - 1; i >= 0; i--) { + var log = archive[keys[i]] || {} + var items = log.items || [] + var map = {} + for (var j = 0; j < items.length; j++) map[String(items[j].uid)] = items[j] + if (!uids || !uids.length) return { key: keys[i], log: log, itemMap: map } + for (var u = 0; u < uids.length; u++) if (map[String(uids[u])]) return { key: keys[i], log: log, itemMap: map } + } + return null +} function sourceName(source) { - if (source === "cron") return "定时任务" + if (source === "cron") return "定时任务/零点任务" if (source === "menu") return "菜单立即执行" if (source === "manual") return "手动签到" return source || "未知" @@ -467,6 +510,12 @@ function formatStatusLine(uid, last, remarks) { if (!r) return "- " + name + ":暂无执行记录" return "- " + name + ":" + (r.ok ? "✅ " : "❌ ") + (r.msg || "无结果") + (r.time ? " @ " + r.time : "") } +function formatArchiveStatusLine(uid, archiveInfo, remarks) { + var name = maskUid(uid) + (remarks && remarks[uid] ? "(" + remarks[uid] + ")" : "") + var r = archiveInfo && archiveInfo.itemMap ? archiveInfo.itemMap[String(uid)] : null + if (!r) return "- " + name + ":归档中暂无该账号记录" + return "- " + name + ":" + (r.ok ? "✅ " : "❌ ") + (r.msg || "无结果") + (r.time ? " @ " + r.time : "") +} function queryStatus(targetUid) { var store = getStore() var bind = getBindStore() @@ -480,9 +529,19 @@ function queryStatus(targetUid) { } if (!uids.length) return "当前用户还没有保存启点读书CK" var title = targetUid ? "启点单账号最近签到记录:" : "启点最近签到记录:" + var archiveInfo = latestArchiveForVisible(uids) + var useArchive = archiveInfo && (!run || !run.finishedAt) + if (useArchive) title = targetUid ? "启点单账号最近归档签到记录:" : "启点最近归档签到记录:" var lines = [title] - if (!targetUid && run && run.finishedAt) lines.push("最近批次:" + sourceName(run.source) + " @ " + run.finishedAt + ";成功 " + (run.okCount || 0) + " / 失败 " + (run.failCount || 0) + " / 总计 " + (run.total || 0)) - for (var i = 0; i < uids.length; i++) lines.push(formatStatusLine(uids[i], last, remarks)) + if (useArchive) { + var arun = archiveInfo.log || {} + lines.push("最近归档:" + archiveInfo.key + ";" + sourceName(arun.source) + " @ " + (arun.finishedAt || "未知时间") + ";成功 " + (arun.okCount || 0) + " / 失败 " + (arun.failCount || 0) + " / 总计 " + (arun.total || 0)) + for (var ai = 0; ai < uids.length; ai++) lines.push(formatArchiveStatusLine(uids[ai], archiveInfo, remarks)) + } else { + if (!targetUid && run && run.finishedAt) lines.push("最近批次:" + sourceName(run.source) + " @ " + run.finishedAt + ";成功 " + (run.okCount || 0) + " / 失败 " + (run.failCount || 0) + " / 总计 " + (run.total || 0)) + for (var i = 0; i < uids.length; i++) lines.push(formatStatusLine(uids[i], last, remarks)) + if (archiveInfo) lines.push("最近归档:" + archiveInfo.key + "(发送启点查询可按归档判断昨日签到)") + } return lines.join("\n") } @@ -709,6 +768,7 @@ function main() { var store = getStore() var bind = getBindStore() if (cmd === "sign" || /^(签到|sign|run)(?:\s+|$)/i.test(arg)) { + if (isCronRun) archiveRunLog("midnight-before-sign") var target = trim(arg.replace(/^(签到|sign|run)\s*/i, "")) var out = signAll(target, isCronRun, isCronRun ? "cron" : "manual") if (!(isCronRun && getBool(CRON_SILENT_KEY, false) && out === "还没有保存启点读书CK")) reply(out) diff --git a/plugins/qdreader/README.md b/plugins/qdreader/README.md index 89ba334..2b46ba7 100644 --- a/plugins/qdreader/README.md +++ b/plugins/qdreader/README.md @@ -1,6 +1,6 @@ # 启点读书 QDReader 签到 Autman 插件 -QDReader/起点读书 Autman 签到插件。用户侧触发词统一使用“启点”,避免平台关键词麻烦;QX 只负责抓取 Cookie,账号管理、执行查询和签到都在 Autman 插件内完成。 +QDReader/起点读书 Autman 签到插件。用户侧触发词统一使用“启点”,避免平台关键词麻烦;QX 只负责抓取 Cookie,账号管理、执行查询、日志归档和签到都在 Autman 插件内完成。 ## 文件位置 @@ -93,8 +93,9 @@ hermes_qidian qdreader_cookie_json # uid -> full cookie qdreader_user_bind_json # uid -> sender.getUserID(),用户绑定索引 qdreader_remark_json # uid -> 备注名 -qdreader_last_result_json # uid -> 单账号最后执行结果 JSON 字符串 -qdreader_last_run_json # 最近一次执行批次日志、来源和汇总 +qdreader_last_result_json # uid -> 单账号当日/当前批次结果 JSON 字符串(每日零点归档前清空) +qdreader_last_run_json # 当日/当前批次日志、来源和汇总(每日零点归档前清空) +qdreader_archive_json # yyyy-mm-dd -> 已归档批次日志,用于判断历史签到结果 qdreader_sign_api # 签名网关 qdreader_retry_count # 请求失败重试次数 qdreader_cron_silent # 定时无账号静默 @@ -127,20 +128,22 @@ set(key, value) - 同时提取 `qdh/QDHeader/guid/qimei/qimei36` 供签名网关使用,其中 `qdh` 只取 Cookie 的 `qdh/QDH`,不再用 `QDHeader` 顶替,避免签名网关 `decodeQDHAuto` 解码失败。 2. `POST /sign` 获取请求签名头。默认签名网关基址是 `https://api.120399.xyz/qdreader`。 3. `POST https://h5.if.qidian.com/argus/api/v3/checkin/checkin` 完成签到。 -4. 写入单账号最后结果 `qdreader_last_result_json`,包括 `ok/msg/time/source/runId`。 -5. 写入批次日志 `qdreader_last_run_json`,包括触发来源、开始/结束时间、成功/失败/总数和每账号结果;即使账号执行异常也会记录失败日志。 -6. 汇总每个账号签到结果。 +4. 写入单账号当日结果 `qdreader_last_result_json`,包括 `ok/msg/time/source/runId`。 +5. 写入当日批次日志 `qdreader_last_run_json`,包括触发来源、开始/结束时间、成功/失败/总数和每账号结果;即使账号执行异常也会记录失败日志。 +6. 每日 00:00 的 cron 触发时,先把上一份 `qdreader_last_run_json` 按完成日期写入 `qdreader_archive_json`,再清空 `qdreader_last_result_json` / `qdreader_last_run_json`,然后执行新一天签到并反馈结果。 +7. 汇总每个账号签到结果。 ## 查询数据来源 `启点查询` 不请求起点实时接口,只读取最后执行日志: ```text -qdreader_last_run_json # 最近一次执行批次 -qdreader_last_result_json # 每个 uid 最近一次执行结果 +qdreader_last_run_json # 当日/当前批次 +qdreader_last_result_json # 每个 uid 当日/当前批次结果 +qdreader_archive_json # 每日零点归档后的历史批次 ``` -因此“查询”语义是“最近签到记录/最后执行日志”。刚添加但从未执行过的账号会显示“暂无执行记录”。 +因此“查询”语义是“最近签到记录/最后执行日志”。刚添加但从未执行过的账号会显示“暂无执行记录”。如果当日日志已被零点任务清空,`启点查询` 会回退到最近归档,根据归档中的账号结果判断昨日/历史签到情况并反馈给用户。 ## 本地验证 diff --git a/plugins/qdreader/qdreader_sign_autman.js b/plugins/qdreader/qdreader_sign_autman.js index 7f16874..ad26a1c 100644 --- a/plugins/qdreader/qdreader_sign_autman.js +++ b/plugins/qdreader/qdreader_sign_autman.js @@ -4,7 +4,7 @@ //[author: Hermes] //[service: BOSS] //[class: 工具类] -//[version: 2.4.2] +//[version: 2.5.0] //[platform: web,qq,wx,tg,tb,fs,we] //[public: false] //[price: 0] @@ -14,7 +14,7 @@ //[rule: ^qdreader(.*)$] //[admin: true] //[priority: 600] -//[cron: 15 7 * * *] +//[cron: 0 0 * * *] var BUCKET = "hermes_qidian" @@ -26,6 +26,7 @@ var RETRY_KEY = "qdreader_retry_count" var CRON_SILENT_KEY = "qdreader_cron_silent" var LAST_RESULT_KEY = "qdreader_last_result_json" var LAST_RUN_KEY = "qdreader_last_run_json" +var ARCHIVE_KEY = "qdreader_archive_json" var DEFAULT_SIGN_API = "https://api.120399.xyz/qdreader" var QIDIAN_BASE = "https://h5.if.qidian.com" var CHECKIN_PATH = "/argus/api/v3/checkin/checkin" @@ -259,7 +260,7 @@ function contentText() { } function usage() { return [ - "启点读书签到插件 v2.4.2", + "启点读书签到插件 v2.5.0", "【一级菜单】", "账号管理:启点账号", "执行查询:启点查询", @@ -269,7 +270,7 @@ function usage() { "指定签到:启点签到 ", "【配置】", "签名网关:Autman 参数 hermes_qidian.qdreader_sign_api,默认 " + DEFAULT_SIGN_API, - "定时:每日 07:15 自动执行;QX 只负责抓 cookie。" + "定时:每日 00:00 自动执行、归档上一日日志并清空当日日志;QX 只负责抓 cookie。" ].join("\n") } function stripCommand(text) { @@ -405,8 +406,50 @@ function getLastResults() { } function setLastRunLog(log) { cfgSet(LAST_RUN_KEY, JSON.stringify(log || {})) } function getLastRunLog() { return safeJsonParse(cfgGet(LAST_RUN_KEY, "{}"), {}) || {} } +function getArchiveStore() { return safeJsonParse(cfgGet(ARCHIVE_KEY, "{}"), {}) || {} } +function setArchiveStore(obj) { cfgSet(ARCHIVE_KEY, JSON.stringify(obj || {})) } +function localDateKey(iso) { + var d = iso ? new Date(iso) : new Date() + if (!d || isNaN(d.getTime())) d = new Date() + var y = d.getFullYear() + var m = String(d.getMonth() + 1); if (m.length < 2) m = "0" + m + var day = String(d.getDate()); if (day.length < 2) day = "0" + day + return y + "-" + m + "-" + day +} +function archiveRunLog(reason) { + var run = getLastRunLog() + var archive = getArchiveStore() + var archived = false + var key = "" + if (run && run.finishedAt && run.total !== undefined) { + key = localDateKey(run.finishedAt) + archive[key] = run + archive[key].archivedAt = nowIso() + archive[key].archiveReason = reason || "daily-rollover" + archived = true + } + setArchiveStore(archive) + cfgSet(LAST_RESULT_KEY, "{}") + cfgSet(LAST_RUN_KEY, "{}") + return { archived: archived, key: key, archive: archive, run: run || {} } +} +function latestArchiveForVisible(uids) { + var archive = getArchiveStore() + var keys = [] + for (var k in archive) if (archive.hasOwnProperty(k)) keys.push(k) + keys.sort() + for (var i = keys.length - 1; i >= 0; i--) { + var log = archive[keys[i]] || {} + var items = log.items || [] + var map = {} + for (var j = 0; j < items.length; j++) map[String(items[j].uid)] = items[j] + if (!uids || !uids.length) return { key: keys[i], log: log, itemMap: map } + for (var u = 0; u < uids.length; u++) if (map[String(uids[u])]) return { key: keys[i], log: log, itemMap: map } + } + return null +} function sourceName(source) { - if (source === "cron") return "定时任务" + if (source === "cron") return "定时任务/零点任务" if (source === "menu") return "菜单立即执行" if (source === "manual") return "手动签到" return source || "未知" @@ -467,6 +510,12 @@ function formatStatusLine(uid, last, remarks) { if (!r) return "- " + name + ":暂无执行记录" return "- " + name + ":" + (r.ok ? "✅ " : "❌ ") + (r.msg || "无结果") + (r.time ? " @ " + r.time : "") } +function formatArchiveStatusLine(uid, archiveInfo, remarks) { + var name = maskUid(uid) + (remarks && remarks[uid] ? "(" + remarks[uid] + ")" : "") + var r = archiveInfo && archiveInfo.itemMap ? archiveInfo.itemMap[String(uid)] : null + if (!r) return "- " + name + ":归档中暂无该账号记录" + return "- " + name + ":" + (r.ok ? "✅ " : "❌ ") + (r.msg || "无结果") + (r.time ? " @ " + r.time : "") +} function queryStatus(targetUid) { var store = getStore() var bind = getBindStore() @@ -480,9 +529,19 @@ function queryStatus(targetUid) { } if (!uids.length) return "当前用户还没有保存启点读书CK" var title = targetUid ? "启点单账号最近签到记录:" : "启点最近签到记录:" + var archiveInfo = latestArchiveForVisible(uids) + var useArchive = archiveInfo && (!run || !run.finishedAt) + if (useArchive) title = targetUid ? "启点单账号最近归档签到记录:" : "启点最近归档签到记录:" var lines = [title] - if (!targetUid && run && run.finishedAt) lines.push("最近批次:" + sourceName(run.source) + " @ " + run.finishedAt + ";成功 " + (run.okCount || 0) + " / 失败 " + (run.failCount || 0) + " / 总计 " + (run.total || 0)) - for (var i = 0; i < uids.length; i++) lines.push(formatStatusLine(uids[i], last, remarks)) + if (useArchive) { + var arun = archiveInfo.log || {} + lines.push("最近归档:" + archiveInfo.key + ";" + sourceName(arun.source) + " @ " + (arun.finishedAt || "未知时间") + ";成功 " + (arun.okCount || 0) + " / 失败 " + (arun.failCount || 0) + " / 总计 " + (arun.total || 0)) + for (var ai = 0; ai < uids.length; ai++) lines.push(formatArchiveStatusLine(uids[ai], archiveInfo, remarks)) + } else { + if (!targetUid && run && run.finishedAt) lines.push("最近批次:" + sourceName(run.source) + " @ " + run.finishedAt + ";成功 " + (run.okCount || 0) + " / 失败 " + (run.failCount || 0) + " / 总计 " + (run.total || 0)) + for (var i = 0; i < uids.length; i++) lines.push(formatStatusLine(uids[i], last, remarks)) + if (archiveInfo) lines.push("最近归档:" + archiveInfo.key + "(发送启点查询可按归档判断昨日签到)") + } return lines.join("\n") } @@ -709,6 +768,7 @@ function main() { var store = getStore() var bind = getBindStore() if (cmd === "sign" || /^(签到|sign|run)(?:\s+|$)/i.test(arg)) { + if (isCronRun) archiveRunLog("midnight-before-sign") var target = trim(arg.replace(/^(签到|sign|run)\s*/i, "")) var out = signAll(target, isCronRun, isCronRun ? "cron" : "manual") if (!(isCronRun && getBool(CRON_SILENT_KEY, false) && out === "还没有保存启点读书CK")) reply(out) diff --git a/tests/qdreader_plugin.test.mjs b/tests/qdreader_plugin.test.mjs index ebba8c3..ef62d26 100644 --- a/tests/qdreader_plugin.test.mjs +++ b/tests/qdreader_plugin.test.mjs @@ -225,6 +225,58 @@ test('quick submit rejects save when Autman JS has no user identity', () => { assert.equal(r.store.qdreader_user_bind_json, undefined); }); +test('cron archives previous run log and clears daily logs before new midnight sign', () => { + const previousRun = { + runId: '20260518_cron', + source: 'cron', + startedAt: '2026-05-18T00:00:00.000Z', + finishedAt: '2026-05-18T00:00:02.000Z', + total: 1, + okCount: 1, + failCount: 0, + items: [{ uid: '12345', ok: true, msg: '昨日签到成功', time: '2026-05-18T00:00:01.000Z' }], + }; + const store = { + qdreader_cookie_json: JSON.stringify({ '12345': cookie }), + qdreader_user_bind_json: JSON.stringify({ '12345': 'user-a' }), + qdreader_last_run_json: JSON.stringify(previousRun), + qdreader_last_result_json: JSON.stringify({ '12345': JSON.stringify({ ok: true, msg: '昨日签到成功' }) }), + }; + const r = runPlugin({ content: '', store, userId: 'cron' }); + const archive = JSON.parse(r.store.qdreader_archive_json); + assert.equal(archive['2026-05-18'].runId, '20260518_cron'); + assert.equal(archive['2026-05-18'].items[0].msg, '昨日签到成功'); + assert.equal(JSON.parse(r.store.qdreader_last_run_json).source, 'cron'); + assert.equal(JSON.parse(JSON.parse(r.store.qdreader_last_result_json)['12345']).msg, '签到成功'); +}); + +test('query falls back to archived sign results after daily log was cleared', () => { + const archiveRun = { + source: 'cron', + finishedAt: '2026-05-18T00:00:02.000Z', + total: 2, + okCount: 1, + failCount: 1, + items: [ + { uid: '12345', ok: true, msg: '签到成功', time: '2026-05-18T00:00:01.000Z' }, + { uid: '67890', ok: false, msg: 'Cookie失效', time: '2026-05-18T00:00:01.000Z' }, + ], + }; + 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_run_json: '{}', + qdreader_last_result_json: '{}', + qdreader_archive_json: JSON.stringify({ '2026-05-18': archiveRun }), + }; + const r = runPlugin({ content: '启点查询', store, userId: 'user-a', listens: ['1'] }); + assert.match(r.replies[1], /启点最近归档签到记录/); + assert.match(r.replies[1], /最近归档:2026-05-18/); + assert.match(r.replies[1], /12\*\*\*45(主号):✅ 签到成功/); + assert.match(r.replies[1], /67\*\*\*90(副号):❌ Cookie失效/); +}); + test('manual sign and cron sign both call sign gateway then qidian checkin', () => { const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }),