diff --git a/README.md b/README.md index 64ab0c0..7fe7c68 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ChatGPT Session to CPA / sub2api / Cockpit +# ChatGPT Session to CPA / sub2api / Cockpit / 9router -纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPA、sub2api 或 Cockpit Tools 可导入 JSON。 +纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPA、sub2api、Cockpit Tools 或 9router 可导入 JSON。 ## 在线使用 @@ -34,6 +34,8 @@ Plus 号可以用此方式导入中转站使用;Free 号的 access token 不 - `account.id` - `account.planType` +也支持粘贴或拖入 9router Codex OAuth JSON,例如包含 `accessToken`、`refreshToken`、`expiresAt`、`providerSpecificData.chatgptAccountId` 和 `providerSpecificData.chatgptPlanType`。 + 页面也会尝试从 `accessToken` 的 JWT payload 中补充邮箱、账号 ID、用户 ID、计划类型和过期时间。 ## 输出格式 @@ -41,6 +43,7 @@ Plus 号可以用此方式导入中转站使用;Free 号的 access token 不 - `CPA`:生成 Codex CPA auth JSON,包含 `type: "codex"`、`access_token`、`session_token`、`id_token`、`email`、`account_id`、套餐和过期时间等字段;缺少真实 `id_token` 时会根据 session 与 access token claims 构造 CPA 可解析的占位 claims。 - `sub2api`:生成参考 `CPA2sub2API` 项目的 `exported_at/proxies/accounts` 结构,账号平台为 `openai`,类型为 `oauth`。 - `Cockpit`:生成 Cockpit Tools Codex JSON 导入可识别的扁平 token 格式,包含 `id_token`、`access_token`、`refresh_token`、`account_id`、`email`、`expired` 等字段。 +- `9router`:生成 9router Codex OAuth JSON,包含 `accessToken`、`refreshToken`、`expiresAt`、`providerSpecificData`、`provider`、`authType`、`priority`、`isActive`、`createdAt` 和 `updatedAt` 等字段。 ChatGPT Web session 通常不包含 CPA OAuth 文件里常见的 `refresh_token`,因此 access token 过期后不能自动刷新。 ## 本地使用 diff --git a/docs/index.html b/docs/index.html index b3be453..d75549c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -158,7 +158,7 @@ .segmented { display: inline-grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); width: min(480px, 100%); padding: 4px; border: 1px solid var(--line); @@ -626,6 +626,7 @@ CPA sub2api Cockpit + 9router
@@ -652,6 +653,7 @@ +
@@ -766,6 +768,7 @@ sub2api: "sub2api", cpa: "CPA", cockpit: "Cockpit", + "9router": "9router", }; const state = { @@ -1061,9 +1064,22 @@ } visited.add(item); - const token = firstNonEmpty(item.accessToken, item.access_token, item.token?.accessToken, item.credentials?.access_token); - const hasUser = isPlainObject(item.user) || firstNonEmpty(item.email, item.name); - if (token && hasUser) { + const token = firstNonEmpty( + item.accessToken, + item.access_token, + item.token?.accessToken, + item.token?.access_token, + item.credentials?.accessToken, + item.credentials?.access_token, + ); + const hasIdentity = isPlainObject(item.user) || firstNonEmpty( + item.email, + item.name, + item.providerSpecificData?.chatgptAccountId, + item.providerSpecificData?.chatgpt_account_id, + item.id, + ); + if (token && hasIdentity) { found.push({ value: item, sourceName, path }); return; } @@ -1108,6 +1124,8 @@ record.accessToken, record.access_token, record.token?.accessToken, + record.token?.access_token, + record.credentials?.accessToken, record.credentials?.access_token, ); if (!accessToken) { @@ -1143,6 +1161,7 @@ const expiresAt = firstNonEmpty( payload ? timestampFromUnixSeconds(payload.exp) : undefined, normalizeTimestamp(record.expires), + normalizeTimestamp(record.expiresAt), normalizeTimestamp(record.expired), normalizeTimestamp(record.expires_at), ); @@ -1150,6 +1169,7 @@ record.user?.email, record.email, record.credentials?.email, + record.providerSpecificData?.email, profile.email, idPayload?.email, payload?.email, @@ -1157,13 +1177,20 @@ const accountId = firstNonEmpty( record.account?.id, record.account_id, + record.chatgptAccountId, + record.providerSpecificData?.chatgptAccountId, + record.providerSpecificData?.chatgpt_account_id, record.credentials?.chatgpt_account_id, auth.chatgpt_account_id, idAuth.chatgpt_account_id, + record.provider === "codex" ? record.id : undefined, ); const userId = firstNonEmpty( record.user?.id, record.user_id, + record.chatgptUserId, + record.providerSpecificData?.chatgptUserId, + record.providerSpecificData?.chatgpt_user_id, auth.chatgpt_user_id, auth.user_id, idAuth.chatgpt_user_id, @@ -1172,7 +1199,10 @@ const planType = firstNonEmpty( record.account?.planType, record.account?.plan_type, + record.planType, record.plan_type, + record.providerSpecificData?.chatgptPlanType, + record.providerSpecificData?.chatgpt_plan_type, record.credentials?.plan_type, auth.chatgpt_plan_type, idAuth.chatgpt_plan_type, @@ -1180,6 +1210,7 @@ const exportedAt = normalizeTimestamp(options.now || new Date()); const expiresIn = getExpiresIn(expiresAt, options.now || new Date()); const sourceName = firstNonEmpty(options.sourceName, "pasted-json"); + const sourceType = record.provider === "codex" && record.authType === "oauth" ? "9router" : "chatgpt_web_session"; const name = firstNonEmpty(email, sourceName, "ChatGPT Account"); const syntheticIdToken = !inputIdToken ? buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) @@ -1236,10 +1267,34 @@ email_key: toEmailKey(email), name, auth_provider: firstNonEmpty(record.authProvider, record.auth_provider), - source: "chatgpt_web_session", + source: sourceType, last_refresh: exportedAt, }, }); + const priority = Number.isFinite(Number(record.priority)) ? Number(record.priority) : 9; + const isActive = typeof record.isActive === "boolean" ? record.isActive : !Boolean(record.disabled); + const createdAt = normalizeTimestamp(record.createdAt) || exportedAt; + const updatedAt = normalizeTimestamp(record.updatedAt) || exportedAt; + const nineRouter = stripUnavailable({ + accessToken, + refreshToken, + expiresAt, + testStatus: firstNonEmpty(record.testStatus, record.test_status, "active"), + expiresIn, + providerSpecificData: { + chatgptAccountId: accountId, + chatgptPlanType: planType, + }, + id: accountId, + provider: "codex", + authType: "oauth", + name, + email, + priority, + isActive, + createdAt, + updatedAt, + }); return { sourceName, @@ -1249,6 +1304,7 @@ expiresAt, cpa, cockpit, + nineRouter, sub2apiAccount, }; } @@ -1279,6 +1335,12 @@ : state.converted.map((item) => item.cockpit); } + if (state.format === "9router") { + return state.converted.length === 1 + ? state.converted[0].nineRouter + : state.converted.map((item) => item.nineRouter); + } + return buildSub2apiDocument(state.converted, now); } @@ -1340,7 +1402,7 @@ elements.statErrors.textContent = String(state.skipped.length); elements.statFormat.textContent = OUTPUT_LABELS[state.format]; elements.outputSubtitle.textContent = `当前输出为 ${OUTPUT_LABELS[state.format]} 导入 JSON。`; - elements.cpaNotice.style.display = state.format === "sub2api" ? "none" : "block"; + elements.cpaNotice.style.display = state.format === "cpa" || state.format === "cockpit" ? "block" : "none"; renderAccounts(); renderIssues();