stepfun-status 1.0.4 → 1.0.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.
Files changed (5) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +190 -175
  3. package/cli/api.js +408 -345
  4. package/package.json +1 -1
  5. package/ARCHITECTURE.md +0 -175
package/cli/api.js CHANGED
@@ -1,345 +1,408 @@
1
- const https = require("https");
2
- const fs = require("fs");
3
- const path = require("path");
4
- const os = require("os");
5
-
6
- // ─── 常量 ─────────────────────────────────────────────────────────────────────
7
-
8
- const STEPFUN_HOST = "platform.stepfun.com";
9
- const STEPFUN_ORIGIN = `https://${STEPFUN_HOST}`;
10
-
11
- const CONFIG = {
12
- CACHE_TIMEOUT: 30000, // 缓存超时:30 秒
13
- REQUEST_TIMEOUT: 8000, // 网络请求超时:8 秒
14
- CONFIG_FILENAME: ".stepfun-config.json",
15
- };
16
-
17
- // ─── 跨平台 User-Agent ────────────────────────────────────────────────────────
18
-
19
- /**
20
- * 根据当前操作系统返回对应的浏览器 User-Agent 字符串,
21
- * 避免 macOS/Linux 下使用 Windows UA 被服务端检测。
22
- */
23
- function getPlatformUserAgent() {
24
- const platform = os.platform();
25
- if (platform === "darwin") {
26
- return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
27
- }
28
- if (platform === "linux") {
29
- return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
30
- }
31
- // Windows(默认)
32
- return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
33
- }
34
-
35
- // 配置文件路径(模块级常量,供外部共享,避免各处重复计算)
36
- const CONFIG_PATH = path.join(
37
- process.env.HOME || process.env.USERPROFILE,
38
- CONFIG.CONFIG_FILENAME
39
- );
40
-
41
- // ─── 模块级纯函数(提升出 parseUsageData,避免每次调用重新创建) ──────────────
42
-
43
- /**
44
- * 将 Unix 时间戳转换为剩余时间对象
45
- * @param {string|number|null} timestamp Unix 秒级时间戳
46
- * @returns {{ ts: number, date: string, hoursUntil: number, minutesUntil: number } | null}
47
- */
48
- function formatResetTime(timestamp) {
49
- if (!timestamp) return null;
50
- const ts = parseInt(timestamp, 10);
51
- const date = new Date(ts * 1000);
52
- const diffMs = date.getTime() - Date.now();
53
- return {
54
- ts,
55
- date: date.toLocaleString(),
56
- hoursUntil: Math.max(0, Math.floor(diffMs / (1000 * 60 * 60))),
57
- minutesUntil: Math.max(0, Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))),
58
- };
59
- }
60
-
61
- /**
62
- * 将剩余时间对象格式化为可读文本
63
- * @param {{ hoursUntil: number, minutesUntil: number } | null} resetInfo
64
- * @returns {string}
65
- */
66
- function calcRemainingText(resetInfo) {
67
- if (!resetInfo) return "未知";
68
- const { hoursUntil, minutesUntil } = resetInfo;
69
- return hoursUntil > 0
70
- ? `${hoursUntil} 小时 ${minutesUntil} 分钟后重置`
71
- : `${minutesUntil} 分钟后重置`;
72
- }
73
-
74
- // ─── StepFunAPI ────────────────────────────────────────────────────────────
75
-
76
- class StepFunAPI {
77
- constructor() {
78
- this.cookie = null;
79
- this.cache = {
80
- statusData: null,
81
- quotaData: null,
82
- statusTimestamp: 0,
83
- quotaTimestamp: 0,
84
- };
85
- this.loadConfig();
86
- }
87
-
88
- /** 是否已配置凭据 */
89
- isAuthenticated() {
90
- return Boolean(this.cookie);
91
- }
92
-
93
- loadConfig() {
94
- try {
95
- if (fs.existsSync(CONFIG_PATH)) {
96
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
97
- this.cookie = config.cookie || null;
98
- }
99
- } catch (error) {
100
- console.error("Failed to load config:", error.message);
101
- }
102
- }
103
-
104
- saveConfig() {
105
- try {
106
- const config = { cookie: this.cookie };
107
- // 0o600:仅所有者可读写,在 Unix/Mac 上防止其他用户读取 Cookie
108
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
109
- } catch (error) {
110
- console.error("Failed to save config:", error.message);
111
- }
112
- }
113
-
114
- setCredentials(cookie) {
115
- if (!cookie || typeof cookie !== "string" || !cookie.trim()) {
116
- throw new Error("Cookie 不能为空");
117
- }
118
- this.cookie = cookie.trim();
119
- this.saveConfig();
120
- }
121
-
122
- /** 从 Cookie 字符串中提取指定字段 */
123
- extractFromCookie() {
124
- if (!this.cookie) return { oasisWebid: null };
125
-
126
- const cookies = {};
127
- this.cookie.split(/;\s*/).forEach((c) => {
128
- const idx = c.indexOf("=");
129
- if (idx === -1) return;
130
- const key = c.slice(0, idx).trim();
131
- const val = c.slice(idx + 1);
132
- if (!key) return;
133
- try {
134
- cookies[key] = decodeURIComponent(val);
135
- } catch {
136
- cookies[key] = val;
137
- }
138
- });
139
-
140
- return { oasisWebid: cookies["Oasis-Webid"] || null };
141
- }
142
-
143
- async makePlatformRequest(endpoint, data = {}) {
144
- if (!this.cookie) {
145
- throw new Error(
146
- 'Missing cookie. Please run "stepfun auth <cookie>" first.\n' +
147
- "获取方式:\n" +
148
- "1. 登录 https://platform.stepfun.com/plan-subscribe\n" +
149
- "2. F12 -> Network -> 刷新页面\n" +
150
- "3. 点击任意 API 请求 -> Copy -> Copy request headers -> 复制 Cookie 行"
151
- );
152
- }
153
-
154
- const { oasisWebid } = this.extractFromCookie();
155
- const postData = JSON.stringify(data);
156
-
157
- const headers = {
158
- accept: "*/*",
159
- "content-type": "application/json",
160
- "oasis-appid": "10300",
161
- "oasis-platform": "web",
162
- "oasis-webid": oasisWebid || "",
163
- origin: STEPFUN_ORIGIN,
164
- referer: `${STEPFUN_ORIGIN}/plan-subscribe`,
165
- "user-agent": getPlatformUserAgent(),
166
- "content-length": Buffer.byteLength(postData),
167
- Cookie: this.cookie,
168
- };
169
-
170
- return new Promise((resolve, reject) => {
171
- const req = https.request(
172
- {
173
- hostname: STEPFUN_HOST, // STEPFUN_ORIGIN 保持同一数据源
174
- port: 443,
175
- path: endpoint,
176
- method: "POST",
177
- headers,
178
- rejectUnauthorized: true, // 显式启用 SSL 证书验证
179
- },
180
- (res) => {
181
- let body = "";
182
- res.on("data", (chunk) => { body += chunk; });
183
- res.on("end", () => {
184
- if (res.statusCode === 200) {
185
- try {
186
- resolve(JSON.parse(body));
187
- } catch {
188
- reject(new Error("Failed to parse response: invalid JSON"));
189
- }
190
- } else if (res.statusCode === 401) {
191
- reject(new Error("Cookie 已失效或过期,请重新运行 auth 命令"));
192
- } else {
193
- reject(new Error(`API error: HTTP ${res.statusCode}`));
194
- }
195
- });
196
- }
197
- );
198
-
199
- // 超时保护
200
- req.setTimeout(CONFIG.REQUEST_TIMEOUT, () => {
201
- req.destroy();
202
- reject(new Error(`Request timeout after ${CONFIG.REQUEST_TIMEOUT / 1000}s`));
203
- });
204
-
205
- req.on("error", (e) => {
206
- reject(new Error(`Network error: ${e.message}`));
207
- });
208
-
209
- req.write(postData);
210
- req.end();
211
- });
212
- }
213
-
214
- /**
215
- * 带缓存的请求封装,消除 getSubscriptionStatus / getQuotaInfo 的重复逻辑
216
- * @param {'statusData'|'quotaData'} dataKey 缓存数据字段名
217
- * @param {'statusTimestamp'|'quotaTimestamp'} tsKey 缓存时间戳字段名
218
- * @param {string} endpoint API 路径
219
- * @param {boolean} forceRefresh 是否强制跳过缓存
220
- */
221
- async _cachedRequest(dataKey, tsKey, endpoint, forceRefresh) {
222
- const now = Date.now();
223
- if (
224
- !forceRefresh &&
225
- this.cache[dataKey] &&
226
- now - this.cache[tsKey] < CONFIG.CACHE_TIMEOUT
227
- ) {
228
- return this.cache[dataKey];
229
- }
230
- const data = await this.makePlatformRequest(endpoint);
231
- this.cache[dataKey] = data;
232
- this.cache[tsKey] = now;
233
- return data;
234
- }
235
-
236
- async getSubscriptionStatus(forceRefresh = false) {
237
- return this._cachedRequest(
238
- "statusData",
239
- "statusTimestamp",
240
- "/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus",
241
- forceRefresh
242
- );
243
- }
244
-
245
- async getQuotaInfo(forceRefresh = false) {
246
- return this._cachedRequest(
247
- "quotaData",
248
- "quotaTimestamp",
249
- "/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit",
250
- forceRefresh
251
- );
252
- }
253
-
254
- // ── 预留扩展区 ──────────────────────────────────────────────────────────────
255
- // 后续新增接口时,只需:
256
- // 1. this.cache 里加对应的 data/timestamp 字段
257
- // 2. 仿照下方模板写一个 get 方法
258
- // 3. 在 getUsageStatus / parseUsageData 里消费新数据
259
- //
260
- // 模板(等 Token 用量明细接口开放后接入):
261
- //
262
- // async getTokenUsage(forceRefresh = false) {
263
- // return this._cachedRequest(
264
- // "tokenData",
265
- // "tokenTimestamp",
266
- // "/api/step.openapi.devcenter.Dashboard/QueryTokenUsage", // 待确认
267
- // forceRefresh
268
- // );
269
- // }
270
- // ────────────────────────────────────────────────────────────────────────────
271
-
272
- async getUsageStatus(forceRefresh = false) {
273
- const [statusData, quotaData] = await Promise.all([
274
- this.getSubscriptionStatus(forceRefresh),
275
- this.getQuotaInfo(forceRefresh),
276
- ]);
277
- return this.parseUsageData(statusData, quotaData);
278
- }
279
-
280
- parseUsageData(statusData, quotaData) {
281
- const subscription = statusData.subscription || {};
282
- const planDefinition = statusData.plan_definition || {};
283
-
284
- const fiveHourLeftRate = quotaData.five_hour_usage_left_rate || 0;
285
- const weeklyLeftRate = quotaData.weekly_usage_left_rate || 0;
286
-
287
- const fiveHourUsedPercent = Math.round((1 - fiveHourLeftRate) * 100);
288
- const weeklyUsedPercent = Math.round((1 - weeklyLeftRate) * 100);
289
-
290
- const fiveHourReset = formatResetTime(quotaData.five_hour_usage_reset_time);
291
- const weeklyReset = formatResetTime(quotaData.weekly_usage_reset_time);
292
-
293
- let expiry = null;
294
- if (subscription.expired_at) {
295
- const expiryDate = new Date(parseInt(subscription.expired_at, 10) * 1000);
296
- const daysRemaining = Math.ceil(
297
- (expiryDate.getTime() - Date.now()) / (1000 * 3600 * 24)
298
- );
299
- expiry = {
300
- date: expiryDate.toLocaleString(),
301
- daysRemaining,
302
- text:
303
- daysRemaining > 0
304
- ? `还剩 ${daysRemaining} 天`
305
- : daysRemaining === 0
306
- ? "今天到期"
307
- : `已过期 ${Math.abs(daysRemaining)} 天`,
308
- };
309
- }
310
-
311
- return {
312
- modelName: planDefinition.support_models?.[0] || "step-3.5-flash",
313
- planName: subscription.name || "Mini",
314
- remaining: {
315
- hours: fiveHourReset?.hoursUntil || 0,
316
- minutes: fiveHourReset?.minutesUntil || 0,
317
- text: calcRemainingText(fiveHourReset),
318
- },
319
- usage: {
320
- percentage: fiveHourUsedPercent,
321
- remaining: Math.round(fiveHourLeftRate * 100),
322
- },
323
- weekly: {
324
- percentage: weeklyUsedPercent,
325
- remaining: Math.round(weeklyLeftRate * 100),
326
- text: calcRemainingText(weeklyReset),
327
- daysUntilReset: weeklyReset?.hoursUntil
328
- ? Math.floor(weeklyReset.hoursUntil / 24)
329
- : 0,
330
- },
331
- expiry,
332
- };
333
- }
334
-
335
- clearCache() {
336
- this.cache = {
337
- statusData: null,
338
- quotaData: null,
339
- statusTimestamp: 0,
340
- quotaTimestamp: 0,
341
- };
342
- }
343
- }
344
-
345
- module.exports = { StepFunAPI, CONFIG_PATH };
1
+ const https = require("https");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+
6
+ // ─── 常量 ─────────────────────────────────────────────────────────────────────
7
+
8
+ const STEPFUN_HOST = "platform.stepfun.com";
9
+ const STEPFUN_ORIGIN = `https://${STEPFUN_HOST}`;
10
+
11
+ const CONFIG = {
12
+ CACHE_TIMEOUT: 30000, // 缓存超时:30 秒
13
+ REQUEST_TIMEOUT: 8000, // 网络请求超时:8 秒
14
+ CONFIG_FILENAME: ".stepfun-config.json",
15
+ };
16
+
17
+ // ─── 跨平台 User-Agent ────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * 根据当前操作系统返回对应的浏览器 User-Agent 字符串,
21
+ * 避免 macOS/Linux 下使用 Windows UA 被服务端检测。
22
+ */
23
+ function getPlatformUserAgent() {
24
+ const platform = os.platform();
25
+ if (platform === "darwin") {
26
+ return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
27
+ }
28
+ if (platform === "linux") {
29
+ return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
30
+ }
31
+ // Windows(默认)
32
+ return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
33
+ }
34
+
35
+ // 配置文件路径(模块级常量,供外部共享,避免各处重复计算)
36
+ const CONFIG_PATH = path.join(
37
+ process.env.HOME || process.env.USERPROFILE,
38
+ CONFIG.CONFIG_FILENAME
39
+ );
40
+
41
+ // ─── 模块级纯函数(提升出 parseUsageData,避免每次调用重新创建) ──────────────
42
+
43
+ /**
44
+ * 将 Unix 时间戳转换为剩余时间对象
45
+ * @param {string|number|null} timestamp Unix 秒级时间戳
46
+ * @returns {{ ts: number, date: string, hoursUntil: number, minutesUntil: number } | null}
47
+ */
48
+ function formatResetTime(timestamp) {
49
+ if (!timestamp) return null;
50
+ const ts = parseInt(timestamp, 10);
51
+ const date = new Date(ts * 1000);
52
+ const diffMs = date.getTime() - Date.now();
53
+ return {
54
+ ts,
55
+ date: date.toLocaleString(),
56
+ hoursUntil: Math.max(0, Math.floor(diffMs / (1000 * 60 * 60))),
57
+ minutesUntil: Math.max(0, Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * 将剩余时间对象格式化为可读文本
63
+ * @param {{ hoursUntil: number, minutesUntil: number } | null} resetInfo
64
+ * @returns {string}
65
+ */
66
+ function calcRemainingText(resetInfo) {
67
+ if (!resetInfo) return "未知";
68
+ const { hoursUntil, minutesUntil } = resetInfo;
69
+ return hoursUntil > 0
70
+ ? `${hoursUntil} 小时 ${minutesUntil} 分钟后重置`
71
+ : `${minutesUntil} 分钟后重置`;
72
+ }
73
+
74
+ // ─── Cookie 合并工具函数 ──────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * 将 Set-Cookie 响应头合并到现有 Cookie 字符串中(模拟浏览器行为)。
78
+ * 新值覆盖同名旧值,从而自动延长会话有效期。
79
+ *
80
+ * @param {string} existingCookie 当前 Cookie 字符串("k1=v1; k2=v2" 格式)
81
+ * @param {string[]} setCookieHeaders 响应头中的 Set-Cookie 数组
82
+ * @returns {string} 合并后的 Cookie 字符串
83
+ */
84
+ function mergeCookies(existingCookie, setCookieHeaders) {
85
+ // 1. 解析现有 cookie 为 Map(保持插入顺序)
86
+ const cookieMap = new Map();
87
+ if (existingCookie) {
88
+ existingCookie.split(/;\s*/).forEach((c) => {
89
+ const idx = c.indexOf("=");
90
+ if (idx === -1) return;
91
+ const key = c.slice(0, idx).trim();
92
+ if (key) cookieMap.set(key, c.slice(idx + 1));
93
+ });
94
+ }
95
+
96
+ // 2. 从 Set-Cookie 头中提取 name=value(忽略 Path/Domain/Expires 等属性)
97
+ let updated = false;
98
+ for (const header of setCookieHeaders) {
99
+ // Set-Cookie 格式: "name=value; Path=/; Domain=...; Expires=..."
100
+ const firstPart = header.split(";")[0].trim();
101
+ const idx = firstPart.indexOf("=");
102
+ if (idx === -1) continue;
103
+ const key = firstPart.slice(0, idx).trim();
104
+ const val = firstPart.slice(idx + 1);
105
+ if (!key) continue;
106
+ const oldVal = cookieMap.get(key);
107
+ if (oldVal !== val) {
108
+ cookieMap.set(key, val);
109
+ updated = true;
110
+ }
111
+ }
112
+
113
+ if (!updated) return existingCookie;
114
+
115
+ // 3. 重新拼接
116
+ return Array.from(cookieMap.entries())
117
+ .map(([k, v]) => `${k}=${v}`)
118
+ .join("; ");
119
+ }
120
+
121
+ // ─── StepFunAPI 类 ────────────────────────────────────────────────────────────
122
+
123
+ class StepFunAPI {
124
+ constructor() {
125
+ this.cookie = null;
126
+ this.cache = {
127
+ statusData: null,
128
+ quotaData: null,
129
+ statusTimestamp: 0,
130
+ quotaTimestamp: 0,
131
+ };
132
+ this.loadConfig();
133
+ }
134
+
135
+ /** 是否已配置凭据 */
136
+ isAuthenticated() {
137
+ return Boolean(this.cookie);
138
+ }
139
+
140
+ loadConfig() {
141
+ try {
142
+ if (fs.existsSync(CONFIG_PATH)) {
143
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
144
+ this.cookie = config.cookie || null;
145
+ }
146
+ } catch (error) {
147
+ console.error("Failed to load config:", error.message);
148
+ }
149
+ }
150
+
151
+ saveConfig() {
152
+ try {
153
+ const config = { cookie: this.cookie };
154
+ // 0o600:仅所有者可读写,在 Unix/Mac 上防止其他用户读取 Cookie
155
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
156
+ } catch (error) {
157
+ console.error("Failed to save config:", error.message);
158
+ }
159
+ }
160
+
161
+ setCredentials(cookie) {
162
+ if (!cookie || typeof cookie !== "string" || !cookie.trim()) {
163
+ throw new Error("Cookie 不能为空");
164
+ }
165
+ this.cookie = cookie.trim();
166
+ this.saveConfig();
167
+ }
168
+
169
+ /** 从 Cookie 字符串中提取指定字段 */
170
+ extractFromCookie() {
171
+ if (!this.cookie) return { oasisWebid: null };
172
+
173
+ const cookies = {};
174
+ this.cookie.split(/;\s*/).forEach((c) => {
175
+ const idx = c.indexOf("=");
176
+ if (idx === -1) return;
177
+ const key = c.slice(0, idx).trim();
178
+ const val = c.slice(idx + 1);
179
+ if (!key) return;
180
+ try {
181
+ cookies[key] = decodeURIComponent(val);
182
+ } catch {
183
+ cookies[key] = val;
184
+ }
185
+ });
186
+
187
+ return { oasisWebid: cookies["Oasis-Webid"] || null };
188
+ }
189
+
190
+ async makePlatformRequest(endpoint, data = {}) {
191
+ if (!this.cookie) {
192
+ throw new Error(
193
+ 'Missing cookie. Please run "stepfun auth <cookie>" first.\n' +
194
+ "获取方式:\n" +
195
+ "1. 登录 https://platform.stepfun.com/plan-subscribe\n" +
196
+ "2. F12 -> Network -> 刷新页面\n" +
197
+ "3. 点击任意 API 请求 -> Copy -> Copy request headers -> 复制 Cookie 行"
198
+ );
199
+ }
200
+
201
+ const { oasisWebid } = this.extractFromCookie();
202
+ const postData = JSON.stringify(data);
203
+
204
+ const headers = {
205
+ accept: "*/*",
206
+ "content-type": "application/json",
207
+ "oasis-appid": "10300",
208
+ "oasis-platform": "web",
209
+ "oasis-webid": oasisWebid || "",
210
+ origin: STEPFUN_ORIGIN,
211
+ referer: `${STEPFUN_ORIGIN}/plan-subscribe`,
212
+ "user-agent": getPlatformUserAgent(),
213
+ "content-length": Buffer.byteLength(postData),
214
+ Cookie: this.cookie,
215
+ };
216
+
217
+ return new Promise((resolve, reject) => {
218
+ const req = https.request(
219
+ {
220
+ hostname: STEPFUN_HOST, // 与 STEPFUN_ORIGIN 保持同一数据源
221
+ port: 443,
222
+ path: endpoint,
223
+ method: "POST",
224
+ headers,
225
+ rejectUnauthorized: true, // 显式启用 SSL 证书验证
226
+ },
227
+ (res) => {
228
+ let body = "";
229
+ res.on("data", (chunk) => { body += chunk; });
230
+ res.on("end", () => {
231
+ // 自动捕获 Set-Cookie 响应头,合并更新本地 Cookie(模拟浏览器保活)
232
+ const setCookieHeaders = res.headers["set-cookie"];
233
+ if (setCookieHeaders && setCookieHeaders.length > 0 && this.cookie) {
234
+ const merged = mergeCookies(this.cookie, setCookieHeaders);
235
+ if (merged !== this.cookie) {
236
+ this.cookie = merged;
237
+ this.saveConfig();
238
+ }
239
+ }
240
+
241
+ if (res.statusCode === 200) {
242
+ try {
243
+ resolve(JSON.parse(body));
244
+ } catch {
245
+ reject(new Error("Failed to parse response: invalid JSON"));
246
+ }
247
+ } else if (res.statusCode === 401) {
248
+ this.clearCache();
249
+ reject(
250
+ new Error(
251
+ "Cookie 已失效或过期,请重新运行 auth 命令\n" +
252
+ "获取方式: 登录 https://platform.stepfun.com/plan-subscribe → F12 → Network → 复制 Cookie"
253
+ )
254
+ );
255
+ } else {
256
+ reject(new Error(`API error: HTTP ${res.statusCode}`));
257
+ }
258
+ });
259
+ }
260
+ );
261
+
262
+ // 超时保护
263
+ req.setTimeout(CONFIG.REQUEST_TIMEOUT, () => {
264
+ req.destroy();
265
+ reject(new Error(`Request timeout after ${CONFIG.REQUEST_TIMEOUT / 1000}s`));
266
+ });
267
+
268
+ req.on("error", (e) => {
269
+ reject(new Error(`Network error: ${e.message}`));
270
+ });
271
+
272
+ req.write(postData);
273
+ req.end();
274
+ });
275
+ }
276
+
277
+ /**
278
+ * 带缓存的请求封装,消除 getSubscriptionStatus / getQuotaInfo 的重复逻辑
279
+ * @param {'statusData'|'quotaData'} dataKey 缓存数据字段名
280
+ * @param {'statusTimestamp'|'quotaTimestamp'} tsKey 缓存时间戳字段名
281
+ * @param {string} endpoint API 路径
282
+ * @param {boolean} forceRefresh 是否强制跳过缓存
283
+ */
284
+ async _cachedRequest(dataKey, tsKey, endpoint, forceRefresh) {
285
+ const now = Date.now();
286
+ if (
287
+ !forceRefresh &&
288
+ this.cache[dataKey] &&
289
+ now - this.cache[tsKey] < CONFIG.CACHE_TIMEOUT
290
+ ) {
291
+ return this.cache[dataKey];
292
+ }
293
+ const data = await this.makePlatformRequest(endpoint);
294
+ this.cache[dataKey] = data;
295
+ this.cache[tsKey] = now;
296
+ return data;
297
+ }
298
+
299
+ async getSubscriptionStatus(forceRefresh = false) {
300
+ return this._cachedRequest(
301
+ "statusData",
302
+ "statusTimestamp",
303
+ "/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus",
304
+ forceRefresh
305
+ );
306
+ }
307
+
308
+ async getQuotaInfo(forceRefresh = false) {
309
+ return this._cachedRequest(
310
+ "quotaData",
311
+ "quotaTimestamp",
312
+ "/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit",
313
+ forceRefresh
314
+ );
315
+ }
316
+
317
+ // ── 预留扩展区 ──────────────────────────────────────────────────────────────
318
+ // 后续新增接口时,只需:
319
+ // 1. 在 this.cache 里加对应的 data/timestamp 字段
320
+ // 2. 仿照下方模板写一个 get 方法
321
+ // 3. getUsageStatus / parseUsageData 里消费新数据
322
+ //
323
+ // 模板(等 Token 用量明细接口开放后接入):
324
+ //
325
+ // async getTokenUsage(forceRefresh = false) {
326
+ // return this._cachedRequest(
327
+ // "tokenData",
328
+ // "tokenTimestamp",
329
+ // "/api/step.openapi.devcenter.Dashboard/QueryTokenUsage", // 待确认
330
+ // forceRefresh
331
+ // );
332
+ // }
333
+ // ────────────────────────────────────────────────────────────────────────────
334
+
335
+ async getUsageStatus(forceRefresh = false) {
336
+ const [statusData, quotaData] = await Promise.all([
337
+ this.getSubscriptionStatus(forceRefresh),
338
+ this.getQuotaInfo(forceRefresh),
339
+ ]);
340
+ return this.parseUsageData(statusData, quotaData);
341
+ }
342
+
343
+ parseUsageData(statusData, quotaData) {
344
+ const subscription = statusData.subscription || {};
345
+ const planDefinition = statusData.plan_definition || {};
346
+
347
+ const fiveHourLeftRate = quotaData.five_hour_usage_left_rate || 0;
348
+ const weeklyLeftRate = quotaData.weekly_usage_left_rate || 0;
349
+
350
+ const fiveHourUsedPercent = Math.round((1 - fiveHourLeftRate) * 100);
351
+ const weeklyUsedPercent = Math.round((1 - weeklyLeftRate) * 100);
352
+
353
+ const fiveHourReset = formatResetTime(quotaData.five_hour_usage_reset_time);
354
+ const weeklyReset = formatResetTime(quotaData.weekly_usage_reset_time);
355
+
356
+ let expiry = null;
357
+ if (subscription.expired_at) {
358
+ const expiryDate = new Date(parseInt(subscription.expired_at, 10) * 1000);
359
+ const daysRemaining = Math.ceil(
360
+ (expiryDate.getTime() - Date.now()) / (1000 * 3600 * 24)
361
+ );
362
+ expiry = {
363
+ date: expiryDate.toLocaleString(),
364
+ daysRemaining,
365
+ text:
366
+ daysRemaining > 0
367
+ ? `还剩 ${daysRemaining} 天`
368
+ : daysRemaining === 0
369
+ ? "今天到期"
370
+ : `已过期 ${Math.abs(daysRemaining)} 天`,
371
+ };
372
+ }
373
+
374
+ return {
375
+ modelName: planDefinition.support_models?.[0] || "step-3.5-flash",
376
+ planName: subscription.name || "Mini",
377
+ remaining: {
378
+ hours: fiveHourReset?.hoursUntil || 0,
379
+ minutes: fiveHourReset?.minutesUntil || 0,
380
+ text: calcRemainingText(fiveHourReset),
381
+ },
382
+ usage: {
383
+ percentage: fiveHourUsedPercent,
384
+ remaining: Math.round(fiveHourLeftRate * 100),
385
+ },
386
+ weekly: {
387
+ percentage: weeklyUsedPercent,
388
+ remaining: Math.round(weeklyLeftRate * 100),
389
+ text: calcRemainingText(weeklyReset),
390
+ daysUntilReset: weeklyReset?.hoursUntil
391
+ ? Math.floor(weeklyReset.hoursUntil / 24)
392
+ : 0,
393
+ },
394
+ expiry,
395
+ };
396
+ }
397
+
398
+ clearCache() {
399
+ this.cache = {
400
+ statusData: null,
401
+ quotaData: null,
402
+ statusTimestamp: 0,
403
+ quotaTimestamp: 0,
404
+ };
405
+ }
406
+ }
407
+
408
+ module.exports = { StepFunAPI, CONFIG_PATH };