creditdaddy 1.6.2 → 1.6.5
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.
- package/README.md +1 -1
- package/bin/creditdaddy.js +22 -0
- package/build.json +3 -3
- package/package.json +2 -2
- package/src/checkin.js +4 -0
- package/src/constants.js +3 -1
- package/src/cookieSites.js +8 -1
- package/src/daemon.js +2251 -2062
- package/src/doubaoClient.js +0 -1
- package/src/i18n/en.json +94 -1
- package/src/i18n/ja.json +94 -1
- package/src/i18n/ko.json +94 -1
- package/src/i18n/zh-TW.json +94 -1
- package/src/loomyAuth.js +230 -0
- package/src/loomyClient.js +306 -0
- package/src/loomyLocal.js +188 -0
- package/src/minimaxGateway.js +61 -11
- package/src/panel.html +350 -59
- package/src/providers.js +9 -0
- package/src/qoderClient.js +15 -6
- package/src/quotaRefresh.js +5 -0
- package/src/store.js +9 -2
- package/src/tenrouter.js +37 -14
- package/src/tenrouterAccounts.js +1 -0
- package/src/traeClient.js +18 -5
- package/src/traeGateway.js +69 -13
- package/src/transfer.js +3 -0
- package/src/usageSync.js +11 -1
- package/src/zcodeAutoClaim.js +15 -2
- package/src/zcodeClient.js +40 -7
- package/src/zcodeGateway.js +32 -2
package/src/providers.js
CHANGED
|
@@ -17,6 +17,7 @@ import { fetchCatpawQuota, fetchCatpawProfile } from './catpawClient.js';
|
|
|
17
17
|
import { fetchTraeQuota, checkinTrae, verifyTraeAccount } from './traeClient.js';
|
|
18
18
|
import { fetchMiniMaxQuota, checkinMiniMax, verifyMiniMaxAccount } from './minimaxClient.js';
|
|
19
19
|
import { fetchDoubaoStatus, checkinDoubao } from './doubaoClient.js';
|
|
20
|
+
import { fetchLoomyQuota, verifyLoomyAccount } from './loomyClient.js';
|
|
20
21
|
/** 从 userinfo 响应中挑一个可读的显示名 */
|
|
21
22
|
export function displayNameFrom(ui) {
|
|
22
23
|
const pick = [ui?.nickname, ui?.name, ui?.username, ui?.email]
|
|
@@ -230,6 +231,14 @@ const PRODUCTS = {
|
|
|
230
231
|
};
|
|
231
232
|
},
|
|
232
233
|
},
|
|
234
|
+
loomy: {
|
|
235
|
+
label: '讯飞 Loomy',
|
|
236
|
+
quota: (account) => fetchLoomyQuota(account),
|
|
237
|
+
verify: (account) => verifyLoomyAccount(account),
|
|
238
|
+
},
|
|
239
|
+
// 码道 CodeArts(issue #40)已于 2026-10-30 撤下:华为云控制台会话受闲置超时 +
|
|
240
|
+
// WAF 指印绑定,本机无法续期,实测当日内即失效,无法支撑每日定时签到。详见
|
|
241
|
+
// cookieSites.js 末尾的口径说明。
|
|
233
242
|
};
|
|
234
243
|
|
|
235
244
|
/** 包裹 MiniMax 的 ctx.onRefresh:回写账号库后,安全同步新凭据回本机 auth.json(仅本机导入账号且客户端未运行时) */
|
package/src/qoderClient.js
CHANGED
|
@@ -55,8 +55,10 @@ export async function exchangePatToJobToken(pat, isCn = false) {
|
|
|
55
55
|
const text = await res.text().catch(() => '');
|
|
56
56
|
throw new Error(`PAT 兑换失败: HTTP ${res.status} ${text.slice(0, 120)}`);
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// 200 但 body 非 JSON(边缘节点 HTML 拦截页/空响应):裸 res.json() 抛 SyntaxError,
|
|
59
|
+
// resolveToken 的兜底 catch 会把它报成「token 不可用」,用户冤枉地去重导 token
|
|
60
|
+
const data = await res.json().catch(() => null);
|
|
61
|
+
if (!data?.token) throw new Error('PAT 兑换未返回 job token(响应非 JSON 或缺 token)');
|
|
60
62
|
return { jobToken: data.token, expiresAt: data.expires_at || null };
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -84,7 +86,9 @@ export async function fetchUserinfo(account) {
|
|
|
84
86
|
preferProxy: account.provider === 'qoder',
|
|
85
87
|
});
|
|
86
88
|
if (!res.ok) throw new Error(`userinfo HTTP ${res.status}`);
|
|
87
|
-
|
|
89
|
+
const data = await res.json().catch(() => null);
|
|
90
|
+
if (!data || typeof data !== 'object') throw new Error(`userinfo 响应非 JSON(HTTP ${res.status})`);
|
|
91
|
+
return data;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/** 查询配额/积分用量 */
|
|
@@ -97,7 +101,9 @@ export async function fetchQuotaUsage(account) {
|
|
|
97
101
|
});
|
|
98
102
|
if (res.status === 401 || res.status === 403) throw new Error(`鉴权失败 (HTTP ${res.status}),token 可能已过期`);
|
|
99
103
|
if (!res.ok) throw new Error(`quota HTTP ${res.status}`);
|
|
100
|
-
|
|
104
|
+
const data = await res.json().catch(() => null);
|
|
105
|
+
if (!data || typeof data !== 'object') throw new Error(`quota 响应非 JSON(HTTP ${res.status})`);
|
|
106
|
+
return data;
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
function webHeaders(cookie, provider) {
|
|
@@ -135,7 +141,9 @@ export async function fetchUsageDetail(account) {
|
|
|
135
141
|
throw err;
|
|
136
142
|
}
|
|
137
143
|
if (!res.ok) throw new Error(`usage detail HTTP ${res.status}`);
|
|
138
|
-
|
|
144
|
+
const data = await res.json().catch(() => null);
|
|
145
|
+
if (!data || typeof data !== 'object') return null; // 结构异常按「无明细」回落聚合数据
|
|
146
|
+
return data;
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
/**
|
|
@@ -174,7 +182,8 @@ async function getCampaigns(base, headers) {
|
|
|
174
182
|
throw err;
|
|
175
183
|
}
|
|
176
184
|
if (!res.ok) throw new Error(`活动列表 HTTP ${res.status}`);
|
|
177
|
-
const payload = await res.json();
|
|
185
|
+
const payload = await res.json().catch(() => null);
|
|
186
|
+
if (!payload || typeof payload !== 'object') throw new Error(`活动列表响应非 JSON(HTTP ${res.status})`);
|
|
178
187
|
return {
|
|
179
188
|
uid: typeof payload?.uid === 'string' ? payload.uid : null,
|
|
180
189
|
campaigns: Array.isArray(payload?.campaigns) ? payload.campaigns : [],
|
package/src/quotaRefresh.js
CHANGED
|
@@ -103,6 +103,11 @@ export async function verifyQuotaRefresh({ source, exp, sig }, { nowMs } = {}) {
|
|
|
103
103
|
const hits = new Map(); // ip → number[]
|
|
104
104
|
export function refreshRateLimited(ip, { limit = 20, windowMs = 60_000, nowMs } = {}) {
|
|
105
105
|
const now = nowMs ?? Date.now();
|
|
106
|
+
// 顺手清掉整个窗口都没动静的来源:公网/网段扫描器每个 IP 都会占一条,永不回收
|
|
107
|
+
for (const [k, v] of hits) {
|
|
108
|
+
const last = v.length ? v[v.length - 1] : 0;
|
|
109
|
+
if (now - last >= windowMs) hits.delete(k);
|
|
110
|
+
}
|
|
106
111
|
const arr = (hits.get(ip) || []).filter((t) => now - t < windowMs);
|
|
107
112
|
arr.push(now);
|
|
108
113
|
hits.set(ip, arr);
|
package/src/store.js
CHANGED
|
@@ -59,7 +59,14 @@ export async function atomicWrite(file, content, { retries = 6 } = {}) {
|
|
|
59
59
|
path.dirname(file),
|
|
60
60
|
`.tmp-${path.basename(file)}-${crypto.randomBytes(4).toString('hex')}`
|
|
61
61
|
);
|
|
62
|
-
|
|
62
|
+
try {
|
|
63
|
+
await fs.writeFile(tmp, content, { mode: 0o600 });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// 首写就失败(ENOSPC/EPERM):清掉半截临时文件再上抛,否则数据目录里
|
|
66
|
+
// 每次失败都多一个 .tmp-* 残骸
|
|
67
|
+
await fs.unlink(tmp).catch(() => {});
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
63
70
|
const doRename = renameImpl || ((a, b) => fs.rename(a, b));
|
|
64
71
|
let transientRetries = 0;
|
|
65
72
|
try {
|
|
@@ -231,7 +238,7 @@ export function publicAccount(a) {
|
|
|
231
238
|
qoderWebDead: String(a.provider).startsWith('qoder')
|
|
232
239
|
? (meta.qoderWebDead?.at || null)
|
|
233
240
|
: null,
|
|
234
|
-
canSwitch: Boolean(meta.session?.account || meta.credentials || meta.qoderAuth || a.provider === 'catpaw' || a.provider === 'trae'),
|
|
241
|
+
canSwitch: Boolean(meta.session?.account || meta.credentials || meta.qoderAuth || a.provider === 'catpaw' || a.provider === 'trae' || a.provider === 'loomy'),
|
|
235
242
|
verified: a.verified ?? null,
|
|
236
243
|
createdAt: a.createdAt,
|
|
237
244
|
lastCheckin: a.lastCheckin,
|
package/src/tenrouter.js
CHANGED
|
@@ -44,20 +44,37 @@ const SOURCES_VERSION = 3; // 3: 新增 10Router 本机实例来源(10r)
|
|
|
44
44
|
|
|
45
45
|
export function loadConfig() {
|
|
46
46
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
return normalizeLoaded(JSON.parse(fs.readFileSync(FILE(), 'utf8')));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// 只有「文件不存在」才回默认值。EPERM/EBUSY(杀软/同步盘瞬时占用)或 JSON 损坏时
|
|
50
|
+
// 返回默认对象,下一个 withConfig 会把「默认值 + 本轮改动」整份写回——端点、key、
|
|
51
|
+
// 同步水位全部被静默抹掉,且 catpaw 的 sentTotals 清零会让历史用量整段重发。宁可报错。
|
|
52
|
+
if (e.code === 'ENOENT') return structuredClone(DEFAULT_CONFIG);
|
|
53
|
+
if (e instanceof SyntaxError) {
|
|
54
|
+
try {
|
|
55
|
+
const bak = JSON.parse(fs.readFileSync(FILE() + '.bak', 'utf8'));
|
|
56
|
+
console.error('tenrouter: 配置文件损坏,已用备份恢复 ' + FILE() + '.bak');
|
|
57
|
+
return normalizeLoaded(bak);
|
|
58
|
+
} catch { /* 备份也没有/同坏——按原错误上抛,禁止用默认值覆盖现场 */ }
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
}
|
|
60
|
+
throw new Error(`读取 ${FILE()} 失败: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// loadConfig 的归一化(旧配置 sources 快照迁移等,主文件与 .bak 同一形状)
|
|
65
|
+
function normalizeLoaded(c) {
|
|
66
|
+
const sync = { ...DEFAULT_CONFIG.sync, ...(c.sync || {}) };
|
|
67
|
+
const quotaPush = { ...DEFAULT_CONFIG.quotaPush, ...(c.quotaPush || {}) };
|
|
68
|
+
const accountSync = { ...DEFAULT_CONFIG.accountSync, ...(c.accountSync || {}) };
|
|
69
|
+
// 旧配置里的 sources 是保存时的快照,会盖掉新增来源(妙手因此被静默关掉);
|
|
70
|
+
// 只把此后新增的来源默认并入,用户手动关掉的保持关闭。
|
|
71
|
+
const savedVersion = Number(c.sync?.sourcesVersion) || 1;
|
|
72
|
+
if (savedVersion < SOURCES_VERSION) {
|
|
73
|
+
sync.sources = (Array.isArray(sync.sources) ? [...sync.sources] : [...SOURCES]);
|
|
74
|
+
for (const s of SOURCES) if (!LEGACY_SOURCES.includes(s) && !sync.sources.includes(s)) sync.sources.push(s);
|
|
75
|
+
sync.sourcesVersion = SOURCES_VERSION;
|
|
76
|
+
}
|
|
77
|
+
return { ...DEFAULT_CONFIG, ...c, sync, quotaPush, accountSync, syncState: c.syncState || {} };
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
// 配置落盘走 store.atomicWrite:Windows 上杀软/同步盘瞬时占文件会直接 EPERM,
|
|
@@ -65,7 +82,13 @@ export function loadConfig() {
|
|
|
65
82
|
async function saveConfig(c) {
|
|
66
83
|
fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
|
|
67
84
|
const { atomicWrite } = await import('./store.js');
|
|
68
|
-
|
|
85
|
+
const file = FILE();
|
|
86
|
+
// 同 saveAccounts:写前留一份 .bak,主文件损坏(断电半写/杀软截断)时 loadConfig 有路可回
|
|
87
|
+
try {
|
|
88
|
+
const existing = fs.readFileSync(file, 'utf8');
|
|
89
|
+
if (existing.trim().length > 2) await atomicWrite(file + '.bak', existing);
|
|
90
|
+
} catch { /* 主文件不存在/读不了就没有可备份的 */ }
|
|
91
|
+
await atomicWrite(file, JSON.stringify(c, null, 2));
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
// 读改写串行,避免面板保存与后台同步互相覆盖
|
package/src/tenrouterAccounts.js
CHANGED
|
@@ -22,6 +22,7 @@ export const TENROUTER_PROVIDER_MAP = {
|
|
|
22
22
|
workbuddy: 'codebuddy-cn',
|
|
23
23
|
'workbuddy-intl': 'codebuddy-intl',
|
|
24
24
|
tokenharbor: 'tokenharbor-free', // #23:会话 cookie 只喂「额度监控卡」(freeTier,不走路由);tokenharbor 钱包卡留给 sk- 运输 key
|
|
25
|
+
loomy: 'loomy',
|
|
25
26
|
// zcode → glm:等 zcode 反代就绪后补一行(同步流程已就绪,无需改代码)
|
|
26
27
|
};
|
|
27
28
|
|
package/src/traeClient.js
CHANGED
|
@@ -53,8 +53,24 @@ function seeded(uid, salt, n) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export function traeDevice(uid, storedDeviceId, attempt = 0) {
|
|
56
|
-
//
|
|
57
|
-
//
|
|
56
|
+
// 首选**登录时注册的真机号**(meta.deviceId,本机导入时从 storage.json 抓的):
|
|
57
|
+
// 服务端大概率把「号-设备」绑在登录时注册的那台上——陌生设备号打接口吃 9074
|
|
58
|
+
//(issue #31 报告者实测派生号一律被拒、同一时刻真机号可领;本机实测同现象:
|
|
59
|
+
// 导入首日派生号签到成功,之后每天 9074,重试换派生号 3 轮也全拒)。
|
|
60
|
+
// attempt>0 才走派生:多账号同机时真机号只有一个,第二个账号起服务端按设备限
|
|
61
|
+
// 每日一领会拒(issue #28,TraeTools 的「设备码切换」本质就是给每号换独立设备码),
|
|
62
|
+
// 此时轮换派生号,至少有一个号能落在服务端风控放行的窗口里。
|
|
63
|
+
if (attempt === 0 && storedDeviceId && /^\d{8,}$/.test(String(storedDeviceId))) {
|
|
64
|
+
const m = seeded(`${uid}`, 'market:0', 16);
|
|
65
|
+
m[6] = (m[6] & 0x0f) | 0x40;
|
|
66
|
+
m[8] = (m[8] & 0x3f) | 0x80;
|
|
67
|
+
const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
68
|
+
return {
|
|
69
|
+
deviceId: String(storedDeviceId),
|
|
70
|
+
marketUserId: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`,
|
|
71
|
+
sessionId: seeded(uid, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join(''),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
58
74
|
// attempt=0 为默认指纹(跨天稳定恒定);attempt>0 派生结果互异但同样确定(同 attempt 重放一致)。
|
|
59
75
|
const d = seeded(`${uid}`, `devid:${attempt}`, 15).map((b) => String(48 + (b % 10)));
|
|
60
76
|
const m = seeded(`${uid}`, `market:${attempt}`, 16);
|
|
@@ -62,9 +78,6 @@ export function traeDevice(uid, storedDeviceId, attempt = 0) {
|
|
|
62
78
|
m[8] = (m[8] & 0x3f) | 0x80;
|
|
63
79
|
const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
64
80
|
return {
|
|
65
|
-
// x-device-id 按 uid 派生(每号独立且稳定):本机导入的 N 个账号若共用客户端自报的
|
|
66
|
-
// 同一设备号,服务端按设备限每日一领——第二个账号起签到必吃 9074(issue #28,
|
|
67
|
-
// TraeTools 的「设备码切换」本质就是给每个账号换独立设备码)。这里不落 storedDeviceId。
|
|
68
81
|
deviceId: d.join(''),
|
|
69
82
|
marketUserId: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`,
|
|
70
83
|
sessionId: seeded(uid, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join(''),
|
package/src/traeGateway.js
CHANGED
|
@@ -45,6 +45,21 @@ export async function setGatewayEnabled(v) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** 丢弃错误响应的 body:不读不取消的 undici body 会把连接钉住到服务端关闭或 ~300s 超时
|
|
49
|
+
* (429 风暴时每个失败尝试泄漏一条 socket)。cancel 即可,错误体不需要内容。 */
|
|
50
|
+
function drainErr(res) {
|
|
51
|
+
try { res?.body?.cancel?.()?.catch?.(() => {}); } catch { /* 已读/已取消 */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 账号被删除时清掉它在各轮换 Map 里的残留(daemon DELETE /api/accounts/:id 调用) */
|
|
55
|
+
export function forgetAccount(id) {
|
|
56
|
+
cooling.delete(id);
|
|
57
|
+
coolingStreak.delete(id);
|
|
58
|
+
dead.delete(id);
|
|
59
|
+
weights.delete(id);
|
|
60
|
+
swrr.delete(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
// ── 账号轮换状态(进程内) ──
|
|
49
64
|
|
|
50
65
|
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
@@ -53,6 +68,8 @@ const coolingStreak = new Map();
|
|
|
53
68
|
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/导入,自动复活)
|
|
54
69
|
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }
|
|
55
70
|
const WEIGHT_TTL_MS = 10 * 60_000; // 超过 10 分钟重查额度,让积分变化反映到分流上
|
|
71
|
+
const WEIGHT_REFRESH_CAP_MS = 2_000; // 请求路径等新权重的上限;超时先用旧快照,刷新转后台
|
|
72
|
+
const weightInflight = new Map(); // accountId → 刷新中 Promise(并发请求共用一次查询)
|
|
56
73
|
|
|
57
74
|
export const stats = {
|
|
58
75
|
lastCallAt: null,
|
|
@@ -107,6 +124,7 @@ export function __resetForTests() {
|
|
|
107
124
|
coolingStreak.clear();
|
|
108
125
|
dead.clear();
|
|
109
126
|
weights.clear();
|
|
127
|
+
weightInflight.clear();
|
|
110
128
|
swrr.clear();
|
|
111
129
|
stats.lastCallAt = null;
|
|
112
130
|
stats.calls = 0;
|
|
@@ -116,15 +134,40 @@ export function __resetForTests() {
|
|
|
116
134
|
async function accountWeight(a) {
|
|
117
135
|
const hit = weights.get(a.id);
|
|
118
136
|
if (hit && Date.now() - hit.at < WEIGHT_TTL_MS) return hit.w;
|
|
119
|
-
|
|
137
|
+
const fallback = hit?.w ?? MIN_WEIGHT;
|
|
138
|
+
// 请求路径只等一个很短的上限:额度接口健康时(通常亚秒)照常拿到新权重精准分流;
|
|
139
|
+
// 上游黑洞时最多等这么久就先用旧快照——刷新仍在后台继续,下一个请求就有新值。
|
|
140
|
+
// 此前是完整等待额度查询(两个 POST,各 15s 上限)且逐号串行,一个黑洞的上游
|
|
141
|
+
// 能让权重过期后的第一个补全请求卡 1-2 分钟
|
|
142
|
+
const refresh = weightRefresh(a);
|
|
143
|
+
let timer;
|
|
144
|
+
const capped = new Promise((r) => { timer = setTimeout(() => r(fallback), WEIGHT_REFRESH_CAP_MS); });
|
|
120
145
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// 额度查询失败保留上次快照权重(网络抖动不该把主力踢成保底权重)
|
|
146
|
+
return await Promise.race([refresh, capped]);
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
125
149
|
}
|
|
126
|
-
|
|
127
|
-
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 去重后的权重刷新:并发请求共用同一次额度查询;查询失败保留旧快照权重(时间戳仍刷新,避免每请求都撞同一坏接口) */
|
|
153
|
+
function weightRefresh(a) {
|
|
154
|
+
const existing = weightInflight.get(a.id);
|
|
155
|
+
if (existing) return existing;
|
|
156
|
+
const run = (async () => {
|
|
157
|
+
const hit = weights.get(a.id);
|
|
158
|
+
let w = hit?.w ?? MIN_WEIGHT;
|
|
159
|
+
try {
|
|
160
|
+
const q = await fetchTraeQuota(a);
|
|
161
|
+
w = Math.max(MIN_WEIGHT, Number(q?.remaining) || 0);
|
|
162
|
+
} catch {
|
|
163
|
+
// 额度查询失败保留上次快照权重(网络抖动不该把主力踢成保底权重)
|
|
164
|
+
}
|
|
165
|
+
weights.set(a.id, { w, at: Date.now() });
|
|
166
|
+
return w;
|
|
167
|
+
})();
|
|
168
|
+
weightInflight.set(a.id, run);
|
|
169
|
+
run.finally(() => { if (weightInflight.get(a.id) === run) weightInflight.delete(a.id); }).catch(() => {});
|
|
170
|
+
return run;
|
|
128
171
|
}
|
|
129
172
|
|
|
130
173
|
const swrr = new Map();
|
|
@@ -133,16 +176,20 @@ function ensureSwrr(id) { if (!swrr.has(id)) swrr.set(id, 0); }
|
|
|
133
176
|
async function rotationQueue() {
|
|
134
177
|
const all = (await loadAccounts()).filter((a) => a.provider === 'trae');
|
|
135
178
|
const now = Date.now();
|
|
136
|
-
const
|
|
179
|
+
const candidates = [];
|
|
137
180
|
for (const a of all) {
|
|
138
181
|
if (isDead(a)) continue;
|
|
139
182
|
if ((cooling.get(a.id) || 0) > now) continue;
|
|
140
183
|
if (!a.token) continue;
|
|
141
184
|
ensureSwrr(a.id);
|
|
142
|
-
|
|
143
|
-
swrr.set(a.id, (swrr.get(a.id) || 0) + w);
|
|
144
|
-
ready.push({ account: a, weight: w });
|
|
185
|
+
candidates.push(a);
|
|
145
186
|
}
|
|
187
|
+
// 权重并行取(各自 2s 封顶),不再逐号串行等——N 个账号的最坏停顿从 N×上限变成 1 个上限
|
|
188
|
+
const ws = await Promise.all(candidates.map((a) => accountWeight(a)));
|
|
189
|
+
const ready = candidates.map((a, i) => {
|
|
190
|
+
swrr.set(a.id, (swrr.get(a.id) || 0) + ws[i]);
|
|
191
|
+
return { account: a, weight: ws[i] };
|
|
192
|
+
});
|
|
146
193
|
if (!ready.length) return [];
|
|
147
194
|
|
|
148
195
|
let pick = ready[0];
|
|
@@ -323,6 +370,9 @@ export async function handleGateway(req, res) {
|
|
|
323
370
|
const attempted = [];
|
|
324
371
|
const clientAbort = new AbortController();
|
|
325
372
|
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
373
|
+
// 优雅断开(客户端 fetch abort 在上游响应前)只触发 res 'close',不触发 req 'close'
|
|
374
|
+
// ——少这一个钩子,取消后还会真的创建 SOLO 会话烧掉额度
|
|
375
|
+
res.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
326
376
|
|
|
327
377
|
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length); i++) {
|
|
328
378
|
const account = queue[i];
|
|
@@ -414,6 +464,7 @@ export async function handleGateway(req, res) {
|
|
|
414
464
|
}
|
|
415
465
|
|
|
416
466
|
if (sessionRes.status === 401) {
|
|
467
|
+
drainErr(sessionRes);
|
|
417
468
|
markDead(account);
|
|
418
469
|
attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
|
|
419
470
|
logger.info('TRAE-GW', '{label} 凭据失效,拉黑', { label });
|
|
@@ -421,6 +472,7 @@ export async function handleGateway(req, res) {
|
|
|
421
472
|
}
|
|
422
473
|
|
|
423
474
|
if (sessionRes.status === 429) {
|
|
475
|
+
drainErr(sessionRes);
|
|
424
476
|
const ms = markRateLimited(account.id);
|
|
425
477
|
const dur = formatDurationMs(ms);
|
|
426
478
|
const streak = coolingStreak.get(account.id);
|
|
@@ -431,6 +483,7 @@ export async function handleGateway(req, res) {
|
|
|
431
483
|
|
|
432
484
|
const sessionData = await sessionRes.json().catch(() => null);
|
|
433
485
|
if (!sessionRes.ok || sessionData?.code !== 0 || !sessionData?.data?.chat_session_id) {
|
|
486
|
+
drainErr(sessionRes);
|
|
434
487
|
const errMsg = sessionData?.message || `HTTP ${sessionRes.status}`;
|
|
435
488
|
attempted.push({ account: label, ok: false, error: `会话创建失败: ${errMsg}` });
|
|
436
489
|
continue;
|
|
@@ -439,11 +492,13 @@ export async function handleGateway(req, res) {
|
|
|
439
492
|
const chatSessionId = sessionData.data.chat_session_id;
|
|
440
493
|
const messageId = sessionData.data.message_id;
|
|
441
494
|
|
|
442
|
-
// 清理 session
|
|
495
|
+
// 清理 session 的辅助函数。不挂 clientAbort 信号——客户端取消时恰恰要执行这里的
|
|
496
|
+
// 清理;DELETE 也走 fetchJsonRace 限时,裸 fetch 黑洞时会吊住 socket 约 5 分钟
|
|
443
497
|
const cleanupSession = () => {
|
|
444
|
-
|
|
498
|
+
fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions/${chatSessionId}`, {
|
|
445
499
|
method: 'DELETE',
|
|
446
500
|
headers: makeHeaders(token),
|
|
501
|
+
timeoutMs: 10_000,
|
|
447
502
|
}).catch(() => {});
|
|
448
503
|
};
|
|
449
504
|
|
|
@@ -471,6 +526,7 @@ export async function handleGateway(req, res) {
|
|
|
471
526
|
}
|
|
472
527
|
|
|
473
528
|
if (!eventsRes.ok || !eventsRes.body) {
|
|
529
|
+
drainErr(eventsRes);
|
|
474
530
|
cleanupSession();
|
|
475
531
|
// 事件流被 429(会话建好了、拉流被限流)同样按阶梯冷却——不判的话这个号
|
|
476
532
|
// 下一轮会话创建仍从 30 秒档起算,继续撞限流
|
package/src/transfer.js
CHANGED
|
@@ -111,6 +111,9 @@ export function buildExportPayload(accounts, { provider, product } = {}) {
|
|
|
111
111
|
refreshToken: a.refreshToken || null,
|
|
112
112
|
expiresAt: a.expiresAt || null,
|
|
113
113
|
createdAt: a.createdAt,
|
|
114
|
+
// 10Router 原生 Loomy 行是 authType:'apikey'(apiKey=session,sms-login 路由同款);
|
|
115
|
+
// 不带这个字段,10R 导入端按默认 oauth 落库、不写 apiKey 字段,loomy 运行时读不到凭据
|
|
116
|
+
...(a.provider === 'loomy' ? { authType: 'apikey' } : {}),
|
|
114
117
|
providerSpecificData: {
|
|
115
118
|
authMethod: a.token.startsWith('pt-') ? 'pat' : 'device',
|
|
116
119
|
userId: a.uid || null,
|
package/src/usageSync.js
CHANGED
|
@@ -287,7 +287,17 @@ async function collectCatpaw(accounts, modelLabel, sentTotals = {}) {
|
|
|
287
287
|
if (failed && !out.length) throw lastErr;
|
|
288
288
|
if (failed) notes.push(`${failed} 个妙手账号用量拉取失败:${lastErr && lastErr.message}`);
|
|
289
289
|
out.sort((x, y) => (x.timestamp < y.timestamp ? -1 : 1));
|
|
290
|
-
|
|
290
|
+
// 水位只服务 400 天回溯窗的差量记账:窗口外的桶永远不会再被读到,
|
|
291
|
+
// 留着只是让 tenrouter.json 每小时白白重写几千条死账(多留 10 天余量,
|
|
292
|
+
// 不剪回溯窗边界附近的活水位——剪早了那桶会被当新差量整段重发)
|
|
293
|
+
const minDate = localDateKey(new Date(now - 410 * 86400e3));
|
|
294
|
+
const merged = {};
|
|
295
|
+
for (const [key, v] of Object.entries({ ...sentTotals, ...nextSent })) {
|
|
296
|
+
const date = String(key).split('|')[1];
|
|
297
|
+
if (date && date < minDate) continue;
|
|
298
|
+
merged[key] = v;
|
|
299
|
+
}
|
|
300
|
+
return { entries: out, notes, nextSentTotals: merged };
|
|
291
301
|
}
|
|
292
302
|
|
|
293
303
|
/** 本机 10Router/9Router 的 usageHistory 行 → 导入行(同 10router-sync 插件 --source 10r)。
|
package/src/zcodeAutoClaim.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { logger } from './logger.js';
|
|
15
15
|
import { t } from './i18n.js';
|
|
16
|
-
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig } from './zcodeClient.js';
|
|
16
|
+
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, ZCODE_KNOWN_CLAIM_PLANS } from './zcodeClient.js';
|
|
17
17
|
import { withAccounts } from './store.js';
|
|
18
18
|
|
|
19
19
|
// 额满(1005)时消费服务端 nextAt(plan.ends_at):
|
|
@@ -33,6 +33,9 @@ export function setZcodeCaptchaProvider(fn) { captchaProvider = typeof fn === 'f
|
|
|
33
33
|
export function hasZcodeCaptchaProvider() { return Boolean(captchaProvider); }
|
|
34
34
|
export function getZcodeCaptchaProvider() { return captchaProvider; }
|
|
35
35
|
|
|
36
|
+
/** 账号被删除时清掉额满暂休缓存(daemon DELETE /api/accounts/:id 调用;落盘份随账号一起删) */
|
|
37
|
+
export function forgetAccount(id) { snoozeUntil.delete(id); }
|
|
38
|
+
|
|
36
39
|
const grantsText = (plan) => (plan.grants && plan.grants.length ? plan.grants.join(';') : '');
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -55,7 +58,12 @@ export async function zcodeAutoClaim(account) {
|
|
|
55
58
|
logger.debug('ZCODE-CLAIM', '{label} 活动查询失败:{err}', { label, err: e.message });
|
|
56
59
|
return null;
|
|
57
60
|
}
|
|
58
|
-
if (!plans.length)
|
|
61
|
+
if (!plans.length) {
|
|
62
|
+
// preview 为空 ≠ 无事可做:服务端偶尔把活动从列表里藏起来(2026-10-30 WEEKEND BUILD
|
|
63
|
+
// 实测 preview 返回空但 claim 按 planId 直接可领)。把已知清单合成伪 plan 走同一条
|
|
64
|
+
// 领取链路:1001/1002(不存在/已结束)在 catch 里静默跳过,不进卡片。
|
|
65
|
+
plans = ZCODE_KNOWN_CLAIM_PLANS.map((k) => ({ planId: k.planId, name: k.name || k.planId, description: '', priority: 0, grants: [], known: true }));
|
|
66
|
+
}
|
|
59
67
|
|
|
60
68
|
const claims = [];
|
|
61
69
|
let anySuccess = false;
|
|
@@ -84,6 +92,9 @@ export async function zcodeAutoClaim(account) {
|
|
|
84
92
|
snoozeAt = Math.max(snoozeAt, Number(new Date(e.nextAt)) || 0);
|
|
85
93
|
}
|
|
86
94
|
if (e.code !== 3007 && e.code !== 3001) {
|
|
95
|
+
// 1001/1002 = 已知清单兜底里的活动不存在/已结束:静默跳过(不进卡片、不算失败)。
|
|
96
|
+
// 只对 known 兜底项生效——preview 列表里的真实活动若返回 1002 仍要如实记失败
|
|
97
|
+
if ((e.code === 1001 || e.code === 1002) && plan.known === true) continue;
|
|
87
98
|
claims.push({ plan: planName, ok: false, message: e.message });
|
|
88
99
|
logger.warn('ZCODE-CLAIM', '{label} 领取「{plan}」失败:{err}', { label, plan: planName, err: e.message });
|
|
89
100
|
continue;
|
|
@@ -127,6 +138,8 @@ export async function zcodeAutoClaim(account) {
|
|
|
127
138
|
} catch {}
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
// 已知清单全部不可领(1001/1002 静默跳过)时 claims 为空:无事发生,不留痕迹
|
|
142
|
+
if (!claims.length) return null;
|
|
130
143
|
const names = claims.filter((c) => c.ok && !c.already).map((c) => c.plan);
|
|
131
144
|
const status = names.length ? 'checked-in'
|
|
132
145
|
: claims.every((c) => c.already) ? 'already'
|
package/src/zcodeClient.js
CHANGED
|
@@ -21,6 +21,7 @@ import https from 'node:https';
|
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import tls from 'node:tls';
|
|
24
|
+
import { PassThrough } from 'node:stream';
|
|
24
25
|
import { execFile } from 'node:child_process';
|
|
25
26
|
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
26
27
|
import { defaultSecret, safeDecrypt } from './zcrypto.js';
|
|
@@ -237,10 +238,20 @@ async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, t
|
|
|
237
238
|
}
|
|
238
239
|
const done = (fn, arg) => { clearTimeout(deadline); signal?.removeEventListener('abort', onAbort); fn(arg); };
|
|
239
240
|
const req = (isHttps ? https : http).request(requestOpts, (res) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
res.
|
|
241
|
+
// 流式转发:经代理的响应(含网关 SSE 补全)逐块到达,而不是整包收完才 resolve——
|
|
242
|
+
// 此前代理优先模式下客户端在整个生成期间一个字节都收不到,首 token 超时必然误报
|
|
243
|
+
const pass = new PassThrough();
|
|
244
|
+
res.pipe(pass);
|
|
245
|
+
res.on('error', (e) => pass.destroy(e));
|
|
246
|
+
// close 在正常收尾与异常中断时都会触发;complete=false = 没收完就被掐
|
|
247
|
+
// (abort/超时销毁 socket 后 res 不一定发 'error')。deadline 也在这里才清:
|
|
248
|
+
// 墙钟上限必须继续管住流式 body,与直连路径(AbortSignal 管到 body 结束)同口径
|
|
249
|
+
res.on('close', () => {
|
|
250
|
+
clearTimeout(deadline);
|
|
251
|
+
signal?.removeEventListener('abort', onAbort);
|
|
252
|
+
if (!res.complete) pass.destroy(new Error('连接中断'));
|
|
253
|
+
});
|
|
254
|
+
resolve(new Response(pass, { status: res.statusCode, headers: res.headers }));
|
|
244
255
|
});
|
|
245
256
|
req.on('error', (e) => done(reject, e));
|
|
246
257
|
req.on('timeout', () => req.destroy(new Error('请求超时')));
|
|
@@ -332,9 +343,21 @@ export async function fetchJsonRace(url, { method = 'GET', headers = {}, body =
|
|
|
332
343
|
if (errs.length > 1) {
|
|
333
344
|
const detail = errs.map(([w, e]) => `${w === 'direct' ? '直连' : '代理'}: ${e?.message || e}`).join(';');
|
|
334
345
|
const last = errs[errs.length - 1][1];
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
346
|
+
// 优先在最后一条错误上改写 message(保留 err.code / instanceof,调用方判定不受影响)。
|
|
347
|
+
// 但 AbortSignal.timeout / fetch 的 abort reason 是 DOMException——message 只有 getter,
|
|
348
|
+
// 赋值会抛 TypeError 把真实网络错误整个吞掉(2026-10-30 ZCode 活动查询报
|
|
349
|
+
// 「Cannot set property message」即此,真因被掩盖)。descriptor 不可写时退回包一层
|
|
350
|
+
// 普通 Error,原始错误挂 cause、code 透传。
|
|
351
|
+
try {
|
|
352
|
+
last.message = detail;
|
|
353
|
+
throw last;
|
|
354
|
+
} catch (e) {
|
|
355
|
+
if (e === last) throw last; // 赋值成功,原样上抛
|
|
356
|
+
const wrapped = new Error(detail); // 赋值抛错(getter-only message)
|
|
357
|
+
wrapped.cause = last;
|
|
358
|
+
if (last?.code) wrapped.code = last.code;
|
|
359
|
+
throw wrapped;
|
|
360
|
+
}
|
|
338
361
|
}
|
|
339
362
|
throw errs[0]?.[1] || new Error('fetch failed');
|
|
340
363
|
}
|
|
@@ -353,6 +376,16 @@ const CLAIM_FAIL = {
|
|
|
353
376
|
|
|
354
377
|
const platform = () => `${process.platform}-${process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : process.arch}`;
|
|
355
378
|
|
|
379
|
+
/**
|
|
380
|
+
* 已知活动兜底清单:服务端偶尔把活动从 billing/preview 里藏起来(2026-10-30 WEEKEND BUILD
|
|
381
|
+
* 实测 preview 返回空 plans,但 claim 按 planId 直接可领、免验证码)。preview 查不到时
|
|
382
|
+
* 逐个试这里的 planId:1001/1002(不存在/已结束)当没有,1003 按已领口径,0 即领取成功。
|
|
383
|
+
* 活动过期后条目留在表里无害(claim 返回 1002 跳过);新活动出现时在此追加一行。
|
|
384
|
+
*/
|
|
385
|
+
export const ZCODE_KNOWN_CLAIM_PLANS = [
|
|
386
|
+
{ planId: 'zcode-v3-start-plan-1030-wk', name: 'ZCODE WEEKEND BUILD' },
|
|
387
|
+
];
|
|
388
|
+
|
|
356
389
|
export async function zaiHeaders(token, deviceMid) {
|
|
357
390
|
const ver = await zcodeAppVersion();
|
|
358
391
|
return {
|
package/src/zcodeGateway.js
CHANGED
|
@@ -108,6 +108,18 @@ function markRateLimited(id) {
|
|
|
108
108
|
/** 账号补全成功:清零连续 429 计数(下次 429 重新从 30 秒起算) */
|
|
109
109
|
function resetCoolingStreak(id) { coolingStreak.delete(id); }
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 账号被删除时清掉它在各轮换 Map 里的残留(daemon DELETE /api/accounts/:id 调用)。
|
|
113
|
+
* 账号 id 每次导入都是新 UUID,删了不清理的话这些条目永远不会被任何人再触碰。
|
|
114
|
+
*/
|
|
115
|
+
export function forgetAccount(id) {
|
|
116
|
+
cooling.delete(id);
|
|
117
|
+
coolingStreak.delete(id);
|
|
118
|
+
dead.delete(id);
|
|
119
|
+
quotaCache.delete(id);
|
|
120
|
+
for (const [k, v] of sessionSticky) if (v?.accountId === id) sessionSticky.delete(k);
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
/**
|
|
112
124
|
* 测试专用:把某账号的冷却截止拨到指定时间戳(0 = 立即到期,用于快进冷却窗口)。
|
|
113
125
|
* 生产不走这里——冷却窗口只能自然到期。
|
|
@@ -402,6 +414,12 @@ function stickyFor(sessionId) {
|
|
|
402
414
|
/** 会话粘住的号仍可用才粘;否则落到轮转队首,由调用方重新绑定 */
|
|
403
415
|
function touchSticky(sessionId, accountId) {
|
|
404
416
|
if (!sessionId) return;
|
|
417
|
+
// 每段对话一个新 session id,安静会话的条目只有等它自己再被查到才会过期——
|
|
418
|
+
// 长期运行的网关上单调增长。超阈值时整表清一遍过期项。
|
|
419
|
+
if (sessionSticky.size > 500) {
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
for (const [k, v] of sessionSticky) if (now - (v?.at || 0) >= SESSION_STICKY_TTL_MS) sessionSticky.delete(k);
|
|
422
|
+
}
|
|
405
423
|
sessionSticky.set(sessionId, { accountId, at: Date.now() });
|
|
406
424
|
}
|
|
407
425
|
/** 从上游 JSON 信封里取业务码;字符串数字("3007")也认,取不到返回 null */
|
|
@@ -563,6 +581,9 @@ export async function handleGateway(req, res) {
|
|
|
563
581
|
// (MaxListenersExceededWarning + 泄漏)。
|
|
564
582
|
const clientAbort = new AbortController();
|
|
565
583
|
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
584
|
+
// 优雅断开(客户端 fetch abort 在上游响应前)只触发 res 'close',不触发 req 'close'
|
|
585
|
+
// ——少这一个钩子,Ctrl+C 后整个重试循环(含验证码求解、账号轮换)还在为已死的客户端空跑
|
|
586
|
+
res.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
566
587
|
// 首发不带验证码(客户端真实流量多数直过);上游 3007/403 时求解并在此后的
|
|
567
588
|
// 尝试上挂新 token(zcode-api 同策略)。provider 为 null(NAS/CLI)时首过路径依然可用。
|
|
568
589
|
let captcha = null;
|
|
@@ -680,8 +701,17 @@ export async function handleGateway(req, res) {
|
|
|
680
701
|
}
|
|
681
702
|
}
|
|
682
703
|
} catch (e) {
|
|
683
|
-
|
|
684
|
-
|
|
704
|
+
if (clientAbort.signal.aborted) {
|
|
705
|
+
// 客户端主动断开(点停止/换一句)就会在这里结束——属正常收尾,不是故障,降为 debug
|
|
706
|
+
logger.debug('ZCODE-GW', '流式转发中断:{err}', { err: e.message });
|
|
707
|
+
} else {
|
|
708
|
+
// 上游中途断了:直接 res.end() 是无 message_stop 的裸 EOF,客户端只会报
|
|
709
|
+
// 「流意外结束」——按 Anthropic 协议补一帧 error 事件,把原因带出去
|
|
710
|
+
logger.warn('ZCODE-GW', '上游流中断:{err}', { err: e.message });
|
|
711
|
+
try {
|
|
712
|
+
res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'api_error', message: `上游流中断:${e.message}` } })}\n\n`);
|
|
713
|
+
} catch { /* 客户端已不在 */ }
|
|
714
|
+
}
|
|
685
715
|
}
|
|
686
716
|
res.end();
|
|
687
717
|
return true;
|