creditdaddy 1.2.0 → 1.3.2

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.
@@ -1,97 +1,97 @@
1
- /**
2
- * ZCode 活动自动领取 — 签到调度器每轮调用。
3
- *
4
- * 策略(对齐用户手动路径,逐级尝试):
5
- * 1. billing/preview 查可领活动;为空 → 无事可做(返回 null,不留痕迹)
6
- * 2. 先直接 claim(不带验证码:服务端未风控时可直接成功)
7
- * 3. 返回 3007/3001(需要验证码 / 参数被拒)且有验证码提供者 → 拉 captcha 配置,
8
- * 让提供者(桌面版的隐藏窗口跑阿里云验证码 SDK 静默验证)拿 captchaVerifyParam 后重试
9
- * 4. 没有提供者(NAS / 纯 CLI)或验证码需要人工 → 标记「需手动领取」,卡片上提示
10
- * - 1003(已领取过)按成功口径处理并记忆
11
- * 结果写入账号 lastResult(签到日历记忆用的是 status checked-in/already,第二天照常重查 preview)
12
- */
13
-
14
- import { logger } from './logger.js';
15
- import { fetchClaimPlans, claimPlan, fetchCaptchaConfig } from './zcodeClient.js';
16
-
17
- // 桌面版(Electron 主进程)注册的隐藏窗口验证码实现;NAS / CLI 为 null(走「需手动领取」分支)
18
- let captchaProvider = null;
19
- export function setZcodeCaptchaProvider(fn) { captchaProvider = typeof fn === 'function' ? fn : null; }
20
- export function hasZcodeCaptchaProvider() { return Boolean(captchaProvider); }
21
- export function getZcodeCaptchaProvider() { return captchaProvider; }
22
-
23
- const grantsText = (plan) => (plan.grants && plan.grants.length ? plan.grants.join(';') : '');
24
-
25
- /**
26
- * 为单个 ZCode 账号执行一次自动领取。
27
- * @returns {null | { status, message, claims: Array }} null = preview 为空或查询失败(已记日志,不上卡片)
28
- */
29
- export async function zcodeAutoClaim(account) {
30
- const label = account.name || account.uid || account.id;
31
- let plans;
32
- try {
33
- plans = (await fetchClaimPlans(account)).plans;
34
- } catch (e) {
35
- logger.debug('ZCODE-CLAIM', `${label} 活动查询失败:${e.message}`);
36
- return null;
37
- }
38
- if (!plans.length) return null;
39
-
40
- const claims = [];
41
- let anySuccess = false;
42
- let needManual = false;
43
-
44
- for (const plan of plans) {
45
- const planName = plan.name || plan.planId;
46
- // 第 2 步:先试不带验证码
47
- try {
48
- const r = await claimPlan(account, plan.planId, {});
49
- claims.push({ plan: planName, via: 'direct', ok: true });
50
- anySuccess = true;
51
- logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(免验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
52
- continue;
53
- } catch (e) {
54
- if (e.code === 1003) {
55
- claims.push({ plan: planName, ok: true, already: true });
56
- logger.info('ZCODE-CLAIM', `${label}「${planName}」已领取过`);
57
- continue;
58
- }
59
- if (e.code !== 3007 && e.code !== 3001) {
60
- claims.push({ plan: planName, ok: false, message: e.message });
61
- logger.warn('ZCODE-CLAIM', `${label} 领取「${planName}」失败:${e.message}`);
62
- continue;
63
- }
64
- }
65
- // 第 3 步:需要验证码
66
- if (!captchaProvider) {
67
- needManual = true;
68
- claims.push({ plan: planName, ok: false, needManual: true });
69
- logger.info('ZCODE-CLAIM', `${label}「${planName}」需要验证码,本环境无法自动通过,请在面板手动领取`);
70
- continue;
71
- }
72
- try {
73
- const cfg = await fetchCaptchaConfig();
74
- if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
75
- const { captchaParam, region } = await captchaProvider(cfg);
76
- await claimPlan(account, plan.planId, { captchaParam, region });
77
- claims.push({ plan: planName, via: 'captcha', ok: true });
78
- anySuccess = true;
79
- logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(静默验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
80
- } catch (e2) {
81
- needManual = true;
82
- claims.push({ plan: planName, ok: false, needManual: true, message: e2.message });
83
- logger.warn('ZCODE-CLAIM', `${label}「${planName}」验证码未完成:${e2.message}(可在面板手动领取)`);
84
- }
85
- }
86
-
87
- const names = claims.filter((c) => c.ok && !c.already).map((c) => c.plan);
88
- const status = names.length ? 'checked-in'
89
- : claims.every((c) => c.already) ? 'already'
90
- : 'failed';
91
- const message = status === 'checked-in'
92
- ? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
93
- : status === 'already' ? '活动均已领取过'
94
- : needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
95
- : `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
96
- return { status, message, claims };
97
- }
1
+ /**
2
+ * ZCode 活动自动领取 — 签到调度器每轮调用。
3
+ *
4
+ * 策略(对齐用户手动路径,逐级尝试):
5
+ * 1. billing/preview 查可领活动;为空 → 无事可做(返回 null,不留痕迹)
6
+ * 2. 先直接 claim(不带验证码:服务端未风控时可直接成功)
7
+ * 3. 返回 3007/3001(需要验证码 / 参数被拒)且有验证码提供者 → 拉 captcha 配置,
8
+ * 让提供者(桌面版的隐藏窗口跑阿里云验证码 SDK 静默验证)拿 captchaVerifyParam 后重试
9
+ * 4. 没有提供者(NAS / 纯 CLI)或验证码需要人工 → 标记「需手动领取」,卡片上提示
10
+ * - 1003(已领取过)按成功口径处理并记忆
11
+ * 结果写入账号 lastResult(签到日历记忆用的是 status checked-in/already,第二天照常重查 preview)
12
+ */
13
+
14
+ import { logger } from './logger.js';
15
+ import { fetchClaimPlans, claimPlan, fetchCaptchaConfig } from './zcodeClient.js';
16
+
17
+ // 桌面版(Electron 主进程)注册的隐藏窗口验证码实现;NAS / CLI 为 null(走「需手动领取」分支)
18
+ let captchaProvider = null;
19
+ export function setZcodeCaptchaProvider(fn) { captchaProvider = typeof fn === 'function' ? fn : null; }
20
+ export function hasZcodeCaptchaProvider() { return Boolean(captchaProvider); }
21
+ export function getZcodeCaptchaProvider() { return captchaProvider; }
22
+
23
+ const grantsText = (plan) => (plan.grants && plan.grants.length ? plan.grants.join(';') : '');
24
+
25
+ /**
26
+ * 为单个 ZCode 账号执行一次自动领取。
27
+ * @returns {null | { status, message, claims: Array }} null = preview 为空或查询失败(已记日志,不上卡片)
28
+ */
29
+ export async function zcodeAutoClaim(account) {
30
+ const label = account.name || account.uid || account.id;
31
+ let plans;
32
+ try {
33
+ plans = (await fetchClaimPlans(account)).plans;
34
+ } catch (e) {
35
+ logger.debug('ZCODE-CLAIM', `${label} 活动查询失败:${e.message}`);
36
+ return null;
37
+ }
38
+ if (!plans.length) return null;
39
+
40
+ const claims = [];
41
+ let anySuccess = false;
42
+ let needManual = false;
43
+
44
+ for (const plan of plans) {
45
+ const planName = plan.name || plan.planId;
46
+ // 第 2 步:先试不带验证码
47
+ try {
48
+ const r = await claimPlan(account, plan.planId, {});
49
+ claims.push({ plan: planName, via: 'direct', ok: true });
50
+ anySuccess = true;
51
+ logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(免验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
52
+ continue;
53
+ } catch (e) {
54
+ if (e.code === 1003) {
55
+ claims.push({ plan: planName, ok: true, already: true });
56
+ logger.info('ZCODE-CLAIM', `${label}「${planName}」已领取过`);
57
+ continue;
58
+ }
59
+ if (e.code !== 3007 && e.code !== 3001) {
60
+ claims.push({ plan: planName, ok: false, message: e.message });
61
+ logger.warn('ZCODE-CLAIM', `${label} 领取「${planName}」失败:${e.message}`);
62
+ continue;
63
+ }
64
+ }
65
+ // 第 3 步:需要验证码
66
+ if (!captchaProvider) {
67
+ needManual = true;
68
+ claims.push({ plan: planName, ok: false, needManual: true });
69
+ logger.info('ZCODE-CLAIM', `${label}「${planName}」需要验证码,本环境无法自动通过,请在面板手动领取`);
70
+ continue;
71
+ }
72
+ try {
73
+ const cfg = await fetchCaptchaConfig();
74
+ if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
75
+ const { captchaParam, region } = await captchaProvider(cfg);
76
+ await claimPlan(account, plan.planId, { captchaParam, region });
77
+ claims.push({ plan: planName, via: 'captcha', ok: true });
78
+ anySuccess = true;
79
+ logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(静默验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
80
+ } catch (e2) {
81
+ needManual = true;
82
+ claims.push({ plan: planName, ok: false, needManual: true, message: e2.message });
83
+ logger.warn('ZCODE-CLAIM', `${label}「${planName}」验证码未完成:${e2.message}(可在面板手动领取)`);
84
+ }
85
+ }
86
+
87
+ const names = claims.filter((c) => c.ok && !c.already).map((c) => c.plan);
88
+ const status = names.length ? 'checked-in'
89
+ : claims.every((c) => c.already) ? 'already'
90
+ : 'failed';
91
+ const message = status === 'checked-in'
92
+ ? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
93
+ : status === 'already' ? '活动均已领取过'
94
+ : needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
95
+ : `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
96
+ return { status, message, claims };
97
+ }
@@ -205,7 +205,7 @@ function connectTunnel(proxyUrl, target) {
205
205
  });
206
206
  }
207
207
 
208
- async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
208
+ async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, signal = null } = {}) {
209
209
  const proxy = effectiveProxy();
210
210
  if (!proxy || !/^http:\/\//i.test(proxy)) throw new Error('没有可用的 http 代理(面板配置或 HTTPS_PROXY 环境变量)');
211
211
  const u = new URL(url);
@@ -227,12 +227,22 @@ async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, t
227
227
  };
228
228
  }
229
229
  return new Promise((resolve, reject) => {
230
+ // request 的 timeout 是「无活动」超时:对端每 14 秒吐一个字节就能一直吊着请求不放。
231
+ // 这里再补一个整条请求的墙钟上限,并接上外部 abort(客户端断开 / 上层取消)。
232
+ const deadline = setTimeout(() => req.destroy(new Error('请求超时')), timeoutMs);
233
+ const onAbort = () => req.destroy(Object.assign(new Error('请求已取消'), { name: 'AbortError' }));
234
+ if (signal) {
235
+ if (signal.aborted) { clearTimeout(deadline); reject(signal.reason || new Error('请求已取消')); return; }
236
+ signal.addEventListener('abort', onAbort, { once: true });
237
+ }
238
+ const done = (fn, arg) => { clearTimeout(deadline); signal?.removeEventListener('abort', onAbort); fn(arg); };
230
239
  const req = (isHttps ? https : http).request(requestOpts, (res) => {
231
240
  const chunks = [];
232
241
  res.on('data', (c) => chunks.push(c));
233
- res.on('end', () => resolve(new Response(Buffer.concat(chunks), { status: res.statusCode, headers: res.headers })));
242
+ res.on('end', () => done(resolve, new Response(Buffer.concat(chunks), { status: res.statusCode, headers: res.headers })));
243
+ res.on('error', (e) => done(reject, e));
234
244
  });
235
- req.on('error', reject);
245
+ req.on('error', (e) => done(reject, e));
236
246
  req.on('timeout', () => req.destroy(new Error('请求超时')));
237
247
  if (body != null) req.write(typeof body === 'string' ? body : JSON.stringify(body));
238
248
  req.end();
@@ -249,19 +259,67 @@ async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, t
249
259
  let viaProxyImpl = null;
250
260
  export function _setViaProxyForTests(fn) { viaProxyImpl = fn || null; }
251
261
 
252
- export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, signal = null } = {}) {
253
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
254
- const abortSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
255
- const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: abortSignal };
262
+ /**
263
+ * 组合多个 AbortSignal 成一个:任一源触发即 abort。
264
+ * 与 AbortSignal.any 的区别——这里返回 { signal, releaseConnect },releaseConnect()
265
+ * 可摘掉 connectSignal 的转发监听:两段式超时里,响应头到达后连接段限时就该失效,
266
+ * 但整墙钟(wallSignal)和外部取消(external)要继续管住流式 body。
267
+ */
268
+ function combineSignals({ wallSignal, connectSignal, external }) {
269
+ const ctrl = new AbortController();
270
+ const onWall = () => ctrl.abort(wallSignal.reason);
271
+ const onConnect = () => ctrl.abort(connectSignal.reason);
272
+ const onExternal = () => ctrl.abort(external.reason);
273
+ if (wallSignal.aborted) ctrl.abort(wallSignal.reason);
274
+ else wallSignal.addEventListener('abort', onWall, { once: true });
275
+ if (connectSignal && !ctrl.signal.aborted) {
276
+ if (connectSignal.aborted) ctrl.abort(connectSignal.reason);
277
+ else connectSignal.addEventListener('abort', onConnect, { once: true });
278
+ }
279
+ if (external && !ctrl.signal.aborted) {
280
+ if (external.aborted) ctrl.abort(external.reason);
281
+ else external.addEventListener('abort', onExternal, { once: true });
282
+ }
283
+ // 连接段成功后调用:只摘掉 connectSignal 的转发,墙钟与外部取消继续管住 body 流
284
+ const releaseConnect = () => { connectSignal?.removeEventListener('abort', onConnect); };
285
+ return { signal: ctrl.signal, releaseConnect };
286
+ }
287
+
288
+ /**
289
+ * 直连 / 代理兜底的 fetch。可选 connectMs 启用两段式超时:
290
+ * - 拿到响应头前(DNS/TCP/TLS/首字节)限时 connectMs——上游黑洞挂死时几秒内
291
+ * 就失败换下一条路,而不是吊满整个 timeoutMs(网关补全场景尤其重要);
292
+ * - 拿到响应头后解除连接段限时,只保留 timeoutMs 整墙钟——流式补全可能合法地
293
+ * 跑几分钟,不能因为「连接快、生成慢」被误杀。
294
+ * 不传 connectMs 时退回单段 timeoutMs(旧行为,所有既有调用方不受影响)。
295
+ */
296
+ export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, connectMs = null, signal = null } = {}) {
256
297
  const useProxy = Boolean(effectiveProxy()) && !proxyBypass(url);
257
- const direct = () => fetch(url, base);
258
- const viaProxy = viaProxyImpl
259
- ? () => viaProxyImpl(url, { method, headers, body, timeoutMs })
260
- : () => fetchViaProxy(url, { method, headers, body, timeoutMs });
261
- const attempts = useProxy ? (proxyFirst() ? [viaProxy, direct] : [direct, viaProxy]) : [direct];
298
+ const twoPhase = Number.isFinite(connectMs) && connectMs > 0 && connectMs < timeoutMs;
299
+ // 超时信号必须每次尝试各建一个:共用一个的话,代理优先模式下第一条路吃完 timeoutMs,
300
+ // 兜底那条就对着已触发的 signal 发请求,必定瞬间失败——「代理优先」就此形同虚设
301
+ const makeAttempt = () => {
302
+ const wallSignal = AbortSignal.timeout(timeoutMs);
303
+ const connectSignal = twoPhase ? AbortSignal.timeout(connectMs) : null;
304
+ const { signal: abortSignal, releaseConnect } = combineSignals({ wallSignal, connectSignal, external: signal });
305
+ const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: abortSignal };
306
+ return {
307
+ // 响应头到达 = 连接段成功:摘掉 connectSignal 转发,此后只有墙钟/外部取消能中断流
308
+ direct: async () => { const r = await fetch(url, base); releaseConnect(); return r; },
309
+ viaProxy: async () => {
310
+ const r = viaProxyImpl
311
+ ? await viaProxyImpl(url, { method, headers, body, timeoutMs, signal: abortSignal })
312
+ : await fetchViaProxy(url, { method, headers, body, timeoutMs, signal: abortSignal });
313
+ releaseConnect();
314
+ return r;
315
+ },
316
+ };
317
+ };
318
+ const order = useProxy ? (proxyFirst() ? ['viaProxy', 'direct'] : ['direct', 'viaProxy']) : ['direct'];
262
319
  let lastErr = null;
263
- for (const attempt of attempts) {
264
- try { return await attempt(); } catch (e) { lastErr = e; }
320
+ for (const which of order) {
321
+ const attempts = makeAttempt();
322
+ try { return await attempts[which](); } catch (e) { lastErr = e; }
265
323
  }
266
324
  throw lastErr || new Error('fetch failed');
267
325
  }
@@ -305,7 +363,11 @@ async function bigmodelHeaders(token) {
305
363
  async function getJson(url, headers) {
306
364
  const res = await fetchJsonRace(url, { headers, timeoutMs: FETCH_TIMEOUT_MS });
307
365
  const text = await res.text();
308
- try { return JSON.parse(text); } catch { return { code: res.status, msg: text.slice(0, 120) }; }
366
+ try { return JSON.parse(text); } catch {
367
+ // 解析失败(含 HTTP 200 + 空 body,服务端偶发对无效 token 就是这样)绝不能伪装成 code:200,
368
+ // 否则 businessOk 会放行并让额度链提前返回“无套餐”,短路后面的 balance 尝试
369
+ return { code: res.ok ? -1 : res.status, msg: (text || `empty response (HTTP ${res.status})`).slice(0, 120) };
370
+ }
309
371
  }
310
372
 
311
373
  const businessOk = (v) => {
@@ -480,6 +542,12 @@ export function normalizePlans(previewResp) {
480
542
  return out;
481
543
  }
482
544
 
545
+ /** “当前用户不存在coding plan / 没有资格”属于无套餐的业务口径,不是查询失败(同 zcode-switch is_no_plan_message) */
546
+ function isNoPlanMessage(msg) {
547
+ const m = String(msg || '');
548
+ return m.includes('不存在coding plan') || m.includes('没有资格') || /no.*coding.*plan/i.test(m);
549
+ }
550
+
483
551
  /**
484
552
  * 额度查询:先试 BigModel Coding Plan(quota/limit),再试 Z.ai / Start Plan(billing/balance)。
485
553
  * 都没有有效套餐时返回 { empty: true }(不是错误:很多账号只用免费额度)。
@@ -488,28 +556,35 @@ export async function fetchZcodeQuota(account) {
488
556
  const tokens = candidateTokens(account);
489
557
  if (!tokens.length) throw new Error('账号快照里没有可用 token,请在 ZCode 登录后重新导入');
490
558
  let lastErr = null;
559
+ let sawNoPlan = false;
491
560
  let authFails = 0;
492
561
  for (const t of tokens) {
493
562
  try {
494
563
  const limit = await getJson(QUOTA_LIMIT_URL, await bigmodelHeaders(t));
495
564
  if (businessOk(limit)) {
496
565
  const sub = await getJson(SUBSCRIPTION_URL, await bigmodelHeaders(t)).catch(() => null);
497
- return { ...normalizeQuotaLimit(limit, sub), source: 'bigmodel' };
566
+ const ov = normalizeQuotaLimit(limit, sub);
567
+ // BigModel 侧“有效响应但零额度项”不代表账号没额度(Start Plan 账号走 balance 有额度),
568
+ // 不能提前返回 empty,必须继续把 balance 链路试完——与 zcode-switch 每 token 双端点连查一致
569
+ if (!ov.empty) return { ...ov, source: 'bigmodel' };
498
570
  }
499
571
  if (limit?.code === 401) authFails++;
572
+ else if (isNoPlanMessage(limit?.msg)) sawNoPlan = true;
500
573
  else lastErr = limit?.msg || lastErr;
501
574
  } catch (e) { lastErr = e.message; }
502
575
  }
503
576
  for (const t of billingTokens(account)) {
504
577
  try {
505
578
  const bal = await getJson(`${BILLING_BALANCE_URL}?app_version=${await zcodeAppVersion()}`, await zaiHeaders(t, ensureVirtualDeviceMid(account)));
579
+ // balance 是账号级总余额端点,有效答复(含“确无套餐”)即可采信;
580
+ // 与 bigmodel 侧不同——那里“有效但零额度项”不代表账号没额度,必须继续往下试
506
581
  if (businessOk(bal)) return { ...normalizeBalance(bal), source: 'zcode.z.ai' };
507
582
  if (bal?.code === 401) authFails++;
508
583
  } catch (e) { lastErr = e.message; }
509
584
  }
510
585
  if (authFails && authFails >= tokens.length) throw new Error('鉴权失败,登录可能已过期,请在 ZCode 重新登录后重新导入');
511
- // “当前用户不存在coding plan”之类属于无套餐,按空额度返回
512
- if (!lastErr || /不存在|no.*plan/i.test(lastErr)) {
586
+ // 无套餐口径:显式的“不存在coding plan”或整条链没有任何错误
587
+ if (!lastErr || sawNoPlan) {
513
588
  return { total: 0, used: 0, remaining: 0, unit: '', plan: null, parts: [], empty: true, exceeded: false };
514
589
  }
515
590
  throw new Error(`额度查询失败:${lastErr}`);