Add 9router OAuth export support

Adds 9router import/export support while preserving access-token-derived expiration and email-based account names.
This commit is contained in:
Long
2026-05-11 17:54:09 +07:00
committed by GitHub
parent 6181594dc0
commit 59367033cb
2 changed files with 73 additions and 8 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
# ChatGPT Session to CPA / sub2api / Cockpit # ChatGPT Session to CPA / sub2api / Cockpit / 9router
纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPA、sub2apiCockpit Tools 可导入 JSON。 纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPA、sub2apiCockpit Tools 或 9router 可导入 JSON。
## 在线使用 ## 在线使用
@@ -34,6 +34,8 @@ Plus 号可以用此方式导入中转站使用;Free 号的 access token 不
- `account.id` - `account.id`
- `account.planType` - `account.planType`
也支持粘贴或拖入 9router Codex OAuth JSON,例如包含 `accessToken``refreshToken``expiresAt``providerSpecificData.chatgptAccountId``providerSpecificData.chatgptPlanType`
页面也会尝试从 `accessToken` 的 JWT payload 中补充邮箱、账号 ID、用户 ID、计划类型和过期时间。 页面也会尝试从 `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。 - `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` - `sub2api`:生成参考 `CPA2sub2API` 项目的 `exported_at/proxies/accounts` 结构,账号平台为 `openai`,类型为 `oauth`
- `Cockpit`:生成 Cockpit Tools Codex JSON 导入可识别的扁平 token 格式,包含 `id_token``access_token``refresh_token``account_id``email``expired` 等字段。 - `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 过期后不能自动刷新。 ChatGPT Web session 通常不包含 CPA OAuth 文件里常见的 `refresh_token`,因此 access token 过期后不能自动刷新。
## 本地使用 ## 本地使用
+68 -6
View File
@@ -158,7 +158,7 @@
.segmented { .segmented {
display: inline-grid; display: inline-grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(480px, 100%); width: min(480px, 100%);
padding: 4px; padding: 4px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -626,6 +626,7 @@
<span>CPA</span> <span>CPA</span>
<span>sub2api</span> <span>sub2api</span>
<span>Cockpit</span> <span>Cockpit</span>
<span>9router</span>
</div> </div>
</div> </div>
<div class="meta-row" aria-label="处理方式"> <div class="meta-row" aria-label="处理方式">
@@ -652,6 +653,7 @@
<button type="button" data-format="sub2api" aria-pressed="true">sub2api</button> <button type="button" data-format="sub2api" aria-pressed="true">sub2api</button>
<button type="button" data-format="cpa" aria-pressed="false">CPA</button> <button type="button" data-format="cpa" aria-pressed="false">CPA</button>
<button type="button" data-format="cockpit" aria-pressed="false">Cockpit</button> <button type="button" data-format="cockpit" aria-pressed="false">Cockpit</button>
<button type="button" data-format="9router" aria-pressed="false">9router</button>
</div> </div>
<div class="actions"> <div class="actions">
<button class="button button-secondary" id="copy-output" type="button" disabled>复制输出</button> <button class="button button-secondary" id="copy-output" type="button" disabled>复制输出</button>
@@ -766,6 +768,7 @@
sub2api: "sub2api", sub2api: "sub2api",
cpa: "CPA", cpa: "CPA",
cockpit: "Cockpit", cockpit: "Cockpit",
"9router": "9router",
}; };
const state = { const state = {
@@ -1061,9 +1064,22 @@
} }
visited.add(item); visited.add(item);
const token = firstNonEmpty(item.accessToken, item.access_token, item.token?.accessToken, item.credentials?.access_token); const token = firstNonEmpty(
const hasUser = isPlainObject(item.user) || firstNonEmpty(item.email, item.name); item.accessToken,
if (token && hasUser) { 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 }); found.push({ value: item, sourceName, path });
return; return;
} }
@@ -1108,6 +1124,8 @@
record.accessToken, record.accessToken,
record.access_token, record.access_token,
record.token?.accessToken, record.token?.accessToken,
record.token?.access_token,
record.credentials?.accessToken,
record.credentials?.access_token, record.credentials?.access_token,
); );
if (!accessToken) { if (!accessToken) {
@@ -1143,6 +1161,7 @@
const expiresAt = firstNonEmpty( const expiresAt = firstNonEmpty(
payload ? timestampFromUnixSeconds(payload.exp) : undefined, payload ? timestampFromUnixSeconds(payload.exp) : undefined,
normalizeTimestamp(record.expires), normalizeTimestamp(record.expires),
normalizeTimestamp(record.expiresAt),
normalizeTimestamp(record.expired), normalizeTimestamp(record.expired),
normalizeTimestamp(record.expires_at), normalizeTimestamp(record.expires_at),
); );
@@ -1150,6 +1169,7 @@
record.user?.email, record.user?.email,
record.email, record.email,
record.credentials?.email, record.credentials?.email,
record.providerSpecificData?.email,
profile.email, profile.email,
idPayload?.email, idPayload?.email,
payload?.email, payload?.email,
@@ -1157,13 +1177,20 @@
const accountId = firstNonEmpty( const accountId = firstNonEmpty(
record.account?.id, record.account?.id,
record.account_id, record.account_id,
record.chatgptAccountId,
record.providerSpecificData?.chatgptAccountId,
record.providerSpecificData?.chatgpt_account_id,
record.credentials?.chatgpt_account_id, record.credentials?.chatgpt_account_id,
auth.chatgpt_account_id, auth.chatgpt_account_id,
idAuth.chatgpt_account_id, idAuth.chatgpt_account_id,
record.provider === "codex" ? record.id : undefined,
); );
const userId = firstNonEmpty( const userId = firstNonEmpty(
record.user?.id, record.user?.id,
record.user_id, record.user_id,
record.chatgptUserId,
record.providerSpecificData?.chatgptUserId,
record.providerSpecificData?.chatgpt_user_id,
auth.chatgpt_user_id, auth.chatgpt_user_id,
auth.user_id, auth.user_id,
idAuth.chatgpt_user_id, idAuth.chatgpt_user_id,
@@ -1172,7 +1199,10 @@
const planType = firstNonEmpty( const planType = firstNonEmpty(
record.account?.planType, record.account?.planType,
record.account?.plan_type, record.account?.plan_type,
record.planType,
record.plan_type, record.plan_type,
record.providerSpecificData?.chatgptPlanType,
record.providerSpecificData?.chatgpt_plan_type,
record.credentials?.plan_type, record.credentials?.plan_type,
auth.chatgpt_plan_type, auth.chatgpt_plan_type,
idAuth.chatgpt_plan_type, idAuth.chatgpt_plan_type,
@@ -1180,6 +1210,7 @@
const exportedAt = normalizeTimestamp(options.now || new Date()); const exportedAt = normalizeTimestamp(options.now || new Date());
const expiresIn = getExpiresIn(expiresAt, options.now || new Date()); const expiresIn = getExpiresIn(expiresAt, options.now || new Date());
const sourceName = firstNonEmpty(options.sourceName, "pasted-json"); 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 name = firstNonEmpty(email, sourceName, "ChatGPT Account");
const syntheticIdToken = !inputIdToken const syntheticIdToken = !inputIdToken
? buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) ? buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt)
@@ -1236,10 +1267,34 @@
email_key: toEmailKey(email), email_key: toEmailKey(email),
name, name,
auth_provider: firstNonEmpty(record.authProvider, record.auth_provider), auth_provider: firstNonEmpty(record.authProvider, record.auth_provider),
source: "chatgpt_web_session", source: sourceType,
last_refresh: exportedAt, 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 { return {
sourceName, sourceName,
@@ -1249,6 +1304,7 @@
expiresAt, expiresAt,
cpa, cpa,
cockpit, cockpit,
nineRouter,
sub2apiAccount, sub2apiAccount,
}; };
} }
@@ -1279,6 +1335,12 @@
: state.converted.map((item) => item.cockpit); : 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); return buildSub2apiDocument(state.converted, now);
} }
@@ -1340,7 +1402,7 @@
elements.statErrors.textContent = String(state.skipped.length); elements.statErrors.textContent = String(state.skipped.length);
elements.statFormat.textContent = OUTPUT_LABELS[state.format]; elements.statFormat.textContent = OUTPUT_LABELS[state.format];
elements.outputSubtitle.textContent = `当前输出为 ${OUTPUT_LABELS[state.format]} 导入 JSON。`; 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(); renderAccounts();
renderIssues(); renderIssues();