Files
2026-05-16 21:26:32 +08:00

1606 lines
50 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatGPT Session -> CPA / sub2api / Cockpit</title>
<meta name="theme-color" content="#24505a" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" sizes="any" />
<style>
:root {
color-scheme: light;
--bg: #f5f7f8;
--surface: #ffffff;
--surface-soft: #f0f4f5;
--line: #d9e1e4;
--line-strong: #b9c8ce;
--text: #142126;
--muted: #64747c;
--accent: #24505a;
--accent-strong: #173941;
--accent-soft: #e3f0f2;
--success: #1e6b50;
--danger: #a33b3b;
--warning: #8a5a14;
--shadow: 0 16px 44px rgba(20, 33, 38, 0.08);
--radius: 8px;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "Inter", "Avenir Next", "Helvetica Neue", "PingFang SC", "Noto Sans SC", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
font-family: var(--sans);
color: var(--text);
background:
linear-gradient(180deg, rgba(36, 80, 90, 0.06), transparent 240px),
var(--bg);
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
}
.app {
width: min(1280px, calc(100vw - 32px));
margin: 0 auto;
padding: 22px 0 28px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.title-block {
min-width: 0;
}
.eyebrow {
margin: 0 0 7px;
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(1.65rem, 3vw, 2.7rem);
line-height: 1.08;
letter-spacing: 0;
}
.title-targets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.title-targets span {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--accent);
font-size: 0.86rem;
font-weight: 800;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.meta-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font-size: 0.86rem;
white-space: nowrap;
}
.github-link {
gap: 7px;
color: var(--text);
text-decoration: none;
}
.github-link:hover {
border-color: var(--line-strong);
background: var(--surface-soft);
}
.github-link svg {
width: 18px;
height: 18px;
flex: 0 0 auto;
fill: currentColor;
}
.toolbar {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.segmented {
display: inline-grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(480px, 100%);
padding: 4px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.segmented button {
min-height: 38px;
border-radius: 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.segmented button[aria-pressed="true"] {
background: var(--accent);
color: #fff;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 9px 13px;
border-radius: var(--radius);
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.button-primary {
background: var(--accent);
color: #fff;
}
.button-primary:hover {
background: var(--accent-strong);
}
.button-secondary,
.button-ghost {
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
}
.button-secondary:hover,
.button-ghost:hover {
border-color: var(--line-strong);
background: var(--surface-soft);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: stretch;
}
.panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.panel-head h2 {
margin: 0;
font-size: 1rem;
line-height: 1.35;
}
.panel-head p {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.45;
}
.panel-body {
padding: 14px 16px 16px;
}
.session-guide {
margin: 0 0 12px;
padding: 13px 14px;
border: 1px solid rgba(36, 80, 90, 0.34);
border-left: 5px solid var(--accent);
border-radius: var(--radius);
background: #eaf5f7;
color: var(--text);
line-height: 1.5;
}
.session-guide strong {
display: block;
margin-bottom: 4px;
color: var(--accent-strong);
font-size: 0.98rem;
}
.session-guide p {
margin: 0;
color: #33464d;
font-size: 0.9rem;
}
.session-guide a {
color: var(--accent-strong);
font-family: var(--mono);
font-weight: 800;
overflow-wrap: anywhere;
}
.session-guide .session-warning {
margin-top: 7px;
color: var(--danger);
font-weight: 700;
}
.telegram-card {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px 14px;
align-items: start;
margin-bottom: 12px;
padding: 14px 14px 52px;
border: 1px solid rgba(0, 136, 204, 0.28);
border-radius: var(--radius);
background: #f1f9ff;
color: #173d52;
text-decoration: none;
}
.telegram-card:hover {
border-color: rgba(0, 136, 204, 0.5);
background: #eaf6ff;
}
.telegram-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: var(--radius);
background: #0088cc;
color: #fff;
}
.telegram-icon svg {
width: 27px;
height: 27px;
fill: currentColor;
}
.telegram-copy strong {
display: block;
margin-bottom: 4px;
color: #12384d;
font-size: 0.98rem;
line-height: 1.35;
}
.telegram-copy span {
display: block;
color: #3f5f70;
font-size: 0.88rem;
line-height: 1.45;
}
.telegram-action {
position: absolute;
right: 14px;
bottom: 14px;
min-height: 32px;
padding: 6px 12px;
border-radius: 6px;
background: #0088cc;
color: #fff;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 8px 18px rgba(0, 136, 204, 0.22);
}
.input-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
margin-bottom: 12px;
}
.textarea-shell {
position: relative;
}
textarea {
display: block;
width: 100%;
min-height: 430px;
resize: vertical;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
background: #fbfcfd;
color: var(--text);
font-family: var(--mono);
font-size: 0.84rem;
line-height: 1.5;
white-space: pre;
}
textarea:focus {
outline: 2px solid rgba(36, 80, 90, 0.22);
border-color: var(--accent);
}
textarea[readonly] {
background: #f8fafb;
}
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.stat {
min-height: 72px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-soft);
}
.stat-value {
font-family: var(--mono);
font-size: 1.22rem;
font-weight: 800;
}
.stat-label {
margin-top: 4px;
color: var(--muted);
font-size: 0.82rem;
}
.accounts {
overflow: auto;
max-height: 212px;
margin-bottom: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 0.86rem;
}
th,
td {
padding: 9px 10px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: var(--surface-soft);
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
}
tr:last-child td {
border-bottom: 0;
}
.col-name {
width: 23%;
}
.col-email {
width: 31%;
}
.col-expiry {
width: 24%;
}
.cell-clip {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
color: var(--muted);
}
.status-line {
min-height: 22px;
margin-top: 10px;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.45;
}
.status-line.is-ok {
color: var(--success);
}
.status-line.is-error {
color: var(--danger);
}
.issues {
display: none;
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid rgba(163, 59, 59, 0.22);
border-radius: var(--radius);
background: #fff7f7;
color: var(--danger);
font-size: 0.88rem;
line-height: 1.45;
}
.issues.is-visible {
display: block;
}
.notice {
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid rgba(138, 90, 20, 0.22);
border-radius: var(--radius);
background: #fffaf0;
color: var(--warning);
font-size: 0.88rem;
line-height: 1.45;
}
.hidden {
display: none !important;
}
@media (max-width: 960px) {
.topbar,
.toolbar,
.workspace {
grid-template-columns: 1fr;
}
.topbar {
align-items: flex-start;
}
.meta-row,
.actions {
justify-content: flex-start;
}
.workspace {
display: block;
}
.panel + .panel {
margin-top: 12px;
}
}
@media (max-width: 620px) {
.app {
width: min(100vw - 20px, 1280px);
padding-top: 14px;
}
.toolbar {
display: block;
}
.segmented {
width: 100%;
margin-bottom: 10px;
}
.actions,
.input-actions {
display: grid;
grid-template-columns: 1fr;
}
.button {
width: 100%;
}
.summary {
grid-template-columns: 1fr;
}
.telegram-card {
grid-template-columns: auto minmax(0, 1fr);
}
.col-expiry,
.col-type,
td:nth-child(3),
td:nth-child(4),
th:nth-child(3),
th:nth-child(4) {
display: none;
}
.col-name,
.col-email {
width: 50%;
}
textarea {
min-height: 320px;
font-size: 0.8rem;
}
}
</style>
</head>
<body>
<main class="app">
<header class="topbar">
<div class="title-block">
<p class="eyebrow">Local JSON Converter</p>
<h1>ChatGPT Session Converter</h1>
<div class="title-targets" aria-label="支持输出格式">
<span>CPA</span>
<span>sub2api</span>
<span>Cockpit</span>
<span>9router</span>
</div>
</div>
<div class="meta-row" aria-label="处理方式">
<span class="meta-chip">浏览器本地解析</span>
<span class="meta-chip">不上传 token</span>
<span class="meta-chip">不写入存储</span>
<a
class="meta-chip github-link"
href="https://github.com/yynxxxxx/GPTSession2CPAandSub2API"
target="_blank"
rel="noreferrer"
aria-label="本页面代码开源"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58v-2.24c-3.34.73-4.04-1.42-4.04-1.42-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.21.08 1.85 1.24 1.85 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.61-2.66-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23A11.5 11.5 0 0 1 12 5.78c1.02 0 2.05.14 3.01.41 2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.49 5.93.43.37.82 1.1.82 2.22v3.3c0 .32.22.7.83.58A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
</svg>
<span>本页面代码开源</span>
</a>
</div>
</header>
<section class="toolbar" aria-label="输出控制">
<div class="segmented" role="group" aria-label="输出格式">
<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>
<button class="button button-primary" id="download-output" type="button" disabled>下载 JSON</button>
</div>
</section>
<section class="workspace">
<section class="panel" aria-labelledby="input-title">
<div class="panel-head">
<div>
<h2 id="input-title">Session JSON</h2>
<p>粘贴 ChatGPT Web session,或拖入一个或多个 JSON 文件。</p>
</div>
</div>
<div class="panel-body">
<div class="session-guide">
<strong>Session 数据从这里获取</strong>
<p>
先在浏览器登录 ChatGPT,然后打开
<a href="https://chatgpt.com/api/auth/session" target="_blank" rel="noreferrer">https://chatgpt.com/api/auth/session</a>
,复制页面显示的整段 JSON,粘贴到下方输入框。
</p>
<p class="session-warning">这段 JSON 包含 accessToken 和 sessionToken,等同敏感登录凭证,不要发给别人。</p>
</div>
<a class="telegram-card" href="https://t.me/Geminivip1" target="_blank" rel="noreferrer">
<span class="telegram-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M22.44 2.13c.42-.17.85.19.74.63l-3.28 15.47c-.1.49-.66.69-1.05.38l-4.98-3.67-2.4 2.31c-.27.27-.49.49-1.01.49l.36-5.1 9.29-8.4c.4-.36-.09-.56-.63-.2L7.99 11.27 3.04 9.72c-1.08-.34-1.1-1.08.22-1.59l19.18-6Z" />
</svg>
</span>
<span class="telegram-copy">
<strong>加入 Telegram 群免费获取 GPT 撸羊毛信息</strong>
<span>本工具主要针对 Plus 账号适用,Free 账号即使转换了也没有权限调用 GPT 模型。GoPay 拉闸了,没法每天发 Plus 了;加入 Telegram 群免费获取 GPT 撸羊毛信息,然后配合本工具导入 CPA or Sub2API 使用。</span>
</span>
<span class="telegram-action">加入群组</span>
</a>
<input class="hidden" id="file-input" type="file" accept=".json,application/json" multiple />
<div class="input-actions">
<button class="button button-secondary" id="pick-files" type="button">选择文件</button>
<button class="button button-secondary" id="load-example" type="button">填入示例结构</button>
<button class="button button-ghost" id="clear-input" type="button">清空</button>
</div>
<textarea
id="session-input"
spellcheck="false"
placeholder='{"user":{"email":"mark@example.com"},"expires":"2026-08-06T14:29:36.155Z","account":{"id":"...","planType":"plus"},"accessToken":"...","sessionToken":"..."}'
></textarea>
<div class="status-line" id="input-status">等待输入。</div>
</div>
</section>
<section class="panel" aria-labelledby="output-title">
<div class="panel-head">
<div>
<h2 id="output-title">转换结果</h2>
<p id="output-subtitle">当前输出为 sub2api 导入 JSON。</p>
</div>
</div>
<div class="panel-body">
<p class="notice" id="cpa-notice">
ChatGPT Web session 一般没有 `refresh_token`;缺少真实 `id_token` 时会构造 CPA/Cockpit 可解析的占位 claims,实际调用仍依赖 access token。
</p>
<div class="summary" aria-label="转换统计">
<div class="stat">
<div class="stat-value" id="stat-count">0</div>
<div class="stat-label">账号数</div>
</div>
<div class="stat">
<div class="stat-value" id="stat-format">sub2api</div>
<div class="stat-label">输出格式</div>
</div>
<div class="stat">
<div class="stat-value" id="stat-errors">0</div>
<div class="stat-label">跳过项</div>
</div>
</div>
<div class="accounts" aria-label="账号预览">
<table>
<colgroup>
<col class="col-name" />
<col class="col-email" />
<col class="col-expiry" />
<col class="col-type" />
</colgroup>
<thead>
<tr>
<th>名称</th>
<th>邮箱</th>
<th>过期时间</th>
<th>来源</th>
</tr>
</thead>
<tbody id="account-body">
<tr>
<td colspan="4" class="empty">暂无可转换账号。</td>
</tr>
</tbody>
</table>
</div>
<div class="issues" id="issues"></div>
<textarea id="output" readonly spellcheck="false" placeholder="转换后会显示 JSON。"></textarea>
<div class="status-line" id="output-status">暂无输出。</div>
</div>
</section>
</section>
</main>
<script>
(() => {
const OUTPUT_LABELS = {
sub2api: "sub2api",
cpa: "CPA",
cockpit: "Cockpit",
"9router": "9router",
};
const state = {
format: "sub2api",
sessions: [],
converted: [],
skipped: [],
outputText: "",
};
const elements = {
accountBody: document.querySelector("#account-body"),
clearInput: document.querySelector("#clear-input"),
copyOutput: document.querySelector("#copy-output"),
cpaNotice: document.querySelector("#cpa-notice"),
downloadOutput: document.querySelector("#download-output"),
fileInput: document.querySelector("#file-input"),
formatButtons: Array.from(document.querySelectorAll("[data-format]")),
input: document.querySelector("#session-input"),
inputStatus: document.querySelector("#input-status"),
issues: document.querySelector("#issues"),
loadExample: document.querySelector("#load-example"),
output: document.querySelector("#output"),
outputStatus: document.querySelector("#output-status"),
outputSubtitle: document.querySelector("#output-subtitle"),
pickFiles: document.querySelector("#pick-files"),
statCount: document.querySelector("#stat-count"),
statErrors: document.querySelector("#stat-errors"),
statFormat: document.querySelector("#stat-format"),
};
const exampleSession = {
user: {
id: "user-example",
email: "mark@example.com",
},
expires: "2026-08-06T14:29:36.155Z",
account: {
id: "00000000-0000-4000-9000-000000000000",
planType: "plus",
},
accessToken: "paste-real-access-token-here",
sessionToken: "paste-real-session-token-here",
authProvider: "openai",
};
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function firstNonEmpty(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim() !== "") {
return value.trim();
}
}
return undefined;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function decodeBase64Url(value) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function bytesToBase64Url(bytes) {
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function encodeBase64UrlJson(value) {
return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value)));
}
function parseJwtPayload(token) {
if (typeof token !== "string" || token.trim() === "") {
return undefined;
}
const segments = token.split(".");
if (segments.length < 2) {
return undefined;
}
try {
return JSON.parse(decodeBase64Url(segments[1]));
} catch {
return undefined;
}
}
function getOpenAIAuthSection(payload) {
if (!isPlainObject(payload)) {
return {};
}
const auth = payload["https://api.openai.com/auth"];
return isPlainObject(auth) ? auth : {};
}
function getOpenAIProfileSection(payload) {
if (!isPlainObject(payload)) {
return {};
}
const profile = payload["https://api.openai.com/profile"];
return isPlainObject(profile) ? profile : {};
}
function normalizeTimestamp(value) {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString();
}
if (typeof value === "number" && Number.isFinite(value)) {
const milliseconds = value > 1e11 ? value : value * 1000;
const date = new Date(milliseconds);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
if (typeof value !== "string" || value.trim() === "") {
return undefined;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function timestampFromUnixSeconds(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return undefined;
}
const date = new Date(numeric * 1000);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function epochSecondsFromValue(value) {
if (value === undefined || value === null || value === "") {
return 0;
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return Math.trunc(numeric > 1e11 ? numeric / 1000 : numeric);
}
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? Math.trunc(parsed / 1000) : 0;
}
function buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) {
if (!accountId) {
return undefined;
}
const now = Math.trunc(Date.now() / 1000);
const authInfo = { chatgpt_account_id: accountId };
const expires = epochSecondsFromValue(expiresAt) || now + 90 * 24 * 60 * 60;
if (planType) {
authInfo.chatgpt_plan_type = planType;
}
if (userId) {
authInfo.chatgpt_user_id = userId;
authInfo.user_id = userId;
}
const payload = {
iat: now,
exp: expires,
"https://api.openai.com/auth": authInfo,
};
if (email) {
payload.email = email;
}
return `${encodeBase64UrlJson({ alg: "none", typ: "JWT", cpa_synthetic: true })}.${encodeBase64UrlJson(payload)}.`;
}
function getExpiresIn(expiresAt, now = new Date()) {
if (!expiresAt) {
return undefined;
}
const expiresMs = new Date(expiresAt).getTime();
if (Number.isNaN(expiresMs)) {
return undefined;
}
return Math.max(0, Math.floor((expiresMs - now.getTime()) / 1000));
}
function stripUnavailable(value) {
if (Array.isArray(value)) {
return value.map(stripUnavailable).filter((item) => item !== undefined);
}
if (isPlainObject(value)) {
const entries = Object.entries(value)
.map(([key, item]) => [key, stripUnavailable(item)])
.filter(([, item]) => item !== undefined);
return entries.length ? Object.fromEntries(entries) : undefined;
}
if (value === undefined || value === null || value === "") {
return undefined;
}
return value;
}
function toEmailKey(email) {
if (typeof email !== "string") {
return undefined;
}
return email
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function sanitizeFileToken(value, fallback = "chatgpt-session") {
const base = firstNonEmpty(value, fallback) || fallback;
return base
.replace(/\.[^.]+$/u, "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase()
.slice(0, 80) || fallback;
}
function getTimestampToken(date = new Date()) {
const pad = (value) => String(value).padStart(2, "0");
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
].join("-") + "_" + [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join("-");
}
function formatDisplayDate(value) {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const pad = (item) => String(item).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function collectSessionLikeObjects(value, sourceName = "pasted-json") {
const found = [];
const visited = new WeakSet();
function visit(item, path) {
if (!isPlainObject(item) && !Array.isArray(item)) {
return;
}
if (isPlainObject(item)) {
if (visited.has(item)) {
return;
}
visited.add(item);
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;
}
for (const [key, child] of Object.entries(item)) {
if (key === "accessToken" || key === "access_token" || key === "sessionToken") {
continue;
}
visit(child, `${path}.${key}`);
}
return;
}
item.forEach((child, index) => visit(child, `${path}[${index}]`));
}
visit(value, "$");
return found;
}
function parseInputDocuments(text) {
if (typeof text !== "string" || text.trim() === "") {
return [];
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (error) {
throw new Error(`JSON 解析失败:${error.message}`);
}
return collectSessionLikeObjects(parsed);
}
function convertSession(record, options = {}) {
if (!isPlainObject(record)) {
throw new Error("session 不是 JSON 对象");
}
const accessToken = firstNonEmpty(
record.accessToken,
record.access_token,
record.token?.accessToken,
record.token?.access_token,
record.credentials?.accessToken,
record.credentials?.access_token,
);
if (!accessToken) {
throw new Error("缺少 accessToken");
}
const sessionToken = firstNonEmpty(
record.sessionToken,
record.session_token,
record.token?.sessionToken,
record.token?.session_token,
record.credentials?.session_token,
);
const refreshToken = firstNonEmpty(
record.refreshToken,
record.refresh_token,
record.token?.refreshToken,
record.token?.refresh_token,
record.credentials?.refresh_token,
);
const inputIdToken = firstNonEmpty(
record.idToken,
record.id_token,
record.token?.idToken,
record.token?.id_token,
record.credentials?.id_token,
);
const payload = parseJwtPayload(accessToken);
const idPayload = parseJwtPayload(inputIdToken);
const auth = getOpenAIAuthSection(payload);
const idAuth = getOpenAIAuthSection(idPayload);
const profile = getOpenAIProfileSection(payload);
const expiresAt = firstNonEmpty(
payload ? timestampFromUnixSeconds(payload.exp) : undefined,
normalizeTimestamp(record.expires),
normalizeTimestamp(record.expiresAt),
normalizeTimestamp(record.expired),
normalizeTimestamp(record.expires_at),
);
const email = firstNonEmpty(
record.user?.email,
record.email,
record.credentials?.email,
record.providerSpecificData?.email,
profile.email,
idPayload?.email,
payload?.email,
);
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,
idAuth.user_id,
);
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,
);
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)
: undefined;
const idToken = firstNonEmpty(inputIdToken, syntheticIdToken);
const cpa = Object.fromEntries(Object.entries({
type: "codex",
account_id: accountId,
chatgpt_account_id: accountId,
email,
name,
plan_type: planType,
chatgpt_plan_type: planType,
id_token: idToken,
id_token_synthetic: Boolean(syntheticIdToken) || undefined,
access_token: accessToken,
refresh_token: refreshToken || "",
session_token: sessionToken,
last_refresh: exportedAt,
expired: expiresAt,
disabled: Boolean(record.disabled) || undefined,
}).filter(([, value]) => value !== undefined && value !== null));
const cockpit = {
type: "codex",
id_token: idToken,
access_token: accessToken,
refresh_token: refreshToken || "",
account_id: accountId,
last_refresh: exportedAt,
email,
expired: expiresAt,
account_note: firstNonEmpty(record.account_note, record.accountInfo, record.account_info, record.note, record.notes, record.remark),
};
const sub2apiAccount = stripUnavailable({
name: firstNonEmpty(name, email, sourceName, "ChatGPT Account"),
platform: "openai",
type: "oauth",
concurrency: 10,
priority: 1,
credentials: {
access_token: accessToken,
chatgpt_account_id: accountId,
chatgpt_user_id: userId,
email,
expires_at: expiresAt,
expires_in: expiresIn,
plan_type: planType,
},
extra: {
email,
email_key: toEmailKey(email),
name,
auth_provider: firstNonEmpty(record.authProvider, record.auth_provider),
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,
sourcePath: options.sourcePath,
email,
name,
expiresAt,
cpa,
cockpit,
nineRouter,
sub2apiAccount,
};
}
function buildSub2apiDocument(converted, now = new Date()) {
return {
exported_at: normalizeTimestamp(now),
proxies: [],
accounts: converted.map((item) => item.sub2apiAccount),
};
}
function buildOutputDocument() {
const now = new Date();
if (state.format === "sub2api") {
return buildSub2apiDocument(state.converted, now);
}
if (state.format === "cpa") {
return state.converted.length === 1
? state.converted[0].cpa
: state.converted.map((item) => item.cpa);
}
if (state.format === "cockpit") {
return state.converted.length === 1
? state.converted[0].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);
}
function convertFromText(text) {
const sources = parseInputDocuments(text);
const converted = [];
const skipped = [];
const now = new Date();
sources.forEach((item, index) => {
try {
converted.push(convertSession(item.value, {
now,
sourceName: item.sourceName,
sourcePath: item.path || `$[${index}]`,
}));
} catch (error) {
skipped.push({
sourceName: item.sourceName,
path: item.path,
reason: error instanceof Error ? error.message : "无法转换",
});
}
});
if (!sources.length) {
skipped.push({
sourceName: "pasted-json",
path: "$",
reason: "未找到包含 accessToken 和 user/email 的 session 对象",
});
}
state.converted = converted;
state.skipped = skipped;
state.sessions = sources;
updateOutput();
}
function setStatus(element, text, tone = "") {
element.textContent = text;
element.classList.toggle("is-ok", tone === "ok");
element.classList.toggle("is-error", tone === "error");
}
function updateOutput() {
const hasConverted = state.converted.length > 0;
let outputText = "";
if (hasConverted) {
outputText = JSON.stringify(buildOutputDocument(), null, 2);
}
state.outputText = outputText;
elements.output.value = outputText;
elements.copyOutput.disabled = !outputText;
elements.downloadOutput.disabled = !outputText;
elements.statCount.textContent = String(state.converted.length);
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 === "cpa" || state.format === "cockpit" ? "block" : "none";
renderAccounts();
renderIssues();
if (outputText) {
setStatus(elements.outputStatus, `已生成 ${state.converted.length} 个账号。`, "ok");
} else {
setStatus(elements.outputStatus, "暂无输出。", state.skipped.length ? "error" : "");
}
}
function renderAccounts() {
if (!state.converted.length) {
elements.accountBody.innerHTML = '<tr><td colspan="4" class="empty">暂无可转换账号。</td></tr>';
return;
}
elements.accountBody.innerHTML = state.converted.map((item) => `
<tr>
<td><div class="cell-clip" title="${escapeHtml(item.name)}">${escapeHtml(item.name || "-")}</div></td>
<td><div class="cell-clip" title="${escapeHtml(item.email)}">${escapeHtml(item.email || "-")}</div></td>
<td><div class="cell-clip" title="${escapeHtml(item.expiresAt)}">${escapeHtml(formatDisplayDate(item.expiresAt) || "-")}</div></td>
<td><div class="cell-clip" title="${escapeHtml(item.sourceName)}">${escapeHtml(item.sourceName || "pasted-json")}</div></td>
</tr>
`).join("");
}
function renderIssues() {
if (!state.skipped.length) {
elements.issues.classList.remove("is-visible");
elements.issues.textContent = "";
return;
}
elements.issues.classList.add("is-visible");
elements.issues.innerHTML = state.skipped
.map((item) => `<div>${escapeHtml(item.sourceName || "input")} ${escapeHtml(item.path || "")}: ${escapeHtml(item.reason)}</div>`)
.join("");
}
function scheduleConvert() {
const text = elements.input.value;
if (!text.trim()) {
state.converted = [];
state.skipped = [];
state.sessions = [];
updateOutput();
setStatus(elements.inputStatus, "等待输入。");
return;
}
try {
convertFromText(text);
if (state.converted.length) {
setStatus(elements.inputStatus, `解析完成:${state.converted.length} 个账号,跳过 ${state.skipped.length} 项。`, "ok");
} else {
setStatus(elements.inputStatus, "没有可转换账号。", "error");
}
} catch (error) {
state.converted = [];
state.skipped = [{
sourceName: "pasted-json",
path: "$",
reason: error instanceof Error ? error.message : "JSON 解析失败",
}];
state.outputText = "";
updateOutput();
setStatus(elements.inputStatus, error instanceof Error ? error.message : "JSON 解析失败", "error");
}
}
function downloadOutput() {
if (!state.outputText) {
return;
}
const first = state.converted[0];
const base = sanitizeFileToken(first?.email || first?.name || state.format);
const fileName = `${base}.${state.format}.${getTimestampToken()}.json`;
const blob = new Blob([state.outputText], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function copyOutput() {
if (!state.outputText) {
return;
}
try {
await navigator.clipboard.writeText(state.outputText);
setStatus(elements.outputStatus, "已复制到剪贴板。", "ok");
} catch {
elements.output.select();
document.execCommand("copy");
setStatus(elements.outputStatus, "已复制到剪贴板。", "ok");
}
}
async function readFiles(files) {
const jsonFiles = Array.from(files).filter((file) => file.name.toLowerCase().endsWith(".json"));
if (!jsonFiles.length) {
setStatus(elements.inputStatus, "没有选择 JSON 文件。", "error");
return;
}
const documents = [];
const skipped = [];
for (const file of jsonFiles) {
try {
const text = await file.text();
const parsed = JSON.parse(text);
const found = collectSessionLikeObjects(parsed, file.webkitRelativePath || file.name);
if (!found.length) {
skipped.push({
sourceName: file.webkitRelativePath || file.name,
path: "$",
reason: "未找到包含 accessToken 和 user/email 的 session 对象",
});
}
documents.push(...found);
} catch (error) {
skipped.push({
sourceName: file.webkitRelativePath || file.name,
path: "$",
reason: error instanceof Error ? error.message : "无法读取文件",
});
}
}
const now = new Date();
const converted = [];
const convertSkipped = [...skipped];
documents.forEach((item) => {
try {
converted.push(convertSession(item.value, {
now,
sourceName: item.sourceName,
sourcePath: item.path,
}));
} catch (error) {
convertSkipped.push({
sourceName: item.sourceName,
path: item.path,
reason: error instanceof Error ? error.message : "无法转换",
});
}
});
state.sessions = documents;
state.converted = converted;
state.skipped = convertSkipped;
elements.input.value = documents.length === 1
? JSON.stringify(documents[0].value, null, 2)
: JSON.stringify(documents.map((item) => item.value), null, 2);
updateOutput();
setStatus(elements.inputStatus, `读取 ${jsonFiles.length} 个文件,生成 ${converted.length} 个账号,跳过 ${convertSkipped.length} 项。`, converted.length ? "ok" : "error");
}
elements.formatButtons.forEach((button) => {
button.addEventListener("click", () => {
state.format = button.dataset.format;
elements.formatButtons.forEach((item) => {
item.setAttribute("aria-pressed", String(item === button));
});
updateOutput();
});
});
elements.input.addEventListener("input", scheduleConvert);
elements.copyOutput.addEventListener("click", copyOutput);
elements.downloadOutput.addEventListener("click", downloadOutput);
elements.pickFiles.addEventListener("click", () => elements.fileInput.click());
elements.fileInput.addEventListener("change", (event) => {
readFiles(event.target.files);
event.target.value = "";
});
elements.clearInput.addEventListener("click", () => {
elements.input.value = "";
scheduleConvert();
});
elements.loadExample.addEventListener("click", () => {
elements.input.value = JSON.stringify(exampleSession, null, 2);
scheduleConvert();
});
updateOutput();
})();
</script>
</body>
</html>