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:
@@ -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 过期后不能自动刷新。
|
||||
|
||||
## 本地使用
|
||||
|
||||
+68
-6
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user