creditdaddy 0.9.3 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 妙手(美团 CatPaw)桌面客户端本机凭据读取与切换。
3
+ *
4
+ * 凭据存储(Windows):
5
+ * %APPDATA%\catpaw-moon\catx-credential.json { ssoTokenEnc }
6
+ * ssoTokenEnc = base64( iv(12B) ‖ gcmTag(16B) ‖ ciphertext ),AES-256-GCM
7
+ * key = sha256( machineId + ":catpaw-desk-token-v2" )
8
+ * machineId = HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid 的值(node-machine-id 同源)
9
+ * 明文结构 { access_token, modified_at }
10
+ *
11
+ * 额度 / 版本 / 用户信息走桌面网关(见 catpawClient.js):
12
+ * token 以 X-Auth-Token 头发送到 https://catx.nocode.cn/api/gateway/*
13
+ *
14
+ * 账号切换:妙手启动时读取该文件恢复登录,因此写入目标账号的 access_token
15
+ * 再重启客户端即为换号。切换前先把当前登录同步进 CreditDaddy 账号库(防丢号)。
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+ import { execFileSync } from 'node:child_process';
23
+
24
+ const EXE_NAME = '妙手.exe';
25
+
26
+ function credentialFile() {
27
+ if (process.env.CATPAW_CREDENTIAL_FILE) return process.env.CATPAW_CREDENTIAL_FILE;
28
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
29
+ return path.join(appData, 'catpaw-moon', 'catx-credential.json');
30
+ }
31
+
32
+ function readJson(file) {
33
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
34
+ }
35
+
36
+ // ── 密钥派生(MachineGuid → sha256) ──
37
+
38
+ let keyCache = null; // Buffer | null(null = 本机不可用),false = 已尝试失败
39
+ let machineIdOverride = null; // 测试钩子:注入固定机器 ID(null = 未注入)
40
+ let runningOverride = null; // 测试钩子:true/false 强制运行状态(null = 真实探测)
41
+
42
+ function machineId() {
43
+ if (machineIdOverride) return machineIdOverride;
44
+ if (process.platform !== 'win32') return null;
45
+ try {
46
+ const out = execFileSync('REG.exe',
47
+ ['QUERY', 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'],
48
+ { windowsHide: true, timeout: 10_000 }).toString();
49
+ // MachineGuid REG_SZ {xxxxxxxx-....}(与 node-machine-id 一致:去空白 + 小写)
50
+ return out.split('REG_SZ')[1].replace(/\r+|\n+|\s+/g, '').toLowerCase();
51
+ } catch { return null; }
52
+ }
53
+
54
+ function tokenKey() {
55
+ if (machineIdOverride) {
56
+ return crypto.createHash('sha256').update(machineIdOverride + ':catpaw-desk-token-v2').digest();
57
+ }
58
+ if (keyCache) return keyCache;
59
+ if (keyCache === false) return null;
60
+ const id = machineId();
61
+ if (!id) { keyCache = false; return null; }
62
+ keyCache = crypto.createHash('sha256').update(id + ':catpaw-desk-token-v2').digest();
63
+ return keyCache;
64
+ }
65
+
66
+ /** 解密本机妙手登录凭据 → { accessToken, modifiedAt };不可用 / 未登录返回 null */
67
+ export function readCredential() {
68
+ const file = credentialFile();
69
+ const j = readJson(file);
70
+ if (!j || typeof j.ssoTokenEnc !== 'string' || !j.ssoTokenEnc) return null;
71
+ const key = tokenKey();
72
+ if (!key) return null;
73
+ try {
74
+ const blob = Buffer.from(j.ssoTokenEnc, 'base64');
75
+ if (blob.length < 29) return null;
76
+ const d = crypto.createDecipheriv('aes-256-gcm', key, blob.subarray(0, 12));
77
+ d.setAuthTag(blob.subarray(12, 28));
78
+ const plain = JSON.parse(Buffer.concat([d.update(blob.subarray(28)), d.final()]).toString('utf8'));
79
+ if (!plain || typeof plain.access_token !== 'string' || !plain.access_token) return null;
80
+ return { accessToken: plain.access_token, modifiedAt: plain.modified_at ?? null };
81
+ } catch { return null; }
82
+ }
83
+
84
+ /** 加密写回凭据文件(保留文件其余字段;妙手重启后即为新登录) */
85
+ function writeCredential(accessToken) {
86
+ const file = credentialFile();
87
+ const key = tokenKey();
88
+ if (!key) throw new Error('无法派生本机密钥(仅支持 Windows 本机)');
89
+ const existing = readJson(file) || {};
90
+ const plain = JSON.stringify({ access_token: accessToken, modified_at: Date.now() });
91
+ const iv = crypto.randomBytes(12);
92
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
93
+ const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
94
+ const enc = Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
95
+ fs.mkdirSync(path.dirname(file), { recursive: true });
96
+ const tmp = `${file}.tmp-${crypto.randomUUID()}`;
97
+ fs.writeFileSync(tmp, JSON.stringify({ ...existing, ssoTokenEnc: enc }, null, 2) + '\n', { mode: 0o600 });
98
+ fs.renameSync(tmp, file);
99
+ }
100
+
101
+ // ── 本机探测 ──
102
+
103
+ function decodeTasklist(buf) {
104
+ // Windows 控制台代码页(简体中文 GBK)输出,中文名需按 gbk 解码
105
+ try { return new TextDecoder('gbk').decode(buf); } catch { return buf.toString('utf8'); }
106
+ }
107
+
108
+ export function catpawRunning() {
109
+ if (runningOverride !== null) return runningOverride; // 测试钩子
110
+ if (process.platform !== 'win32') return false;
111
+ try {
112
+ const out = decodeTasklist(execFileSync('tasklist.exe',
113
+ ['/FI', `IMAGENAME eq ${EXE_NAME}`, '/NH'],
114
+ { windowsHide: true, encoding: 'buffer', timeout: 8000 }));
115
+ // 无匹配时 tasklist 输出 INFO 行(中文系统为 GBK 代码页,见 decodeTasklist)
116
+ return out.toLowerCase().includes(EXE_NAME.toLowerCase()) && !/no tasks|没有找到|没有任务/i.test(out);
117
+ } catch { return false; }
118
+ }
119
+
120
+ export function candidateClientPath() {
121
+ if (process.platform !== 'win32') return null;
122
+ const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
123
+ const p = path.join(local, '妙手', EXE_NAME);
124
+ return fs.existsSync(p) ? p : null;
125
+ }
126
+
127
+ export function detectCatpaw() {
128
+ const file = credentialFile();
129
+ const cred = readCredential();
130
+ const clientPath = candidateClientPath();
131
+ return {
132
+ dataDir: path.dirname(file),
133
+ exists: fs.existsSync(file),
134
+ signedIn: Boolean(cred),
135
+ clientInstalled: Boolean(clientPath),
136
+ clientPath,
137
+ running: catpawRunning(),
138
+ };
139
+ }
140
+
141
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
142
+
143
+ export function terminateCatpaw({ timeoutMs = 8000 } = {}) {
144
+ if (process.platform !== 'win32') return { closed: false, supported: false };
145
+ if (!catpawRunning()) return { closed: false, running: false };
146
+ try { execFileSync('taskkill.exe', ['/IM', EXE_NAME, '/T'], { windowsHide: true, stdio: 'ignore', timeout: 15_000 }); } catch {}
147
+ let deadline = Date.now() + timeoutMs;
148
+ while (Date.now() < deadline && catpawRunning()) nap(250);
149
+ if (catpawRunning()) {
150
+ try { execFileSync('taskkill.exe', ['/IM', EXE_NAME, '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 15_000 }); } catch {}
151
+ deadline = Date.now() + 3000;
152
+ while (Date.now() < deadline && catpawRunning()) nap(250);
153
+ }
154
+ return { closed: !catpawRunning(), running: catpawRunning() };
155
+ }
156
+
157
+ /** 当前客户端登录的 token(面板「客户端当前」标识用,避免额外网络请求) */
158
+ export function currentCatpawToken() {
159
+ try { return readCredential()?.accessToken || null; } catch { return null; }
160
+ }
161
+
162
+ // ── 当前会话读取与切换 ──
163
+
164
+ /**
165
+ * 读取妙手客户端当前登录,返回标准化账号对象(供 addAccount / scan)。
166
+ * uid / name 通过网关 auth/current-user 补全(离线时降级为只带 token)。
167
+ */
168
+ export async function liveToAccount() {
169
+ const cred = readCredential();
170
+ if (!cred) return null;
171
+ const account = {
172
+ provider: 'catpaw',
173
+ token: cred.accessToken,
174
+ refreshToken: null,
175
+ uid: null,
176
+ name: null,
177
+ expiresAt: null,
178
+ source: 'local-app',
179
+ meta: { capturedAt: new Date().toISOString() },
180
+ };
181
+ try {
182
+ const { fetchCatpawProfile } = await import('./catpawClient.js');
183
+ const p = await fetchCatpawProfile(cred.accessToken);
184
+ if (p) {
185
+ account.uid = p.userId ? String(p.userId) : null;
186
+ account.name = p.name || null;
187
+ if (p.mobile) account.meta.phone = p.mobile;
188
+ if (p.avatarUrl) account.meta.avatarUrl = p.avatarUrl;
189
+ }
190
+ } catch {}
191
+ return account;
192
+ }
193
+
194
+ /**
195
+ * 切换妙手客户端当前登录账号(写入加密凭据;客户端重启后生效)。
196
+ * @param {object} account 目标账号
197
+ * @param {{ force?: boolean }} opts
198
+ */
199
+ export async function switchTo(account, { force = false } = {}) {
200
+ const cred = readCredential();
201
+ if (!cred) throw new Error('本机妙手客户端没有可用的登录凭据(无法派生本机密钥或未登录过)');
202
+ if (account.token === cred.accessToken) return { switched: false, alreadyActive: true };
203
+
204
+ if (!force && catpawRunning()) {
205
+ const err = new Error('妙手客户端正在运行,请先退出后再切换(或强制切换,切换后自动重新拉起客户端)');
206
+ err.catpawRunning = true;
207
+ throw err;
208
+ }
209
+ writeCredential(account.token);
210
+ return { switched: true, alreadyActive: false };
211
+ }
212
+
213
+ // ── 测试钩子(仅单元测试使用;不影响默认路径) ──
214
+
215
+ /** 注入固定机器 ID 派生密钥(null 恢复真实探测);同时复位密钥缓存 */
216
+ export function _setMachineIdForTests(id) {
217
+ machineIdOverride = id || null;
218
+ keyCache = null;
219
+ }
220
+
221
+ /** 设置/复位「妙手是否正在运行」的探测结果(null = 真实探测) */
222
+ export function _setRunningForTests(running) {
223
+ runningOverride = running === null || running === undefined ? null : Boolean(running);
224
+ }
225
+
226
+ /** 写出加密凭据(等价于切换流程里的落盘一步) */
227
+ export function _writeCredentialForTests(accessToken) {
228
+ writeCredential(accessToken);
229
+ }
package/src/checkin.js CHANGED
@@ -1,12 +1,5 @@
1
1
  /**
2
- * 签到调度 — 逻辑对齐 10router qoderCheckin.js:
3
- * - 一次 tick 扫描全部账号
4
- * - 已确认完成今日签到的账号当天跳过(state.json 记忆)
5
- * - "无可领活动" 不记忆(每日 10:00 (UTC+8) 刷新积分,之后还会出现可领活动)
6
- * - "签到日" 以积分刷新时刻 10:00 (UTC+8) 为界,与本机时区无关
7
- * - 国际版每台设备每天只能领一次(服务端按设备风控身份限领):本机已有账号领取后,
8
- * 其余国际版账号标记为 limited 并当日记忆,不再反复请求
9
- * - 定时器每 ~2h + 抖动执行一次;多轮签到(定时 / 手动)串行执行
2
+ * 签到调度:普通账号每 ~2h 扫描一次;ZCode 自动活动领取使用独立的短周期调度。
10
3
  */
11
4
 
12
5
  import { loadAccounts, loadState, saveState, withAccounts } from './store.js';
@@ -15,30 +8,51 @@ import { productOf } from './constants.js';
15
8
  import { startUsageSyncScheduler } from './tenrouter.js';
16
9
  import { refreshContext } from './accounts.js';
17
10
  import { zcodeAutoClaim } from './zcodeAutoClaim.js';
18
- import { autoClaimEnabled } from './zcodeClient.js';
11
+ import { clearQuotaMark } from './zcodeGateway.js';
12
+ import {
13
+ autoClaimEnabled, autoClaimUntil, enableAutoClaimFor, setAutoClaimEnabled,
14
+ warmZcodeAppVersion,
15
+ } from './zcodeClient.js';
19
16
  import { logger } from './logger.js';
20
17
 
21
- const TICK_MS = 2 * 60 * 60 * 1000; // 2 小时
22
- const TICK_JITTER_MS = 10 * 60 * 1000; // ±10 分钟抖动,避免整点请求特征
18
+ const TICK_MS = 2 * 60 * 60 * 1000;
19
+ const TICK_JITTER_MS = 10 * 60 * 1000;
20
+ const ZCODE_TICK_MS = 2 * 60 * 1000;
21
+ const ZCODE_JITTER_MS = 15 * 1000;
22
+ const ZCODE_FIRST_DELAY_MS = 2 * 60 * 1000;
23
+ const ZCODE_AUTO_CLAIM_WINDOW_MS = 60 * 60 * 1000;
23
24
 
24
25
  // 每日积分刷新:10:00 (UTC+8) = 02:00 UTC。签到日 = (now - 2h) 的 UTC 日期
25
26
  const REFRESH_UTC_OFFSET_MS = 2 * 60 * 60 * 1000;
26
27
 
27
28
  let timerHandle = null;
29
+ let zcodeTimerHandle = null;
30
+ let zcodeExpiryTimerHandle = null;
31
+ let zcodeExpiryAt = null;
32
+ let zcodeNextTickAt = null;
33
+ let zcodeLastTick = null;
34
+ let zcodeTicking = false;
35
+ let zcodeQueue = Promise.resolve();
28
36
  let tickQueue = Promise.resolve();
29
37
  let nextTickAt = null;
30
- let lastTick = null; // { at, summary }
38
+ let lastTick = null;
31
39
  let ticking = false;
32
40
 
33
41
  /** 调度器状态(面板展示用) */
34
42
  export function getSchedulerInfo() {
35
- return { running: Boolean(timerHandle), nextTickAt, lastTick, ticking };
43
+ const enabled = autoClaimEnabled();
44
+ return {
45
+ running: Boolean(timerHandle), nextTickAt, lastTick, ticking,
46
+ zcode: {
47
+ enabled,
48
+ autoOffAt: enabled ? autoClaimUntil() : null,
49
+ nextTickAt: enabled ? zcodeNextTickAt : null,
50
+ lastTick: zcodeLastTick,
51
+ ticking: zcodeTicking,
52
+ },
53
+ };
36
54
  }
37
55
 
38
- /**
39
- * 当前所属的"签到日"(YYYY-MM-DD)。以 10:00 (UTC+8) 为日界:
40
- * 刷新前领过的记录不会让刷新后的新一轮被跳过;也不受 NAS / 海外机器时区影响。
41
- */
42
56
  export function dayKey(nowMs = Date.now()) {
43
57
  return new Date(nowMs - REFRESH_UTC_OFFSET_MS).toISOString().slice(0, 10);
44
58
  }
@@ -47,19 +61,131 @@ export function msUntilNextTick(nowMs = Date.now(), rand = Math.random) {
47
61
  return Math.max(TICK_MS + Math.floor(rand() * TICK_JITTER_MS), 1000);
48
62
  }
49
63
 
64
+ export function zcodeMsUntilNextTick(rand = Math.random) {
65
+ return Math.max(ZCODE_TICK_MS + Math.floor(rand() * ZCODE_JITTER_MS), 1000);
66
+ }
67
+
68
+ /** 开启 ZCode 自动领取一小时;首次资格检查两分钟后开始。 */
69
+ export function enableZcodeAutoClaimForOneHour() {
70
+ enableAutoClaimFor(ZCODE_AUTO_CLAIM_WINDOW_MS);
71
+ syncZcodeScheduler(true);
72
+ return autoClaimUntil();
73
+ }
74
+
75
+ /** 更新 ZCode 定时器,autoClaimUntil 是持久化的到期时间,重启后仍会生效。 */
76
+ export function refreshZcodeScheduler() {
77
+ syncZcodeScheduler();
78
+ }
79
+
80
+ function clearZcodeTimers() {
81
+ if (zcodeTimerHandle) clearTimeout(zcodeTimerHandle);
82
+ if (zcodeExpiryTimerHandle) clearTimeout(zcodeExpiryTimerHandle);
83
+ zcodeTimerHandle = null;
84
+ zcodeExpiryTimerHandle = null;
85
+ zcodeExpiryAt = null;
86
+ zcodeNextTickAt = null;
87
+ }
88
+
89
+ function scheduleZcodeNextTick(delay = zcodeMsUntilNextTick()) {
90
+ if (!autoClaimEnabled()) {
91
+ syncZcodeScheduler();
92
+ return;
93
+ }
94
+ if (zcodeTimerHandle) clearTimeout(zcodeTimerHandle);
95
+ zcodeNextTickAt = new Date(Date.now() + delay).toISOString();
96
+ zcodeTimerHandle = setTimeout(() => {
97
+ zcodeTimerHandle = null;
98
+ zcodeNextTickAt = null;
99
+ runZcodeTick().catch((err) => logger.error('ZCODE-CLAIM', `资格轮询失败:${err?.message || err}`))
100
+ .finally(() => scheduleZcodeNextTick());
101
+ }, delay);
102
+ zcodeTimerHandle.unref?.();
103
+ }
104
+
105
+ function syncZcodeScheduler(startFresh = false) {
106
+ if (!autoClaimEnabled()) {
107
+ clearZcodeTimers();
108
+ return;
109
+ }
110
+
111
+ const expiresAt = autoClaimUntil();
112
+ if (startFresh && zcodeTimerHandle) {
113
+ clearTimeout(zcodeTimerHandle);
114
+ zcodeTimerHandle = null;
115
+ zcodeNextTickAt = null;
116
+ }
117
+ if (!zcodeTimerHandle && !zcodeTicking) scheduleZcodeNextTick(ZCODE_FIRST_DELAY_MS);
118
+
119
+ if (expiresAt && zcodeExpiryAt !== expiresAt) {
120
+ if (zcodeExpiryTimerHandle) clearTimeout(zcodeExpiryTimerHandle);
121
+ zcodeExpiryAt = expiresAt;
122
+ zcodeExpiryTimerHandle = setTimeout(() => {
123
+ zcodeExpiryTimerHandle = null;
124
+ setAutoClaimEnabled(false);
125
+ clearZcodeTimers();
126
+ logger.info('ZCODE-CLAIM', '一小时自动领取时段已结束,自动领取已关闭');
127
+ }, Math.max(0, expiresAt - Date.now()));
128
+ zcodeExpiryTimerHandle.unref?.();
129
+ }
130
+ }
131
+
132
+ function runZcodeTick(accountIds, { force = false } = {}) {
133
+ const run = zcodeQueue.then(async () => {
134
+ if (!force && !autoClaimEnabled()) return { results: [], summary: 'ZCode 自动领取已关闭' };
135
+ zcodeTicking = true;
136
+ const results = [];
137
+ const claimedIds = [];
138
+ try {
139
+ const accounts = (await loadAccounts()).filter((a) => a.provider === 'zcode'
140
+ && (!accountIds || accountIds.has(a.id)));
141
+ for (const account of accounts) {
142
+ if (!force && !autoClaimEnabled()) break;
143
+ const label = account.name || account.uid || account.id;
144
+ try {
145
+ const outcome = await zcodeAutoClaim(account);
146
+ if (!outcome) continue;
147
+ if (outcome.status === 'checked-in') {
148
+ claimedIds.push(account.id);
149
+ await clearQuotaMark(account.id);
150
+ }
151
+ results.push({ accountId: account.id, account: label, provider: 'zcode', status: outcome.status, message: outcome.message });
152
+ const lastResult = { status: outcome.status, message: outcome.message, amount: 0, at: new Date().toISOString() };
153
+ await withAccounts((list) => {
154
+ const current = list.find((a) => a.id === account.id);
155
+ if (current) current.lastResult = lastResult;
156
+ });
157
+ } catch (err) {
158
+ logger.warn('ZCODE-CLAIM', `${label} 自动领取异常:${err?.message || err}`);
159
+ }
160
+ }
161
+ zcodeLastTick = {
162
+ at: new Date().toISOString(),
163
+ summary: `ZCode 资格轮询完成:${results.length} 个账号返回活动结果`,
164
+ claimedIds, // 面板据此即时刷新这些账号的额度
165
+ };
166
+ return { results, summary: zcodeLastTick.summary };
167
+ } finally {
168
+ zcodeTicking = false;
169
+ }
170
+ });
171
+ zcodeQueue = run.catch(() => {});
172
+ return run;
173
+ }
174
+
175
+ /** 手动触发一次 ZCode 资格轮询(面板单账号 / 全部领取走这里),与定时轮共用同一队列;手动轮不受开关限制。 */
176
+ export function pollZcodeNow(accountIds) {
177
+ return runZcodeTick(accountIds && accountIds.length ? new Set(accountIds) : null, { force: true });
178
+ }
179
+
50
180
  async function getDoneMap(state) {
51
181
  const today = dayKey();
52
182
  const raw = state?.qoderDailyDone;
53
183
  const map = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : {};
54
- // 只保留今天的记录,其余清掉
55
- for (const k of Object.keys(map)) if (map[k] !== today) delete map[k];
184
+ for (const key of Object.keys(map)) if (map[key] !== today) delete map[key];
56
185
  return map;
57
186
  }
58
187
 
59
- /**
60
- * 执行一轮签到。多次调用会排队串行执行,避免同一账号被并发领取、state.json 互相覆盖。
61
- * @param {{provider?: string, product?: string, skipIfCheckedToday?: boolean, onlyAccountId?: string}} opts
62
- */
188
+ /** 多次调用排队串行执行,避免并发签到和 state.json 覆盖。 */
63
189
  export function runCheckinTick(opts = {}) {
64
190
  const run = tickQueue.then(() => runTickNow(opts));
65
191
  tickQueue = run.catch(() => {});
@@ -73,16 +199,13 @@ async function runTickNow(opts) {
73
199
 
74
200
  async function runTickInner(opts) {
75
201
  const allAccounts = await loadAccounts();
76
- // 没有每日签到能力的产品(如 ZCode:积分靠需验证码的活动领取)不参与签到轮
77
202
  const accounts = allAccounts.filter(
78
203
  (a) => (!opts.provider || a.provider === opts.provider)
79
204
  && (!opts.product || productOf(a.provider) === opts.product)
80
205
  && (!opts.onlyAccountId || a.id === opts.onlyAccountId)
81
206
  && typeof productImpl(a.provider)?.checkin === 'function'
82
207
  );
83
-
84
- // ZCode 活动轮询领取:开关打开(标签页可切换,默认关)且未被筛选参数排除时参与
85
- const zAccounts = (autoClaimEnabled()
208
+ const zAccounts = (opts.includeZcode !== false && autoClaimEnabled()
86
209
  && !opts.onlyAccountId
87
210
  && (!opts.provider || opts.provider === 'zcode')
88
211
  && (!opts.product || opts.product === 'zcode'))
@@ -97,7 +220,6 @@ async function runTickInner(opts) {
97
220
  const memo = await getDoneMap(state);
98
221
  const today = dayKey();
99
222
  const results = [];
100
- // 本设备今日领取国际版每日积分的账号 { day, accountId, name }
101
223
  let deviceClaim = state?.deviceClaim?.day === today ? state.deviceClaim : null;
102
224
 
103
225
  for (const account of accounts) {
@@ -108,7 +230,7 @@ async function runTickInner(opts) {
108
230
  continue;
109
231
  }
110
232
 
111
- const ctx = refreshContext(account, (m) => logger.info('CHECKIN', `${label}:${m}`));
233
+ const ctx = refreshContext(account, (message) => logger.info('CHECKIN', `${label}:${message}`));
112
234
  let outcome = await productImpl(account.provider).checkin(account, ctx);
113
235
  if (account.provider === 'qoder' && outcome.risk) {
114
236
  if (outcome.status === 'checked-in') {
@@ -139,7 +261,6 @@ async function runTickInner(opts) {
139
261
  memo[account.id] = today;
140
262
  logger.info('CHECKIN', `${label}:${outcome.message}`);
141
263
  } else if (outcome.status === 'no-activity') {
142
- // 不记忆——积分窗口(10:00 UTC+8)打开后还会出现可领活动
143
264
  logger.debug('CHECKIN', `${label}:${outcome.message || '当前无可领取的活动'}`);
144
265
  } else {
145
266
  logger.warn('CHECKIN', `${label} 领取失败:${outcome.error || outcome.status}`);
@@ -151,34 +272,22 @@ async function runTickInner(opts) {
151
272
  }
152
273
  }
153
274
 
154
- // 保存签到日历与账号 lastCheckin / lastResult / uid(在账号锁内重新读取,不覆盖期间的增删)
155
- await withAccounts((all) => {
156
- for (const updated of accounts) {
157
- const cur = all.find((a) => a.id === updated.id);
158
- if (!cur) continue;
159
- cur.lastCheckin = updated.lastCheckin;
160
- if (updated.lastResult) cur.lastResult = updated.lastResult;
161
- if (updated.uid && !cur.uid) cur.uid = updated.uid;
162
- }
163
- });
164
- await saveState({ ...state, qoderDailyDone: memo, deviceClaim });
275
+ if (accounts.length) {
276
+ await withAccounts((all) => {
277
+ for (const updated of accounts) {
278
+ const current = all.find((a) => a.id === updated.id);
279
+ if (!current) continue;
280
+ current.lastCheckin = updated.lastCheckin;
281
+ if (updated.lastResult) current.lastResult = updated.lastResult;
282
+ if (updated.uid && !current.uid) current.uid = updated.uid;
283
+ }
284
+ });
285
+ await saveState({ ...state, qoderDailyDone: memo, deviceClaim });
286
+ }
165
287
 
166
- // ZCode 活动自动领取:每轮签到后顺带轮询(preview 为空时零请求副作用;需开关打开)。
167
- // 桌面版已注册隐藏窗口验证码提供者,可静默过验证码;其它环境需要验证码时标记「需手动领取」。
168
- for (const za of zAccounts) {
169
- const label = za.name || za.uid || za.id;
170
- try {
171
- const r = await zcodeAutoClaim(za);
172
- if (!r) continue;
173
- results.push({ accountId: za.id, account: label, provider: 'zcode', status: r.status, message: r.message });
174
- const lastResult = { status: r.status, message: r.message, amount: 0, at: new Date().toISOString() };
175
- await withAccounts((list) => {
176
- const cur = list.find((a) => a.id === za.id);
177
- if (cur) cur.lastResult = lastResult;
178
- });
179
- } catch (e) {
180
- logger.warn('ZCODE-CLAIM', `${label} 自动领取异常:${e.message}`);
181
- }
288
+ if (zAccounts.length) {
289
+ const zResult = await runZcodeTick(new Set(zAccounts.map((a) => a.id)));
290
+ results.push(...zResult.results);
182
291
  }
183
292
 
184
293
  const claimed = results.filter((r) => r.status === 'checked-in');
@@ -186,44 +295,46 @@ async function runTickInner(opts) {
186
295
  const none = results.filter((r) => r.status === 'no-activity');
187
296
  const limited = results.filter((r) => r.status === 'limited');
188
297
  const already = results.length - claimed.length - failed.length - none.length - limited.length;
189
- const totalCredits = claimed.reduce((s, r) => s + (r.claimedAmount || 0), 0);
298
+ const totalCredits = claimed.reduce((sum, r) => sum + (r.claimedAmount || 0), 0);
190
299
  const summary = `签到汇总:成功 ${claimed.length}(+${totalCredits} Credits)、已领 ${already}`
191
300
  + (limited.length ? `、本机限领 ${limited.length}` : '')
192
301
  + `、无活动 ${none.length}、失败 ${failed.length}`;
193
302
  logger.info('CHECKIN', summary);
194
303
  lastTick = { at: new Date().toISOString(), summary };
195
-
196
304
  return { results, summary };
197
305
  }
198
306
 
199
- /** 启动定时签到(每 ~2h 一轮,当天已成功的账号自动跳过) */
307
+ /** 启动普通签到调度与 ZCode 独立轮询调度。 */
200
308
  export function startScheduler() {
201
309
  if (timerHandle) return;
202
- startUsageSyncScheduler(); // 10Router 用量同步(未配置 / 未开启时不做任何事)
310
+ startUsageSyncScheduler();
311
+ warmZcodeAppVersion();
203
312
  const scheduleNext = () => {
204
313
  const delay = msUntilNextTick();
205
314
  nextTickAt = new Date(Date.now() + delay).toISOString();
206
315
  timerHandle = setTimeout(async () => {
207
316
  try {
208
- await runCheckinTick({ skipIfCheckedToday: true });
317
+ await runCheckinTick({ skipIfCheckedToday: true, includeZcode: false });
209
318
  } catch (err) {
210
319
  logger.error('CHECKIN', `定时轮失败:${err?.message || err}`);
211
320
  } finally {
212
321
  scheduleNext();
213
322
  }
214
323
  }, delay);
215
- if (timerHandle.unref) timerHandle.unref();
324
+ timerHandle.unref?.();
216
325
  logger.info('CHECKIN', `定时签到已启动,下次执行约 ${Math.round(delay / 60000)} 分钟后`);
217
326
  };
218
327
  scheduleNext();
219
- // 启动后 15 秒先跑一轮引导签到
328
+ syncZcodeScheduler();
220
329
  setTimeout(() => {
221
- runCheckinTick({ skipIfCheckedToday: true }).catch((e) =>
222
- logger.error('CHECKIN', `引导轮失败:${e?.message || e}`));
330
+ runCheckinTick({ skipIfCheckedToday: true, includeZcode: false }).catch((err) =>
331
+ logger.error('CHECKIN', `引导轮失败:${err?.message || err}`));
223
332
  }, 15000).unref?.();
224
333
  }
225
334
 
226
335
  export function stopScheduler() {
227
- if (timerHandle) { clearTimeout(timerHandle); timerHandle = null; }
336
+ if (timerHandle) clearTimeout(timerHandle);
337
+ timerHandle = null;
228
338
  nextTickAt = null;
339
+ clearZcodeTimers();
229
340
  }
package/src/constants.js CHANGED
@@ -13,6 +13,10 @@ import { readFileSync } from 'node:fs';
13
13
  export const OPENAPI_BASE = 'https://openapi.qoder.sh';
14
14
  export const CN_OPENAPI_BASE = 'https://openapi.qoder.com.cn';
15
15
 
16
+ // 网页端(账号设置 → 用量明细)同源 API:逐资源包明细只在这里提供,且只认浏览器登录后的会话 Cookie。
17
+ export const WEB_BASE = 'https://qoder.com';
18
+ export const CN_WEB_BASE = 'https://qoder.cn';
19
+
16
20
  export const LOGIN_URL = 'https://qoder.com/device/selectAccounts';
17
21
  export const CN_LOGIN_URL = 'https://qoder.cn/device/selectAccounts';
18
22
 
@@ -23,29 +27,41 @@ export const CAMPAIGN_CLAIM_PATH = (id) => `/sash/api/v1/me/campaigns/${encodeUR
23
27
  // 账号信息与配额
24
28
  export const USERINFO_PATH = '/api/v1/userinfo';
25
29
  export const QUOTA_USAGE_PATH = '/api/v2/quota/usage';
30
+ // 网页端逐资源包用量明细(plan / 个人资源包 / 组织资源包,含每包到期时间)。
31
+ // 仅接受浏览器登录的会话 Cookie(dt- / jt- token 一律 401),见 qoderClient.fetchUsageDetail。
32
+ export const WEB_USAGE_PATH = '/api/v2/me/usages/big_model_credits';
26
33
 
27
34
  // PAT (pt-...) → 短期 job token (jt-...) 兑换(普通 JSON POST,无需 COSY 签名)
28
35
  export const JOB_TOKEN_EXCHANGE_PATH = '/api/v1/jobToken/exchange';
29
36
 
30
37
  // 账号的 provider = 产品 + 区域。新增产品时在此登记,并在 providers.js 挂上对应实现。
31
- export const PROVIDERS = ['qoder', 'qoder-cn', 'workbuddy', 'workbuddy-intl', 'zcode'];
38
+ export const PROVIDERS = ['qoder', 'qoder-cn', 'workbuddy', 'workbuddy-intl', 'zcode', 'mirasim', 'catpaw'];
32
39
  export const PROVIDER_LABEL = {
33
40
  qoder: 'Qoder 国际版',
34
41
  'qoder-cn': 'Qoder 国内版',
35
42
  workbuddy: 'WorkBuddy 国内版',
36
43
  'workbuddy-intl': 'WorkBuddy 国际版',
37
44
  zcode: 'ZCode',
45
+ mirasim: 'mirasim',
46
+ catpaw: '妙手',
38
47
  };
39
- /** provider → 产品线(qoder / workbuddy / zcode) */
48
+ /** provider → 产品线(qoder / workbuddy / zcode / mirasim / catpaw) */
40
49
  export const productOf = (provider) => {
41
50
  const p = String(provider);
42
51
  if (p.startsWith('workbuddy')) return 'workbuddy';
43
52
  if (p.startsWith('zcode')) return 'zcode';
53
+ if (p === 'mirasim') return 'mirasim';
54
+ if (p === 'catpaw') return 'catpaw';
44
55
  return 'qoder';
45
56
  };
46
57
  /** provider 是否国内版 */
47
58
  export const isCnProvider = (provider) => provider === 'qoder-cn' || provider === 'workbuddy';
48
59
 
60
+ /** Qoder 网页端同源 API 的 host(用量明细逐资源包接口在这里,只认浏览器会话 Cookie) */
61
+ export function webBase(provider) {
62
+ return provider === 'qoder-cn' ? CN_WEB_BASE : WEB_BASE;
63
+ }
64
+
49
65
  /** 签到请求头(源自 10router qoderCheckin.js,clientType=10 与官方客户端一致) */
50
66
  export function buildQoderHeaders(token) {
51
67
  return {