creditdaddy 0.8.0
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/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/creditdaddy.js +187 -0
- package/package.json +28 -0
- package/src/accounts.js +143 -0
- package/src/authDevice.js +205 -0
- package/src/checkin.js +200 -0
- package/src/constants.js +79 -0
- package/src/daemon.js +585 -0
- package/src/localDetect.js +147 -0
- package/src/logger.js +27 -0
- package/src/panel.html +1785 -0
- package/src/providers.js +75 -0
- package/src/qoderApp.js +330 -0
- package/src/qoderClient.js +199 -0
- package/src/qoderUmid.js +177 -0
- package/src/store.js +187 -0
- package/src/tenrouter.js +221 -0
- package/src/transfer.js +141 -0
- package/src/usageSync.js +319 -0
- package/src/workbuddyAuth.js +126 -0
- package/src/workbuddyClient.js +257 -0
- package/src/workbuddyLocal.js +133 -0
- package/src/zcodeAuth.js +131 -0
- package/src/zcodeClient.js +495 -0
- package/src/zcodeLocal.js +217 -0
- package/src/zcrypto.js +160 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器登录(统一入口):面板发起 → 浏览器 / 隐私窗口里授权 → 面板轮询 → 自动入库。
|
|
3
|
+
*
|
|
4
|
+
* qoder / qoder-cn Qoder 设备码登录(PKCE + nonce,与官方 qodercli 同流程,移植自 10router)
|
|
5
|
+
* workbuddy / workbuddy-intl WorkBuddy / CodeBuddy 登录(state 轮询,见 workbuddyAuth.js)
|
|
6
|
+
* zcode-bigmodel / zcode-zai ZCode 登录(客户端 CLI 轮询流程,见 zcodeAuth.js)
|
|
7
|
+
*
|
|
8
|
+
* Qoder 流程:
|
|
9
|
+
* 1. 本地生成 PKCE 对 (verifier/challenge-S256) + nonce + machine_id
|
|
10
|
+
* 2. 打开 verificationUriComplete(qoder.com/device/selectAccounts?challenge=...)选择账号授权
|
|
11
|
+
* 3. 带 nonce+verifier 轮询 openapi.qoder.sh/api/v1/deviceToken/poll,202/404=等待,200=拿到 dt- token
|
|
12
|
+
* token 约 30 天有效;上游 refresh 对该流程 403,过期后重新走一遍登录即可。
|
|
13
|
+
*
|
|
14
|
+
* 会话只存在内存里(授权是短时动作,重启重来即可);面板每 2s 轮询一次,
|
|
15
|
+
* 各流程按自己的上游间隔节流,未到时间直接回 pending。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import {
|
|
20
|
+
OPENAPI_BASE, CN_OPENAPI_BASE,
|
|
21
|
+
LOGIN_URL, CN_LOGIN_URL,
|
|
22
|
+
USERINFO_PATH, PROVIDER_LABEL,
|
|
23
|
+
} from './constants.js';
|
|
24
|
+
import { publicAccount } from './store.js';
|
|
25
|
+
import { addAccount } from './accounts.js';
|
|
26
|
+
import { logger } from './logger.js';
|
|
27
|
+
import { startWorkbuddyLogin, pollWorkbuddyLogin } from './workbuddyAuth.js';
|
|
28
|
+
import { startZcodeLogin, pollZcodeLogin } from './zcodeAuth.js';
|
|
29
|
+
|
|
30
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
31
|
+
const QODER_TTL_MS = 5 * 60 * 1000; // Qoder 授权链接 5 分钟有效
|
|
32
|
+
const QODER_POLL_MS = 2000;
|
|
33
|
+
|
|
34
|
+
const sessions = new Map();
|
|
35
|
+
|
|
36
|
+
function base64Url(buf) {
|
|
37
|
+
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort('timeout'), FETCH_TIMEOUT_MS);
|
|
43
|
+
try {
|
|
44
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
45
|
+
} finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Qoder ──
|
|
51
|
+
export function endpointsFor(provider) {
|
|
52
|
+
const cn = provider === 'qoder-cn';
|
|
53
|
+
return {
|
|
54
|
+
deviceTokenUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + '/api/v1/deviceToken/poll',
|
|
55
|
+
loginUrl: cn ? CN_LOGIN_URL : LOGIN_URL,
|
|
56
|
+
userInfoUrl: (cn ? CN_OPENAPI_BASE : OPENAPI_BASE) + USERINFO_PATH,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function startQoderLogin(provider) {
|
|
61
|
+
const ep = endpointsFor(provider);
|
|
62
|
+
const verifier = base64Url(crypto.randomBytes(32));
|
|
63
|
+
const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest());
|
|
64
|
+
const nonce = crypto.randomUUID();
|
|
65
|
+
const params = new URLSearchParams({ challenge, challenge_method: 'S256', machine_id: crypto.randomUUID(), nonce });
|
|
66
|
+
return {
|
|
67
|
+
url: ep.loginUrl + '?' + params.toString(),
|
|
68
|
+
expiresInMs: QODER_TTL_MS,
|
|
69
|
+
intervalMs: QODER_POLL_MS,
|
|
70
|
+
data: { provider, nonce, verifier },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 用户资料(best-effort,失败不阻断登录) */
|
|
75
|
+
async function fetchQoderProfile(provider, accessToken) {
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetchWithTimeout(endpointsFor(provider).userInfoUrl, {
|
|
78
|
+
headers: { Authorization: 'Bearer ' + accessToken, Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' },
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) return { name: null, email: null };
|
|
81
|
+
const b = await res.json();
|
|
82
|
+
return { name: (b.name || b.username || '').trim() || null, email: (b.email || '').trim() || null };
|
|
83
|
+
} catch { return { name: null, email: null }; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function pollQoderLogin(data) {
|
|
87
|
+
const url = endpointsFor(data.provider).deviceTokenUrl
|
|
88
|
+
+ '?nonce=' + encodeURIComponent(data.nonce)
|
|
89
|
+
+ '&verifier=' + encodeURIComponent(data.verifier)
|
|
90
|
+
+ '&challenge_method=S256';
|
|
91
|
+
const res = await fetchWithTimeout(url, { headers: { Accept: 'application/json', 'User-Agent': 'Go-http-client/2.0' } });
|
|
92
|
+
if (res.status === 202 || res.status === 404) return { status: 'pending' };
|
|
93
|
+
|
|
94
|
+
const text = await res.text();
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
let msg = '轮询失败 HTTP ' + res.status;
|
|
97
|
+
try { const b = JSON.parse(text); if (b.message) msg += ':' + b.message; } catch {}
|
|
98
|
+
throw new Error(msg);
|
|
99
|
+
}
|
|
100
|
+
let body;
|
|
101
|
+
try { body = JSON.parse(text); } catch (e) { throw new Error('轮询返回非 JSON:' + e.message); }
|
|
102
|
+
if (!body.token) throw new Error('轮询返回 200 但没有 token(上游结构可能变化)');
|
|
103
|
+
|
|
104
|
+
const profile = await fetchQoderProfile(data.provider, body.token);
|
|
105
|
+
return {
|
|
106
|
+
status: 'ok',
|
|
107
|
+
input: {
|
|
108
|
+
provider: data.provider,
|
|
109
|
+
token: body.token,
|
|
110
|
+
name: profile.name || profile.email || null,
|
|
111
|
+
email: profile.email,
|
|
112
|
+
uid: body.user_id || null,
|
|
113
|
+
refreshToken: body.refresh_token || body.refreshToken || null,
|
|
114
|
+
expiresAt: body.expires_at || (body.expires_in ? Date.now() + Number(body.expires_in) * 1000 : null),
|
|
115
|
+
source: 'device',
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 统一入口 ──
|
|
121
|
+
const FLOWS = {
|
|
122
|
+
qoder: { start: startQoderLogin, poll: pollQoderLogin },
|
|
123
|
+
'qoder-cn': { start: startQoderLogin, poll: pollQoderLogin },
|
|
124
|
+
workbuddy: { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
|
|
125
|
+
'workbuddy-intl': { start: startWorkbuddyLogin, poll: pollWorkbuddyLogin },
|
|
126
|
+
'zcode-bigmodel': { start: startZcodeLogin, poll: pollZcodeLogin },
|
|
127
|
+
'zcode-zai': { start: startZcodeLogin, poll: pollZcodeLogin },
|
|
128
|
+
};
|
|
129
|
+
export const LOGIN_KINDS = Object.keys(FLOWS);
|
|
130
|
+
const KIND_LABEL = { 'zcode-bigmodel': 'ZCode(BigModel)', 'zcode-zai': 'ZCode(Z.ai)' };
|
|
131
|
+
const labelOf = (kind) => KIND_LABEL[kind] || PROVIDER_LABEL[kind] || kind;
|
|
132
|
+
|
|
133
|
+
function gcSessions() {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
for (const [k, s] of sessions) if (s.expiresAt < now || s.done) sessions.delete(k);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 创建一次登录会话,返回浏览器授权地址 */
|
|
139
|
+
export async function startDeviceFlow(kind) {
|
|
140
|
+
const flow = FLOWS[kind];
|
|
141
|
+
if (!flow) throw new Error('不支持的登录类型:' + kind);
|
|
142
|
+
gcSessions();
|
|
143
|
+
const r = await flow.start(kind);
|
|
144
|
+
const sessionId = crypto.randomUUID();
|
|
145
|
+
sessions.set(sessionId, {
|
|
146
|
+
kind, data: r.data,
|
|
147
|
+
expiresAt: Date.now() + r.expiresInMs,
|
|
148
|
+
intervalMs: r.intervalMs, nextPollAt: 0,
|
|
149
|
+
done: false, busy: false,
|
|
150
|
+
});
|
|
151
|
+
logger.info('DEVICE', '发起浏览器登录(' + labelOf(kind) + ')');
|
|
152
|
+
return {
|
|
153
|
+
sessionId,
|
|
154
|
+
url: r.url,
|
|
155
|
+
expiresIn: Math.floor(r.expiresInMs / 1000),
|
|
156
|
+
interval: Math.max(1, Math.floor(r.intervalMs / 1000)),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 面板侧轮询入口。返回:
|
|
162
|
+
* { status: 'pending' } 继续等
|
|
163
|
+
* { status: 'ok', account, already } 成功(already=账号已存在)
|
|
164
|
+
* { status: 'expired' } 会话过期,重新发起
|
|
165
|
+
* { status: 'failed', error } 终态失败
|
|
166
|
+
*/
|
|
167
|
+
export async function pollDeviceFlow(sessionId) {
|
|
168
|
+
gcSessions();
|
|
169
|
+
const session = sessions.get(sessionId);
|
|
170
|
+
if (!session) return { status: 'expired' };
|
|
171
|
+
if (Date.now() > session.expiresAt) {
|
|
172
|
+
sessions.delete(sessionId);
|
|
173
|
+
return { status: 'expired' };
|
|
174
|
+
}
|
|
175
|
+
// 节流:未到上游轮询间隔、或上一轮还在进行,直接回 pending
|
|
176
|
+
if (session.busy || Date.now() < session.nextPollAt) return { status: 'pending' };
|
|
177
|
+
session.busy = true;
|
|
178
|
+
session.nextPollAt = Date.now() + session.intervalMs;
|
|
179
|
+
|
|
180
|
+
let result;
|
|
181
|
+
try {
|
|
182
|
+
result = await FLOWS[session.kind].poll(session.data);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
sessions.delete(sessionId);
|
|
185
|
+
logger.warn('DEVICE', labelOf(session.kind) + ' 登录失败:' + err.message);
|
|
186
|
+
return { status: 'failed', error: err.message };
|
|
187
|
+
} finally {
|
|
188
|
+
session.busy = false;
|
|
189
|
+
}
|
|
190
|
+
if (result.status === 'pending') return { status: 'pending' };
|
|
191
|
+
|
|
192
|
+
let added;
|
|
193
|
+
try {
|
|
194
|
+
added = await addAccount(result.input, { trusted: true });
|
|
195
|
+
} catch (e) {
|
|
196
|
+
sessions.delete(sessionId);
|
|
197
|
+
return { status: 'failed', error: e.message };
|
|
198
|
+
}
|
|
199
|
+
session.done = true;
|
|
200
|
+
const { account, duplicate } = added;
|
|
201
|
+
logger.info('DEVICE', duplicate
|
|
202
|
+
? labelOf(session.kind) + ' 登录成功:账号已存在,已更新凭据(' + (account.name || account.id) + ')'
|
|
203
|
+
: labelOf(session.kind) + ' 登录成功:' + (account.name || account.id));
|
|
204
|
+
return { status: 'ok', account: publicAccount(account), already: duplicate };
|
|
205
|
+
}
|
package/src/checkin.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
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 + 抖动执行一次;多轮签到(定时 / 手动)串行执行
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { loadAccounts, loadState, saveState, withAccounts } from './store.js';
|
|
13
|
+
import { productImpl } from './providers.js';
|
|
14
|
+
import { productOf } from './constants.js';
|
|
15
|
+
import { startUsageSyncScheduler } from './tenrouter.js';
|
|
16
|
+
import { refreshContext } from './accounts.js';
|
|
17
|
+
import { logger } from './logger.js';
|
|
18
|
+
|
|
19
|
+
const TICK_MS = 2 * 60 * 60 * 1000; // 2 小时
|
|
20
|
+
const TICK_JITTER_MS = 10 * 60 * 1000; // ±10 分钟抖动,避免整点请求特征
|
|
21
|
+
|
|
22
|
+
// 每日积分刷新:10:00 (UTC+8) = 02:00 UTC。签到日 = (now - 2h) 的 UTC 日期
|
|
23
|
+
const REFRESH_UTC_OFFSET_MS = 2 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
let timerHandle = null;
|
|
26
|
+
let tickQueue = Promise.resolve();
|
|
27
|
+
let nextTickAt = null;
|
|
28
|
+
let lastTick = null; // { at, summary }
|
|
29
|
+
let ticking = false;
|
|
30
|
+
|
|
31
|
+
/** 调度器状态(面板展示用) */
|
|
32
|
+
export function getSchedulerInfo() {
|
|
33
|
+
return { running: Boolean(timerHandle), nextTickAt, lastTick, ticking };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 当前所属的"签到日"(YYYY-MM-DD)。以 10:00 (UTC+8) 为日界:
|
|
38
|
+
* 刷新前领过的记录不会让刷新后的新一轮被跳过;也不受 NAS / 海外机器时区影响。
|
|
39
|
+
*/
|
|
40
|
+
export function dayKey(nowMs = Date.now()) {
|
|
41
|
+
return new Date(nowMs - REFRESH_UTC_OFFSET_MS).toISOString().slice(0, 10);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function msUntilNextTick(nowMs = Date.now(), rand = Math.random) {
|
|
45
|
+
return Math.max(TICK_MS + Math.floor(rand() * TICK_JITTER_MS), 1000);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getDoneMap(state) {
|
|
49
|
+
const today = dayKey();
|
|
50
|
+
const raw = state?.qoderDailyDone;
|
|
51
|
+
const map = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : {};
|
|
52
|
+
// 只保留今天的记录,其余清掉
|
|
53
|
+
for (const k of Object.keys(map)) if (map[k] !== today) delete map[k];
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 执行一轮签到。多次调用会排队串行执行,避免同一账号被并发领取、state.json 互相覆盖。
|
|
59
|
+
* @param {{provider?: string, product?: string, skipIfCheckedToday?: boolean, onlyAccountId?: string}} opts
|
|
60
|
+
*/
|
|
61
|
+
export function runCheckinTick(opts = {}) {
|
|
62
|
+
const run = tickQueue.then(() => runTickNow(opts));
|
|
63
|
+
tickQueue = run.catch(() => {});
|
|
64
|
+
return run;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function runTickNow(opts) {
|
|
68
|
+
ticking = true;
|
|
69
|
+
try { return await runTickInner(opts); } finally { ticking = false; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runTickInner(opts) {
|
|
73
|
+
// 没有每日签到能力的产品(如 ZCode:积分靠需验证码的活动领取)不参与签到轮
|
|
74
|
+
const accounts = (await loadAccounts()).filter(
|
|
75
|
+
(a) => (!opts.provider || a.provider === opts.provider)
|
|
76
|
+
&& (!opts.product || productOf(a.provider) === opts.product)
|
|
77
|
+
&& (!opts.onlyAccountId || a.id === opts.onlyAccountId)
|
|
78
|
+
&& typeof productImpl(a.provider)?.checkin === 'function'
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (accounts.length === 0) {
|
|
82
|
+
return { results: [], summary: '没有匹配的账号' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const state = await loadState();
|
|
86
|
+
const memo = await getDoneMap(state);
|
|
87
|
+
const today = dayKey();
|
|
88
|
+
const results = [];
|
|
89
|
+
// 本设备今日领取国际版每日积分的账号 { day, accountId, name }
|
|
90
|
+
let deviceClaim = state?.deviceClaim?.day === today ? state.deviceClaim : null;
|
|
91
|
+
|
|
92
|
+
for (const account of accounts) {
|
|
93
|
+
const label = account.name || account.id;
|
|
94
|
+
try {
|
|
95
|
+
if (opts.skipIfCheckedToday && memo[account.id] === today) {
|
|
96
|
+
results.push({ accountId: account.id, account: label, provider: account.provider, status: 'already', memoized: true });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ctx = refreshContext(account, (m) => logger.info('CHECKIN', `${label}:${m}`));
|
|
101
|
+
let outcome = await productImpl(account.provider).checkin(account, ctx);
|
|
102
|
+
if (account.provider === 'qoder' && outcome.risk) {
|
|
103
|
+
if (outcome.status === 'checked-in') {
|
|
104
|
+
deviceClaim = { day: today, accountId: account.id, name: label };
|
|
105
|
+
} else if (outcome.status === 'no-activity' && deviceClaim && deviceClaim.accountId !== account.id) {
|
|
106
|
+
outcome = { ...outcome, status: 'limited', message: `本机今日国际版额度已由「${deviceClaim.name}」领取(Qoder 每台设备每天限领一次)` };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
results.push(outcome);
|
|
110
|
+
if (outcome.uid && !account.uid) account.uid = outcome.uid;
|
|
111
|
+
account.lastResult = {
|
|
112
|
+
status: outcome.status,
|
|
113
|
+
message: outcome.error || outcome.message || null,
|
|
114
|
+
amount: outcome.claimedAmount || 0,
|
|
115
|
+
...(outcome.streakDays !== undefined ? { streakDays: outcome.streakDays + (outcome.status === 'checked-in' ? 1 : 0) } : {}),
|
|
116
|
+
at: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (outcome.status === 'checked-in') {
|
|
120
|
+
memo[account.id] = today;
|
|
121
|
+
account.lastCheckin = new Date().toISOString();
|
|
122
|
+
logger.info('CHECKIN', `${label} 领取成功 +${outcome.claimedAmount} Credits`);
|
|
123
|
+
} else if (outcome.status === 'already') {
|
|
124
|
+
memo[account.id] = today;
|
|
125
|
+
account.lastCheckin = account.lastCheckin || new Date().toISOString();
|
|
126
|
+
logger.info('CHECKIN', `${label}:${outcome.message || '今日已领'}`);
|
|
127
|
+
} else if (outcome.status === 'limited') {
|
|
128
|
+
memo[account.id] = today;
|
|
129
|
+
logger.info('CHECKIN', `${label}:${outcome.message}`);
|
|
130
|
+
} else if (outcome.status === 'no-activity') {
|
|
131
|
+
// 不记忆——积分窗口(10:00 UTC+8)打开后还会出现可领活动
|
|
132
|
+
logger.debug('CHECKIN', `${label}:${outcome.message || '当前无可领取的活动'}`);
|
|
133
|
+
} else {
|
|
134
|
+
logger.warn('CHECKIN', `${label} 领取失败:${outcome.error || outcome.status}`);
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
results.push({ accountId: account.id, account: label, provider: account.provider, status: 'failed', error: err?.message || String(err) });
|
|
138
|
+
account.lastResult = { status: 'failed', message: err?.message || String(err), amount: 0, at: new Date().toISOString() };
|
|
139
|
+
logger.error('CHECKIN', `${label} 异常:${err?.message || err}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 保存签到日历与账号 lastCheckin / lastResult / uid(在账号锁内重新读取,不覆盖期间的增删)
|
|
144
|
+
await withAccounts((all) => {
|
|
145
|
+
for (const updated of accounts) {
|
|
146
|
+
const cur = all.find((a) => a.id === updated.id);
|
|
147
|
+
if (!cur) continue;
|
|
148
|
+
cur.lastCheckin = updated.lastCheckin;
|
|
149
|
+
if (updated.lastResult) cur.lastResult = updated.lastResult;
|
|
150
|
+
if (updated.uid && !cur.uid) cur.uid = updated.uid;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
await saveState({ ...state, qoderDailyDone: memo, deviceClaim });
|
|
154
|
+
|
|
155
|
+
const claimed = results.filter((r) => r.status === 'checked-in');
|
|
156
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
157
|
+
const none = results.filter((r) => r.status === 'no-activity');
|
|
158
|
+
const limited = results.filter((r) => r.status === 'limited');
|
|
159
|
+
const already = results.length - claimed.length - failed.length - none.length - limited.length;
|
|
160
|
+
const totalCredits = claimed.reduce((s, r) => s + (r.claimedAmount || 0), 0);
|
|
161
|
+
const summary = `签到汇总:成功 ${claimed.length}(+${totalCredits} Credits)、已领 ${already}`
|
|
162
|
+
+ (limited.length ? `、本机限领 ${limited.length}` : '')
|
|
163
|
+
+ `、无活动 ${none.length}、失败 ${failed.length}`;
|
|
164
|
+
logger.info('CHECKIN', summary);
|
|
165
|
+
lastTick = { at: new Date().toISOString(), summary };
|
|
166
|
+
|
|
167
|
+
return { results, summary };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 启动定时签到(每 ~2h 一轮,当天已成功的账号自动跳过) */
|
|
171
|
+
export function startScheduler() {
|
|
172
|
+
if (timerHandle) return;
|
|
173
|
+
startUsageSyncScheduler(); // 10Router 用量同步(未配置 / 未开启时不做任何事)
|
|
174
|
+
const scheduleNext = () => {
|
|
175
|
+
const delay = msUntilNextTick();
|
|
176
|
+
nextTickAt = new Date(Date.now() + delay).toISOString();
|
|
177
|
+
timerHandle = setTimeout(async () => {
|
|
178
|
+
try {
|
|
179
|
+
await runCheckinTick({ skipIfCheckedToday: true });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
logger.error('CHECKIN', `定时轮失败:${err?.message || err}`);
|
|
182
|
+
} finally {
|
|
183
|
+
scheduleNext();
|
|
184
|
+
}
|
|
185
|
+
}, delay);
|
|
186
|
+
if (timerHandle.unref) timerHandle.unref();
|
|
187
|
+
logger.info('CHECKIN', `定时签到已启动,下次执行约 ${Math.round(delay / 60000)} 分钟后`);
|
|
188
|
+
};
|
|
189
|
+
scheduleNext();
|
|
190
|
+
// 启动后 15 秒先跑一轮引导签到
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
runCheckinTick({ skipIfCheckedToday: true }).catch((e) =>
|
|
193
|
+
logger.error('CHECKIN', `引导轮失败:${e?.message || e}`));
|
|
194
|
+
}, 15000).unref?.();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function stopScheduler() {
|
|
198
|
+
if (timerHandle) { clearTimeout(timerHandle); timerHandle = null; }
|
|
199
|
+
nextTickAt = null;
|
|
200
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CreditDaddy 常量 — 支持的产品线与 Qoder 官方 OpenAPI 端点(WorkBuddy 端点见 workbuddyClient.js)。
|
|
3
|
+
*
|
|
4
|
+
* 端点与请求头结构来自社区逆向成果(10router 项目实测可用):
|
|
5
|
+
* openapi.qoder.sh 国际版:活动/签到、userinfo、配额、PAT 兑换
|
|
6
|
+
* openapi.qoder.com.cn 国内版:同上
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 非官方接口,Qoder 调整服务端时可能失效。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
export const OPENAPI_BASE = 'https://openapi.qoder.sh';
|
|
14
|
+
export const CN_OPENAPI_BASE = 'https://openapi.qoder.com.cn';
|
|
15
|
+
|
|
16
|
+
export const LOGIN_URL = 'https://qoder.com/device/selectAccounts';
|
|
17
|
+
export const CN_LOGIN_URL = 'https://qoder.cn/device/selectAccounts';
|
|
18
|
+
|
|
19
|
+
// 活动(签到)列表与领取
|
|
20
|
+
export const CAMPAIGNS_PATH = '/sash/api/v1/me/campaigns?clientType=10';
|
|
21
|
+
export const CAMPAIGN_CLAIM_PATH = (id) => `/sash/api/v1/me/campaigns/${encodeURIComponent(id)}/claim`;
|
|
22
|
+
|
|
23
|
+
// 账号信息与配额
|
|
24
|
+
export const USERINFO_PATH = '/api/v1/userinfo';
|
|
25
|
+
export const QUOTA_USAGE_PATH = '/api/v2/quota/usage';
|
|
26
|
+
|
|
27
|
+
// PAT (pt-...) → 短期 job token (jt-...) 兑换(普通 JSON POST,无需 COSY 签名)
|
|
28
|
+
export const JOB_TOKEN_EXCHANGE_PATH = '/api/v1/jobToken/exchange';
|
|
29
|
+
|
|
30
|
+
// 账号的 provider = 产品 + 区域。新增产品时在此登记,并在 providers.js 挂上对应实现。
|
|
31
|
+
export const PROVIDERS = ['qoder', 'qoder-cn', 'workbuddy', 'workbuddy-intl', 'zcode'];
|
|
32
|
+
export const PROVIDER_LABEL = {
|
|
33
|
+
qoder: 'Qoder 国际版',
|
|
34
|
+
'qoder-cn': 'Qoder 国内版',
|
|
35
|
+
workbuddy: 'WorkBuddy 国内版',
|
|
36
|
+
'workbuddy-intl': 'WorkBuddy 国际版',
|
|
37
|
+
zcode: 'ZCode',
|
|
38
|
+
};
|
|
39
|
+
/** provider → 产品线(qoder / workbuddy / zcode) */
|
|
40
|
+
export const productOf = (provider) => {
|
|
41
|
+
const p = String(provider);
|
|
42
|
+
if (p.startsWith('workbuddy')) return 'workbuddy';
|
|
43
|
+
if (p.startsWith('zcode')) return 'zcode';
|
|
44
|
+
return 'qoder';
|
|
45
|
+
};
|
|
46
|
+
/** provider 是否国内版 */
|
|
47
|
+
export const isCnProvider = (provider) => provider === 'qoder-cn' || provider === 'workbuddy';
|
|
48
|
+
|
|
49
|
+
/** 签到请求头(源自 10router qoderCheckin.js,clientType=10 与官方客户端一致) */
|
|
50
|
+
export function buildQoderHeaders(token) {
|
|
51
|
+
return {
|
|
52
|
+
Authorization: `Bearer ${token}`,
|
|
53
|
+
'Cosy-ClientType': '10',
|
|
54
|
+
'Cosy-Version': '0.3.3',
|
|
55
|
+
'Cosy-MachineOS':
|
|
56
|
+
process.platform === 'win32' ? 'windows'
|
|
57
|
+
: process.platform === 'darwin' ? 'macos' : 'linux',
|
|
58
|
+
'User-Agent': 'Qoder',
|
|
59
|
+
Accept: 'application/json',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** PAT 兑换请求头(源自 10router qoderModels.js,模拟官方 qodercli) */
|
|
64
|
+
export function buildExchangeHeaders() {
|
|
65
|
+
return {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
Accept: 'application/json',
|
|
68
|
+
'User-Agent': 'qodercli/1.0.0',
|
|
69
|
+
'Cosy-Version': '1.0.0',
|
|
70
|
+
'Cosy-ClientType': '5',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const FETCH_TIMEOUT_MS = 15000;
|
|
75
|
+
|
|
76
|
+
// 版本号 / 项目主页以 package.json 为唯一来源(桌面版 / fpk 打包时都会一并拷入 package.json)
|
|
77
|
+
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
78
|
+
export const APP_VERSION = PKG.version;
|
|
79
|
+
export const PROJECT_URL = PKG.homepage || 'https://github.com/techysy/CreditDaddy';
|