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.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 过期后不能自动刷新。
## 本地使用
+68 -6
View File
@@ -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 @@
<span>CPA</span>
<span>sub2api</span>
<span>Cockpit</span>
<span>9router</span>
</div>
</div>
<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="cpa" aria-pressed="false">CPA</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 class="actions">
<button class="button button-secondary" id="copy-output" type="button" disabled>复制输出</button>
@@ -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();