没有refresh_token free号是没法使用的,但是plus号可以正常调用,只是到期不能刷新
所以叫codex糊了一个,获取到session后转为cpa/sub2的json格式文件,方便导入使用
worker.js如下:
const HTML = String.raw`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CPA / sub2api JSON Converter</title>
<style>
:root {
color-scheme: light;
--bg: #f4f7fb;
--panel: #ffffff;
--line: #d7e0ed;
--line-soft: #e8eef6;
--text: #0f172a;
--muted: #62708a;
--blue: #1f73b7;
--blue-dark: #155f99;
--green: #178a55;
--amber-bg: #fff8e8;
--amber-line: #f3be62;
--amber-text: #8a570f;
--red: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
}
main {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 22px 0 34px;
}
header {
margin-bottom: 18px;
}
h1 {
margin: 0 0 4px;
font-size: 26px;
line-height: 1.2;
letter-spacing: 0;
}
.subtitle {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: var(--muted);
font-size: 14px;
}
.subtitle a {
color: #075cab;
font-weight: 700;
text-decoration: none;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.12fr) minmax(360px, 0.88fr);
gap: 18px;
align-items: start;
}
.panel {
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.08);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 58px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.panel-title {
font-weight: 800;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
button,
select,
input,
textarea {
font: inherit;
}
button {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 7px;
background: #fff;
color: var(--text);
cursor: pointer;
font-weight: 750;
transition: background .15s, border-color .15s, transform .15s;
}
button:hover {
border-color: #adc0d7;
background: #f8fbff;
}
button:active {
transform: translateY(1px);
}
.btn {
padding: 0 14px;
}
.btn-primary {
border-color: var(--blue);
background: var(--blue);
color: #fff;
}
.btn-primary:hover {
border-color: var(--blue-dark);
background: var(--blue-dark);
}
.btn-wide {
width: 100%;
}
.body {
padding: 18px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 800;
}
textarea {
width: 100%;
min-height: 460px;
resize: vertical;
padding: 12px;
border: 1px solid var(--line);
border-radius: 7px;
outline: none;
color: #0b1220;
background: #fff;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
tab-size: 2;
}
textarea:focus,
input:focus,
select:focus {
border-color: #71a9da;
box-shadow: 0 0 0 3px rgba(31, 115, 183, 0.13);
}
.field {
margin-top: 16px;
}
input,
select {
width: 100%;
min-height: 40px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 7px;
outline: none;
background: #fff;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.hint {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.status {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
background: #eff6ff;
color: #175a96;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.status.ok {
background: #eaf8f0;
color: var(--green);
}
.status.warn {
background: #fff1d6;
color: #9a6208;
}
.kv {
margin: 12px 0 14px;
border-top: 1px solid var(--line-soft);
}
.row {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid var(--line-soft);
align-items: center;
}
.key {
color: var(--muted);
font-size: 14px;
}
.value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.notice {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid var(--amber-line);
border-radius: 7px;
background: var(--amber-bg);
color: var(--amber-text);
line-height: 1.5;
}
.notice.error {
border-color: #ffb4ab;
background: #fff0ee;
color: var(--red);
}
.download-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.preview {
margin-top: 16px;
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
pre {
max-height: 260px;
overflow: auto;
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 7px;
background: #fbfdff;
color: #0b1220;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 900px) {
main {
width: min(100vw - 24px, 760px);
padding-top: 16px;
}
.layout,
.grid-2,
.download-grid {
grid-template-columns: 1fr;
}
textarea {
min-height: 340px;
}
}
</style>
</head>
<body>
<main>
<header>
<h1>CPA / sub2api JSON 下载</h1>
<div class="subtitle">
<span>ChatGPT Session JSON 转 CPA Auth JSON / sub2api 导入 JSON</span>
<a href="https://chatgpt.com/api/auth/session" target="_blank" rel="noopener noreferrer">https://chatgpt.com/api/auth/session</a>
</div>
</header>
<div class="layout">
<section class="panel">
<div class="panel-head">
<div class="panel-title">输入</div>
<div class="actions">
<button class="btn" id="pasteBtn" type="button">粘贴</button>
<button class="btn" id="clearBtn" type="button">清空</button>
<button class="btn btn-primary" id="generateBtn" type="button">生成</button>
</div>
</div>
<div class="body">
<label for="source">OpenAI / Codex Session JSON</label>
<textarea id="source" spellcheck="false" placeholder='粘贴 https://chatgpt.com/api/auth/session 返回的 JSON'></textarea>
<div class="grid-2">
<div class="field">
<label for="namePrefix">账号名前缀</label>
<select id="namePrefix">
<option value="codex">codex</option>
<option value="chatgpt">chatgpt</option>
<option value="openai">openai</option>
</select>
</div>
<div class="field">
<label for="fileName">文件名</label>
<input id="fileName" placeholder="可留空自动生成">
</div>
</div>
<div class="hint">转换逻辑在浏览器本地执行,Worker 不接收、不保存你的 token。下载后的 JSON 仍是敏感凭据,请勿公开分享。</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title">结果</div>
<span class="status" id="status">待生成</span>
</div>
<div class="body">
<div class="kv">
<div class="row"><div class="key">CPA 文件名</div><div class="value" id="cpaName">-</div></div>
<div class="row"><div class="key">sub2 文件名</div><div class="value" id="sub2Name">-</div></div>
<div class="row"><div class="key">邮箱</div><div class="value" id="email">-</div></div>
<div class="row"><div class="key">账号 ID</div><div class="value" id="accountId">-</div></div>
<div class="row"><div class="key">套餐</div><div class="value" id="planType">-</div></div>
<div class="row"><div class="key">过期时间</div><div class="value" id="expires">-</div></div>
<div class="row"><div class="key">access_token</div><div class="value" id="accessToken">-</div></div>
<div class="row"><div class="key">session_token</div><div class="value" id="sessionToken">-</div></div>
<div class="row"><div class="key">refresh_token</div><div class="value" id="refreshToken">-</div></div>
<div class="row"><div class="key">id_token</div><div class="value" id="idToken">-</div></div>
</div>
<div id="messages"></div>
<div class="download-grid">
<button class="btn btn-primary" id="downloadCpa" type="button" disabled>下载 CPA JSON</button>
<button class="btn btn-primary" id="downloadSub2" type="button" disabled>下载 sub2 JSON</button>
<button class="btn" id="downloadBoth" type="button" disabled>下载两个文件</button>
<button class="btn" id="copyFileNames" type="button" disabled>复制文件名</button>
</div>
<div class="preview">
<div class="preview-head">
<label for="previewType" style="margin:0">预览</label>
<select id="previewType" style="width:150px">
<option value="cpa">CPA</option>
<option value="sub2">sub2api</option>
</select>
</div>
<pre id="preview">尚未生成</pre>
</div>
</div>
</section>
</div>
</main>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
const state = {
cpa: null,
sub2: null,
cpaFileName: "",
sub2FileName: "",
};
function parseJson(text) {
const trimmed = text.trim();
if (!trimmed) throw new Error("请先粘贴 session JSON。");
try {
return JSON.parse(trimmed);
} catch (error) {
const first = trimmed.indexOf("{");
const last = trimmed.lastIndexOf("}");
if (first >= 0 && last > first) {
return JSON.parse(trimmed.slice(first, last + 1));
}
throw new Error("JSON 格式无效,请确认粘贴的是完整 session JSON。");
}
}
function base64UrlToString(input) {
const base64 = input.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(input.length / 4) * 4, "=");
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function stringToBase64Url(input) {
const bytes = new TextEncoder().encode(input);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function decodeJwtPayload(token) {
if (!token || typeof token !== "string") return {};
const parts = token.split(".");
if (parts.length < 2 || !parts[1]) return {};
try {
return JSON.parse(base64UrlToString(parts[1]));
} catch {
return {};
}
}
function createUnsignedJwt(payload) {
const header = { alg: "none", typ: "JWT", cpa_synthetic: true };
return stringToBase64Url(JSON.stringify(header)) + "." + stringToBase64Url(JSON.stringify(payload)) + ".";
}
function getAuthClaims(accessPayload) {
return accessPayload["https://api.openai.com/auth"] || {};
}
function getProfile(accessPayload) {
return accessPayload["https://api.openai.com/profile"] || {};
}
function cleanNamePart(value) {
return String(value || "")
.trim()
.replace(/[^a-zA-Z0-9@._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function shortId(accountId) {
return String(accountId || "").slice(0, 8) || "account";
}
function isoToUnix(iso) {
const time = Date.parse(iso || "");
return Number.isFinite(time) ? Math.floor(time / 1000) : undefined;
}
function unixToIso(seconds) {
return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : "";
}
function valueStatus(value) {
return value ? "已包含" : "缺失";
}
function buildOutputs(session) {
const accessToken = session.accessToken || session.access_token || "";
const sessionToken = session.sessionToken || session.session_token || "";
const refreshToken = session.refreshToken || session.refresh_token || "";
const accessPayload = decodeJwtPayload(accessToken);
const authClaims = getAuthClaims(accessPayload);
const profile = getProfile(accessPayload);
const user = session.user || {};
const account = session.account || {};
const email = user.email || profile.email || session.email || "";
const accountId = account.id || authClaims.chatgpt_account_id || session.account_id || session.chatgpt_account_id || "";
const planType = account.planType || account.plan_type || authClaims.chatgpt_plan_type || session.plan_type || "unknown";
const chatgptUserId = user.id || authClaims.chatgpt_user_id || authClaims.user_id || session.chatgpt_user_id || "";
const clientId = accessPayload.client_id || session.client_id || "";
const accessExp = Number(accessPayload.exp) || isoToUnix(session.expires) || isoToUnix(session.expired) || undefined;
const idTokenExp = isoToUnix(session.expires) || accessExp;
const issuedAt = Math.floor(Date.now() / 1000);
const idToken = session.id_token || createUnsignedJwt({
iat: issuedAt,
exp: idTokenExp,
"https://api.openai.com/auth": {
chatgpt_account_id: accountId,
chatgpt_plan_type: planType,
chatgpt_user_id: chatgptUserId,
user_id: chatgptUserId,
},
email,
});
if (!email) throw new Error("无法识别邮箱字段:需要 user.email 或 access_token profile.email。");
if (!accountId) throw new Error("无法识别账号 ID:需要 account.id 或 access_token claims。");
if (!accessToken) throw new Error("缺少 accessToken / access_token。");
const prefix = $("namePrefix").value || "codex";
const baseName = cleanNamePart($("fileName").value) || cleanNamePart(prefix + "-" + email + "-" + shortId(accountId));
const cpaFileName = baseName + ".json";
const sub2FileName = "sub2api-" + baseName + ".json";
const nowIso = new Date().toISOString();
const cpa = {
type: "codex",
email,
account_id: accountId,
chatgpt_account_id: accountId,
plan_type: planType,
chatgpt_plan_type: planType,
id_token: idToken,
access_token: accessToken,
refresh_token: refreshToken,
session_token: sessionToken,
last_refresh: nowIso,
expired: session.expires || session.expired || unixToIso(accessExp),
disabled: false,
id_token_synthetic: !session.id_token,
};
const sub2 = {
exported_at: nowIso.replace(/\.\d{3}Z$/, "Z"),
proxies: [],
accounts: [
{
name: baseName,
platform: "openai",
type: "oauth",
credentials: {
access_token: accessToken,
chatgpt_account_id: accountId,
chatgpt_user_id: chatgptUserId,
client_id: clientId,
email,
expires_at: accessExp || null,
id_token: idToken,
organization_id: session.organization_id || "",
plan_type: planType,
refresh_token: refreshToken,
},
extra: { email },
concurrency: 10,
priority: 1,
rate_multiplier: 1,
auto_pause_on_expired: true,
},
],
};
return {
cpa,
sub2,
cpaFileName,
sub2FileName,
summary: {
email,
accountId,
planType,
expires: cpa.expired || "-",
accessToken: valueStatus(accessToken),
sessionToken: valueStatus(sessionToken),
refreshToken: valueStatus(refreshToken),
idToken: session.id_token ? "已包含" : "占位 claims",
},
warnings: [
!refreshToken ? "缺少 refresh_token,access_token 过期后 CPA 不能自动刷新。" : "",
!session.id_token ? "缺少真实 id_token,已根据 account_id / plan_type 写入 CPA 额度面板可解析的占位 claims;上游认证仍使用 access_token。" : "",
!sessionToken ? "缺少 session_token,部分依赖网页会话的工具可能不可用。" : "",
].filter(Boolean),
};
}
function renderMessages(warnings, error) {
const box = $("messages");
if (error) {
box.innerHTML = '<div class="notice error">' + escapeHtml(error) + "</div>";
return;
}
box.innerHTML = warnings.map((text) => '<div class="notice">' + escapeHtml(text) + "</div>").join("");
}
function escapeHtml(input) {
return String(input)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function setText(id, text) {
$(id).textContent = text || "-";
$(id).title = text || "";
}
function setReady(enabled) {
$("downloadCpa").disabled = !enabled;
$("downloadSub2").disabled = !enabled;
$("downloadBoth").disabled = !enabled;
$("copyFileNames").disabled = !enabled;
}
function refreshPreview() {
if (!state.cpa || !state.sub2) {
$("preview").textContent = "尚未生成";
return;
}
const value = $("previewType").value === "sub2" ? state.sub2 : state.cpa;
$("preview").textContent = JSON.stringify(value, null, 2);
}
function generate() {
try {
const session = parseJson($("source").value);
const result = buildOutputs(session);
Object.assign(state, result);
setText("cpaName", result.cpaFileName);
setText("sub2Name", result.sub2FileName);
setText("email", result.summary.email);
setText("accountId", result.summary.accountId);
setText("planType", result.summary.planType);
setText("expires", result.summary.expires);
setText("accessToken", result.summary.accessToken);
setText("sessionToken", result.summary.sessionToken);
setText("refreshToken", result.summary.refreshToken);
setText("idToken", result.summary.idToken);
$("status").textContent = "已生成";
$("status").className = result.warnings.length ? "status warn" : "status ok";
renderMessages(result.warnings);
setReady(true);
refreshPreview();
} catch (error) {
$("status").textContent = "生成失败";
$("status").className = "status warn";
renderMessages([], error.message || String(error));
setReady(false);
}
}
function downloadJson(fileName, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2) + "\n"], { 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();
URL.revokeObjectURL(url);
}
$("generateBtn").addEventListener("click", generate);
$("clearBtn").addEventListener("click", () => {
$("source").value = "";
$("fileName").value = "";
setReady(false);
$("status").textContent = "待生成";
$("status").className = "status";
$("messages").innerHTML = "";
for (const id of ["cpaName", "sub2Name", "email", "accountId", "planType", "expires", "accessToken", "sessionToken", "refreshToken", "idToken"]) {
setText(id, "-");
}
state.cpa = null;
state.sub2 = null;
refreshPreview();
});
$("pasteBtn").addEventListener("click", async () => {
try {
$("source").value = await navigator.clipboard.readText();
generate();
} catch {
renderMessages([], "浏览器未允许读取剪贴板,请手动粘贴。");
}
});
$("downloadCpa").addEventListener("click", () => downloadJson(state.cpaFileName, state.cpa));
$("downloadSub2").addEventListener("click", () => downloadJson(state.sub2FileName, state.sub2));
$("downloadBoth").addEventListener("click", () => {
downloadJson(state.cpaFileName, state.cpa);
setTimeout(() => downloadJson(state.sub2FileName, state.sub2), 150);
});
$("copyFileNames").addEventListener("click", async () => {
const text = state.cpaFileName + "\n" + state.sub2FileName;
await navigator.clipboard.writeText(text);
$("status").textContent = "已复制";
$("status").className = "status ok";
});
$("previewType").addEventListener("change", refreshPreview);
</script>
</body>
</html>`;
export default {
async fetch(request) {
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET, HEAD" },
});
}
return new Response(request.method === "HEAD" ? null : HTML, {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
},
});
},
};
嫌麻烦也可以直接用我这个 https://sub-cpa.112525.xyz/
2 个帖子 - 2 位参与者