Files
GPTSession2CPAandSub2API/docs/index.html
T
2026-05-08 23:28:57 +08:00

1307 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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</title>
<meta name="theme-color" content="#24505a" />
<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;
}
.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;
}
.toolbar {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.segmented {
display: inline-grid;
grid-template-columns: repeat(2, 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;
}
.dropzone {
display: grid;
min-height: 128px;
place-items: center;
margin-bottom: 12px;
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
background: var(--surface-soft);
color: var(--muted);
text-align: center;
}
.dropzone.is-dragover {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.dropzone strong {
display: block;
margin-bottom: 4px;
color: var(--text);
}
.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;
}
.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 -> CPA / sub2api</h1>
</div>
<div class="meta-row" aria-label="处理方式">
<span class="meta-chip">浏览器本地解析</span>
<span class="meta-chip">不上传 token</span>
<span class="meta-chip">不写入存储</span>
</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>
</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="dropzone" id="dropzone" tabindex="0" role="button">
<div>
<strong>拖入 JSON 文件</strong>
<span>也可以点击选择文件</span>
</div>
</div>
<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":{"name":"Mark Anderson","email":"mark@example.com"},"expires":"2026-08-06T14:29:36.155Z","account":{"id":"...","planType":"free"},"accessToken":"..."}'
></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 输出只包含 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",
};
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"),
dropzone: document.querySelector("#dropzone"),
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",
name: "Mark Anderson",
email: "mark@example.com",
},
expires: "2026-08-06T14:29:36.155Z",
account: {
id: "00000000-0000-4000-9000-000000000000",
planType: "free",
},
accessToken: "paste-real-access-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 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 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.credentials?.access_token);
const hasUser = isPlainObject(item.user) || firstNonEmpty(item.email, item.name);
if (token && hasUser) {
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.credentials?.access_token,
);
if (!accessToken) {
throw new Error("缺少 accessToken");
}
const payload = parseJwtPayload(accessToken);
const auth = getOpenAIAuthSection(payload);
const profile = getOpenAIProfileSection(payload);
const expiresAt = firstNonEmpty(
normalizeTimestamp(record.expires),
normalizeTimestamp(record.expired),
normalizeTimestamp(record.expires_at),
payload ? timestampFromUnixSeconds(payload.exp) : undefined,
);
const email = firstNonEmpty(
record.user?.email,
record.email,
record.credentials?.email,
profile.email,
payload?.email,
);
const name = firstNonEmpty(
record.user?.name,
record.name,
profile.name,
email,
"ChatGPT Account",
);
const accountId = firstNonEmpty(
record.account?.id,
record.account_id,
record.credentials?.chatgpt_account_id,
auth.chatgpt_account_id,
);
const userId = firstNonEmpty(
record.user?.id,
record.user_id,
auth.chatgpt_user_id,
auth.user_id,
);
const planType = firstNonEmpty(
record.account?.planType,
record.account?.plan_type,
record.plan_type,
record.credentials?.plan_type,
auth.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 cpa = stripUnavailable({
type: "codex",
access_token: accessToken,
account_id: accountId,
email,
name,
expired: expiresAt,
last_refresh: exportedAt,
plan_type: planType,
});
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: "chatgpt_web_session",
last_refresh: exportedAt,
},
});
return {
sourceName,
sourcePath: options.sourcePath,
email,
name,
expiresAt,
cpa,
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);
}
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 === "sub2api" ? "none" : "block";
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.dropzone.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();
});
["dragenter", "dragover"].forEach((eventName) => {
elements.dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
elements.dropzone.classList.add("is-dragover");
});
});
["dragleave", "drop"].forEach((eventName) => {
elements.dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
elements.dropzone.classList.remove("is-dragover");
});
});
elements.dropzone.addEventListener("drop", (event) => {
readFiles(event.dataTransfer.files);
});
elements.dropzone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
elements.fileInput.click();
}
});
updateOutput();
})();
</script>
</body>
</html>