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.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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user