creditdaddy 1.5.2 → 1.6.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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 豆包(doubao.com)网页会话 — 每日签到领体验额度(issue #32)。
3
+ *
4
+ * POST https://www.doubao.com/alice/ug/campaign/v3/detail/ 任务面板数据(含签到状态)
5
+ * POST https://www.doubao.com/alice/ug/campaign/landing/checkin 领取(+200 额度/天)
6
+ *
7
+ * 鉴权:纯 doubao.com 会话 cookie(网页捕获线),无 a_bogus / msToken 请求签名;
8
+ * device_id 空可过;服务端自带幂等(重复领取返回 idempotent:true 不重复发放)。
9
+ * 接口形状 2026-10-19/20 带调试端口实测(CDP 挂监听抓已签 + 未签两个场景)。
10
+ *
11
+ * 参与门槛:仅个人账号(飞书企业号被排除,活动页会提示「请切换至个人账号」)。
12
+ * 活动页 UI 有「仅支持豆包电脑版」前端墙,但接口不吃这套——cookie 直打即通。
13
+ */
14
+
15
+ import crypto from 'node:crypto';
16
+ import { fetchJsonRace } from './zcodeClient.js';
17
+ import { FETCH_TIMEOUT_MS } from './constants.js';
18
+
19
+ const DETAIL_URL = 'https://www.doubao.com/alice/ug/campaign/v3/detail/';
20
+ const CHECKIN_URL = 'https://www.doubao.com/alice/ug/campaign/landing/checkin';
21
+ /** 任务面板所属活动组(pc 端激励任务 v3;签到是其中的 daily_signin 任务) */
22
+ const CAMPAIGN_GROUP = 'pc_incentive_task_mvp_v3';
23
+
24
+ /**
25
+ * 客户端指纹伪装(issue #32 用户实测反馈:纯网页 cookie 裸 UA 打活动接口吃「请求非法」,
26
+ * 活动在服务端按客户端环境放行)。指纹 = 豆包桌面版 webview 的完整请求形状(CDP 抓包):
27
+ * - UA 与 webview 同款(Chrome/147 + DoubaoWebView 标识)
28
+ * - URL 必带 version_code / device_platform=web / web_platform=desktop / aid 等平台参数
29
+ * - web_id / tea_uuid 按账号 uid 确定性派生(每号一台稳定「设备」,同 attempt 语义)
30
+ * - device_id 空(实测可过)
31
+ */
32
+ const CLIENT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) DoubaoDesktop/2.32.6 Chrome/147.0.7727.149 Safari/537.36';
33
+ const COMMON_PARAMS = {
34
+ version_code: '20800',
35
+ language: 'zh',
36
+ device_platform: 'web',
37
+ doubao_device_platform: 'web',
38
+ aid: '497858',
39
+ real_aid: '497858',
40
+ pkg_type: 'release_version',
41
+ pc_version: '2.32.6',
42
+ doubao_pc_version: '5.72.66',
43
+ samantha_web: '1',
44
+ web_platform: 'desktop',
45
+ 'use-olympus-account': '1',
46
+ web_tab_id: '0',
47
+ };
48
+
49
+ /** 按 uid 派生稳定设备标识(每号恒定,账号间互异;纯数字 web_id 风格) */
50
+ function deviceIdsFor(uid) {
51
+ const seed = crypto.createHash('sha256').update(`doubao-dev:${uid || 'anonymous'}`).digest();
52
+ const webId = '76' + [...seed.subarray(0, 8)].map((b) => b % 10).join(''); // 18 位数字
53
+ const teaUuid = [...seed.subarray(8, 16)].map((b) => (b % 10)).join('').padEnd(16, '7');
54
+ return { web_id: webId, tea_uuid: teaUuid };
55
+ }
56
+
57
+ /** 组装带客户端指纹的接口 URL */
58
+ function apiUrl(base, uid) {
59
+ const params = new URLSearchParams({ ...COMMON_PARAMS, ...deviceIdsFor(uid), device_id: '', region: '', sys_region: '' });
60
+ return base + '?' + params.toString();
61
+ }
62
+
63
+ const postJson = (url, cookie, body, uid) => fetchJsonRace(url, {
64
+ method: 'POST',
65
+ headers: {
66
+ Cookie: String(cookie || ''),
67
+ 'Content-Type': 'application/json',
68
+ Accept: 'application/json, text/plain, */*',
69
+ Referer: 'https://www.doubao.com/pc/agent-explore',
70
+ Origin: 'https://www.doubao.com',
71
+ 'User-Agent': CLIENT_UA,
72
+ },
73
+ body: JSON.stringify(body),
74
+ timeoutMs: FETCH_TIMEOUT_MS,
75
+ });
76
+
77
+ /** 会话失效的业务码(实测:匿名 / 过期 cookie 返 code 710012001「登录已过期」) */
78
+ const LOGIN_INVALID = 710012001;
79
+
80
+ /** detail 响应里挖签到任务(tasks[].task_kind === 'daily_signin') */
81
+ function findCheckinTask(body) {
82
+ const tasks = body?.data?.tasks;
83
+ if (!Array.isArray(tasks)) return null;
84
+ return tasks.find((t) => t && t.task_kind === 'daily_signin') || null;
85
+ }
86
+
87
+ /**
88
+ * 签到状态 + 体验额度读数(detail 接口一次拿全)。
89
+ * @returns {{ auth: boolean, code: number|string|null, checkin: { checkedIn: boolean, continuousDays: number, lastDate: string, rewardAmount: number }|null, totalAmount: number|null }}
90
+ */
91
+ /**
92
+ * 签到状态 + 体验额度读数(detail 接口一次拿全)。uid 用于派生稳定设备指纹。
93
+ * @returns {{ auth: boolean, code: number|string|null, checkin: object|null, totalAmount: number|null }}
94
+ */
95
+ export async function fetchDoubaoStatus(cookie, uid = '') {
96
+ const res = await postJson(apiUrl(DETAIL_URL, uid), cookie, { campaign_group_code: CAMPAIGN_GROUP }, uid);
97
+ let body = null;
98
+ try { body = JSON.parse(await res.text()); } catch { /* 非 JSON */ }
99
+ if (!body || typeof body !== 'object') return { auth: false, code: `HTTP ${res.status}`, checkin: null, totalAmount: null };
100
+ if (!body || typeof body !== 'object') return { auth: res.status !== 401, code: `HTTP ${res.status}`, checkin: null, totalAmount: null };
101
+ const code = Number(body.code);
102
+ if (code === LOGIN_INVALID) return { auth: false, code, checkin: null, totalAmount: null };
103
+ const task = findCheckinTask(body);
104
+ const c = task?.daily_checkin || {};
105
+ return {
106
+ auth: true,
107
+ code,
108
+ checkin: task ? {
109
+ checkedIn: c.checked_in_today === true,
110
+ continuousDays: Number(c.continuous_days) || 0,
111
+ lastDate: typeof c.last_checkin_date === 'string' ? c.last_checkin_date : null,
112
+ rewardAmount: Number(c.reward?.reward_amount) || 0,
113
+ } : null,
114
+ totalAmount: Number(body.data?.progress?.total_amount) || 0,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * 每日签到:先查状态,未签才 claim。返回 CreditDaddy 签到契约。
120
+ * 服务端幂等:重复领取 code 0 + idempotent:true——归 already 不算错误。
121
+ */
122
+ export async function checkinDoubao(account) {
123
+ const base = { accountId: account.id, account: account.name || account.id, provider: 'doubao' };
124
+ const cookie = account.meta?.cookieSiteSession?.cookie || account.cookie;
125
+ if (!cookie) return { ...base, status: 'failed', message: '缺少豆包会话 cookie(请重新登录捕获)' };
126
+
127
+ const uid = account.uid || '';
128
+ let st;
129
+ try {
130
+ st = await fetchDoubaoStatus(cookie, uid);
131
+ } catch (e) {
132
+ return { ...base, status: 'failed', message: e?.message || '网络错误' };
133
+ }
134
+ if (!st.auth) {
135
+ const e = new Error('豆包会话已失效,请重新登录捕获');
136
+ e.auth = true;
137
+ throw e;
138
+ }
139
+ if (st.checkin?.checkedIn) {
140
+ return { ...base, status: 'already', message: '今天已签到', uid: null };
141
+ }
142
+ if (!st.checkin) {
143
+ // 活动下线 / 飞书企业号被排除等场景:detail 里没有签到任务
144
+ return { ...base, status: 'no-activity', message: '当前账号无签到任务(活动可能未开放,或飞书企业账号不支持参与)' };
145
+ }
146
+
147
+ let body = null;
148
+ try {
149
+ const res = await postJson(apiUrl(CHECKIN_URL, uid), cookie, { scene: CAMPAIGN_GROUP }, uid);
150
+ const text = await res.text();
151
+ body = JSON.parse(text);
152
+ } catch (e) {
153
+ return { ...base, status: 'failed', message: e?.message || '网络错误' };
154
+ }
155
+ if (!body || typeof body !== 'object') return { ...base, status: 'failed', message: '签到接口返回无法解析' };
156
+ const code = Number(body.code);
157
+ if (code === LOGIN_INVALID) {
158
+ const e = new Error('豆包会话已失效,请重新登录捕获');
159
+ e.auth = true;
160
+ throw e;
161
+ }
162
+ if (code !== 0) return { ...base, status: 'failed', message: `${body.msg || body.message || '签到失败'}(code ${code})` };
163
+ const d = body.data || {};
164
+ if (d.idempotent === true) return { ...base, status: 'already', message: '今天已签到(服务端幂等去重)' };
165
+ const amount = Number(d.reward_amount) || st.checkin.rewardAmount || 200;
166
+ const days = Number(d.continuous_days) || st.checkin.continuousDays + 1;
167
+ return { ...base, status: 'checked-in', claimedAmount: amount, message: `签到成功,+${amount} 额度(连续 ${days} 天)` };
168
+ }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * 首批 source(issue #24「建议的首批接线」):
10
10
  * - `tokenharbor` :TH 免费额度网页读数(CD 已捕获的网页会话 → dashboard 解析);
11
- * - `minimax-code` :MiniMax Code 免费线算力币/签到额度——10Router 的 minimax-free
11
+ * - `minimax-free` :MiniMax Code 免费线算力币/签到额度——挂在 10Router 的 minimax-free 提供商(订阅线 minimax-code 是 10R 自己的直连线,两者不同卡)
12
12
  * 路由卡走 CD 本地网关,配额只有 CD 查得到,推数字正好补它的盲区。
13
13
  *
14
14
  * 降级语义(issue #24):会话失效 / 全部解析失败时改推 `message` 文字状态卡,
@@ -102,13 +102,62 @@ export function nextUtc8Midnight(now = new Date()) {
102
102
  return new Date((Math.floor(ms / dayMs) + 1) * dayMs - 8 * 3600_000).toISOString();
103
103
  }
104
104
 
105
+ /** 生成单个 MiniMax 账号的外配额卡 source id */
106
+ export function buildMiniMaxAccountSource(account) {
107
+ const rawId = account.uid || account.id || 'default';
108
+ const clean = String(rawId).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 18);
109
+ return `minimax-free-${clean || 'acc'}`.slice(0, 32);
110
+ }
111
+
112
+ /**
113
+ * 组装单个 MiniMax 账号的单卡载荷(minimax-code 同款单值卡):
114
+ * 单账号独立一张卡,卡面副标题为账号名称(如「主」、「备」),
115
+ * 单行 Credits 储值余额,右侧带有只读连续签到徽章。
116
+ */
117
+ export function buildMiniMaxAccountPayload(account, quotaData, checkinData, { resetAt } = {}) {
118
+ const source = buildMiniMaxAccountSource(account);
119
+ const remaining = Number(quotaData?.remaining ?? quotaData?.total);
120
+ const label = String(account?.name || account?.uid || 'MiniMax').slice(0, 100);
121
+ const payload = {
122
+ provider: 'minimax-free',
123
+ label,
124
+ plan: quotaData?.plan || 'Free',
125
+ };
126
+
127
+ if (Number.isFinite(remaining) && !quotaData?.error) {
128
+ const row = {
129
+ name: 'Credits',
130
+ used: 0,
131
+ total: Math.max(remaining, 0) || 0.000001,
132
+ displayRemaining: true,
133
+ isCreditBalance: true,
134
+ recurring: true,
135
+ resetAt: resetAt || nextUtc8Midnight(),
136
+ };
137
+ if (checkinData) {
138
+ if (checkinData.checkedToday !== undefined) row.checkinToday = checkinData.checkedToday === true;
139
+ if (Number.isFinite(checkinData.streakDays) && checkinData.streakDays > 0) {
140
+ row.streakDays = checkinData.streakDays;
141
+ }
142
+ }
143
+ payload.quotas = { credits: row };
144
+ } else {
145
+ payload.message = quotaData?.error || t('MiniMax 账号额度暂不可查(凭据失效需在 CreditDaddy 重新登录,或官方接口异常稍后自动重试)');
146
+ }
147
+ assertScalarOnly(payload, source);
148
+ return { source, payload };
149
+ }
150
+
105
151
  /**
106
- * 组装 minimax-code 卡片载荷(issue #24 行字段建议):
152
+ * 组装 minimax-code 卡片载荷(历史兼容多行卡方法):
107
153
  * 每账号一行「存量」——`used=0 / total=剩余算力币 / displayRemaining=true`,
108
154
  * `resetAt` 给次日零点(免费线每日刷新口径)。全部查询失败时降级为文字状态卡。
155
+ * 每行可带只读签到状态(checkinToday / streakDays)——10R 白名单放行后卡面显示「今日已签」;
156
+ * 旧版 10R 无此字段自动忽略(向前兼容)。签到动作不进 10R(只读边界,方案 B)。
109
157
  * @param fetchQuota 注入的「account → 归一化额度」查询器(生产 = fetchMiniMaxQuota)
158
+ * @param fetchCheckin 注入的「account → 签到状态」查询器(生产 = fetchMiniMaxCheckinStatus;测试可注入)
110
159
  */
111
- export async function buildMiniMaxPayload(accounts, fetchQuota, { resetAt } = {}) {
160
+ export async function buildMiniMaxPayload(accounts, fetchQuota, { resetAt, fetchCheckin } = {}) {
112
161
  const rows = {};
113
162
  let failed = 0;
114
163
  for (const a of accounts.slice(0, MAX_ROWS)) {
@@ -117,24 +166,40 @@ export async function buildMiniMaxPayload(accounts, fetchQuota, { resetAt } = {}
117
166
  const remaining = Number(q?.remaining);
118
167
  if (!Number.isFinite(remaining) || q?.error) { failed++; continue; }
119
168
  const i = Object.keys(rows).length;
120
- rows[rowKey(i)] = {
169
+ const row = {
121
170
  name: (a.name || a.uid || 'MiniMax').slice(0, 80),
122
171
  used: 0,
123
172
  total: Math.max(remaining, 0) || 0.000001, // total 必须 >0(10Router positiveNumber);0 余额给最小正数让卡片显示「耗尽」
124
173
  displayRemaining: true,
174
+ // 算力币是储值余额不是订阅包:10R 按单值卡渲染(无 X/Y 进度条与重置倒计时,
175
+ // 与 minimax-code 订阅线 Credits 卡同款)。10R 旧版无此字段自动忽略。
176
+ isCreditBalance: true,
125
177
  recurring: true,
126
178
  resetAt: resetAt || nextUtc8Midnight(),
127
179
  };
180
+ // 签到状态(可选注入,fetch 失败 / 无注入则不带——字段缺席即「状态未知」,10R 不画徽章)
181
+ if (fetchCheckin) {
182
+ try {
183
+ const c = await fetchCheckin(a);
184
+ if (c) {
185
+ row.checkinToday = c.checkedToday === true;
186
+ if (Number.isFinite(c.streakDays) && c.streakDays > 0) row.streakDays = c.streakDays;
187
+ }
188
+ } catch { /* 状态未知就不带 */ }
189
+ }
190
+ rows[rowKey(i)] = row;
128
191
  }
129
- const payload = { provider: 'minimax-code', label: t('MiniMax Code 免费额度') };
192
+ // label 是数据标识不是 UI 文案——不随 CD 面板语言翻译(面板设英文时曾把
193
+ // label 推成英文,10R 卡面副标题跟着变英文)。显示侧翻译由 10R 负责。
194
+ const payload = { provider: 'minimax-free', label: 'MiniMax Code 免费额度' };
130
195
  if (Object.keys(rows).length) {
131
196
  payload.quotas = rows;
132
197
  payload.plan = 'Free';
133
- if (failed) payload.label = t('MiniMax Code 免费额度({n} 个账号查询失败)', { n: failed }).slice(0, 100);
198
+ if (failed) payload.label = `MiniMax Code 免费额度(${failed} 个账号查询失败)`.slice(0, 100);
134
199
  } else {
135
200
  payload.message = t('MiniMax 账号额度暂不可查(凭据失效需在 CreditDaddy 重新登录,或官方接口异常稍后自动重试)');
136
201
  }
137
- assertScalarOnly(payload, 'minimax-code');
202
+ assertScalarOnly(payload, 'minimax-free');
138
203
  return payload;
139
204
  }
140
205
 
@@ -215,6 +280,13 @@ export async function runQuotaPush({ endpoint, key, accounts, fetchImpl, onlySou
215
280
  if (hit) hit.meta = { ...(hit.meta || {}), lastProbe: { ok: Boolean(ok), status, error: ok ? null : String(error || '').slice(0, 200), at: new Date().toISOString() } };
216
281
  })).catch(() => {});
217
282
  };
283
+ const markRateLimited = (a, error) => {
284
+ if (noProbeMark || !a?.id) return;
285
+ import('./store.js').then(({ withAccounts }) => withAccounts((list) => {
286
+ const hit = list.find((x) => x.id === a.id);
287
+ if (hit) hit.meta = { ...(hit.meta || {}), lastProbe: { ok: false, status: 'dead', error: String(error || '').slice(0, 200), at: new Date().toISOString() } };
288
+ })).catch(() => {});
289
+ };
218
290
  // live 余额落库(面板 /quota 缓存路径的数据源):推送循环每轮 live 拉到的数字顺手写回
219
291
  // meta.quotaSnapshot + 各家明细字段,缓存随推送节奏(默认 60 分钟)保鲜。无条件落盘——
220
292
  // 回调路径(quota-refresh → onlySource)拿的也是真数据,没理由只落 lastProbe 不落数字。
@@ -232,8 +304,39 @@ export async function runQuotaPush({ endpoint, key, accounts, fetchImpl, onlySou
232
304
  return Number.isFinite(at) && (Date.now() - at) < DEAD_RETRY_MS;
233
305
  };
234
306
 
235
- // 清理历史遗留的 minimax-code 卡片(MiniMax 账号会话在 CD 自持,避免生成 0 行报错卡)
236
- if (!only) deleteExternalQuota({ endpoint, key, source: 'minimax-code', fetchImpl }).catch(() => {});
307
+ // 0. minimax-free(单账号单卡,minimax-code 同款单值卡:带签到徽章)
308
+ const mmAccounts = (accounts || []).filter((a) => a.provider === 'minimax' && (a.token || a.refreshToken));
309
+ if (mmAccounts.length) {
310
+ // 一次性清理:按账号单卡(6ebcc52)之前的版本把全部账号聚在 source「minimax-free」
311
+ // 一张多行卡上;升级用户的那张旧卡没人再推(也不该再推),不删会挂到 7 天 TTL 自然过期,
312
+ // 期间与新的 per-account 卡并存(主/备 + 老聚合卡三张,2026-10-21 实测)。有账号可推时
313
+ // 删一次旧 source,失败不阻断本轮推送(下轮还会再试)。
314
+ if (!only) {
315
+ const deleted = await deleteExternalQuota({ endpoint, key, source: 'minimax-free', fetchImpl });
316
+ if (deleted) logger.info('10R', '旧版聚合卡 minimax-free 已清理(推送改为按账号单卡)');
317
+ }
318
+ const { fetchMiniMaxQuota, fetchMiniMaxCheckinStatus } = await import('./minimaxClient.js');
319
+ for (const a of mmAccounts) {
320
+ const { source } = buildMiniMaxAccountPayload(a, null);
321
+ if (only && !only.includes(source)) continue;
322
+ const skipDead = !only && deadSkip(a);
323
+ let q = null;
324
+ let c = null;
325
+ if (!skipDead) {
326
+ try { q = await fetchMiniMaxQuota(a); } catch (e) { q = { error: e.message }; }
327
+ try { c = await fetchMiniMaxCheckinStatus(a); } catch {}
328
+ }
329
+ const { payload } = buildMiniMaxAccountPayload(a, q, c);
330
+ try {
331
+ const r = await pushOne({ endpoint, key, source, payload, fetchImpl, refreshUrl: await rurl(source) });
332
+ results.push({ source, status: 'ok', entries: r.entries });
333
+ logger.info('10R', '额度卡已推送:{source}({n} 行)', { source, n: r.entries ?? (Object.keys(payload.quotas || {}).length || 0) });
334
+ } catch (e) {
335
+ results.push({ source, status: 'failed', error: e.message });
336
+ logger.warn('10R', '额度卡推送失败 {source}:{err}', { source, err: e.message });
337
+ }
338
+ }
339
+ }
237
340
 
238
341
  // 1. tokenharbor(issue #24:TH 免费额度网页读数)
239
342
  const thAccounts = (accounts || []).filter((a) => a.provider === 'tokenharbor' && a.meta?.cookieSiteId);
@@ -249,6 +352,7 @@ export async function runQuotaPush({ endpoint, key, accounts, fetchImpl, onlySou
249
352
  }
250
353
  }
251
354
 
355
+
252
356
  // 2. xiaomi-mimo(issue #26:小米 MiMo 控制台充值余额推送)
253
357
  const mimoAccounts = (accounts || []).filter((a) => (a.provider === 'xiaomi-mimo' || a.meta?.cookieSiteId === 'xiaomi-mimo') && a.token);
254
358
  if (mimoAccounts.length) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 冷却时长的人类可读格式(三个网关共用)。
3
+ * 30_000 → '30 秒'
4
+ * 60_000 → '1 分钟'
5
+ * 120_000 → '2 分钟'
6
+ * 240_000 → '4 分钟'
7
+ * 不足 1 分钟的按秒显示,否则 30s 会被 Math.round 抹成「1 分钟」(或与 0 混淆)。
8
+ */
9
+ export function formatDurationMs(ms) {
10
+ // 冷却截止是 Date.now() + ms 算出来的,正常不会异常;但 NaN / Infinity 直接进模板
11
+ // 会渲染成「NaN 分钟」这种鬼东西,这里统一兜成 0 秒
12
+ const finite = Number.isFinite(ms) ? ms : 0;
13
+ const s = Math.max(0, Math.round(finite / 1000));
14
+ if (s < 60) return `${s} 秒`;
15
+ const m = Math.round(s / 60);
16
+ return `${m} 分钟`;
17
+ }