Files
grok-free-register-oss/tools/GrokSession2CPAAndSub2API/index.html
chaos d10009d639 Initial commit: grok-free-register-oss
Open-source Grok free registration CLI, xai_enroller auth pipeline,
local auth service, tests and docs.
2026-07-16 21:04:05 +08:00

903 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>Grok Session → CPA / sub2api</title>
<meta name="theme-color" content="#111827" />
<style>
:root {
color-scheme: light dark;
--bg: #0b1220;
--surface: #121a2b;
--surface-soft: #1a2438;
--line: #2a3650;
--text: #e8eefc;
--muted: #93a0bc;
--accent: #38bdf8;
--accent-strong: #0ea5e9;
--success: #34d399;
--danger: #f87171;
--warning: #fbbf24;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "Inter", "Avenir Next", "Helvetica Neue", "PingFang SC", "Noto Sans SC", sans-serif;
--radius: 10px;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 28%),
linear-gradient(180deg, #0b1220, #0a101b 40%, #070b14);
}
button, textarea, input { font: inherit; }
button { border: 0; cursor: pointer; }
.app { width: min(1280px, calc(100vw - 28px)); margin: 0 auto; padding: 22px 0 36px; }
.topbar { display: flex; justify-content: space-between; gap: 16px; align-items: flex-end; margin-bottom: 16px; }
.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(1.5rem, 3vw, 2.3rem); line-height: 1.1; }
.subtitle { margin: 10px 0 0; color: var(--muted); line-height: 1.5; max-width: 70ch; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.chip {
display: inline-flex; align-items: center; min-height: 32px; padding: 6px 10px;
border: 1px solid var(--line); border-radius: 999px; background: rgba(18, 26, 43, 0.9); color: var(--muted); font-size: 0.84rem;
}
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.segmented {
display: inline-grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px;
width: min(760px, 100%); padding: 4px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
}
.segmented button {
min-height: 40px; border-radius: 8px; background: transparent; color: var(--muted); padding: 0 10px;
}
.segmented button[aria-pressed="true"] { background: var(--accent-strong); color: #041018; font-weight: 800; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.button {
display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 14px;
border-radius: var(--radius); transition: 0.15s ease;
}
.button-primary { background: var(--accent-strong); color: #041018; font-weight: 800; }
.button-primary:hover { filter: brightness(1.08); }
.button-secondary { border: 1px solid var(--line); background: var(--surface); color: var(--text); }
.button-secondary:hover { background: var(--surface-soft); }
.button:disabled { opacity: 0.5; cursor: not-allowed; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.panel {
min-width: 0; border: 1px solid var(--line); border-radius: var(--radius);
background: rgba(18, 26, 43, 0.92); box-shadow: var(--shadow);
}
.panel-head { display: flex; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
.panel-head h2 { margin: 0; font-size: 1rem; }
.panel-head p { margin: 4px 0 0; color: var(--muted); font-size: 0.88rem; line-height: 1.45; }
.panel-body { padding: 14px 16px 16px; }
.guide {
margin: 0 0 12px; padding: 12px 14px; border: 1px solid rgba(56, 189, 248, 0.28);
border-left: 4px solid var(--accent); border-radius: var(--radius); background: rgba(14, 165, 233, 0.08);
color: var(--muted); line-height: 1.5; font-size: 0.9rem;
}
.guide strong { color: var(--text); }
.guide code { font-family: var(--mono); color: var(--accent); }
textarea {
width: 100%; min-height: 460px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius);
padding: 12px; background: #0a1220; color: var(--text); font-family: var(--mono); font-size: 0.82rem; line-height: 1.5;
}
textarea:focus { outline: 2px solid rgba(56, 189, 248, 0.35); border-color: var(--accent); }
.status-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.status {
min-height: 30px; display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); font-size: 0.84rem; background: rgba(0,0,0,0.15);
}
.status.ok { color: var(--success); border-color: rgba(52, 211, 153, 0.35); }
.status.err { color: var(--danger); border-color: rgba(248, 113, 113, 0.35); }
.status.warn { color: var(--warning); border-color: rgba(251, 191, 36, 0.35); }
.accounts, .issues {
margin-top: 12px; display: grid; gap: 8px; max-height: 180px; overflow: auto;
}
.card {
border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: rgba(0,0,0,0.14);
font-size: 0.86rem; line-height: 1.4;
}
.card .title { font-weight: 700; }
.card .meta { color: var(--muted); margin-top: 4px; font-family: var(--mono); font-size: 0.78rem; word-break: break-all; }
.drop-active textarea { border-color: var(--accent); box-shadow: inset 0 0 0 1px rgba(56,189,248,0.35); }
.footer-note { margin-top: 14px; color: var(--muted); font-size: 0.84rem; line-height: 1.5; }
@media (max-width: 960px) {
.workspace { grid-template-columns: 1fr; }
.segmented { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
.topbar { flex-direction: column; align-items: flex-start; }
.chips { justify-content: flex-start; }
}
</style>
</head>
<body>
<div class="app">
<div class="topbar">
<div>
<p class="eyebrow">Grok Free Register toolkit</p>
<h1>Grok Session → CPA / sub2api</h1>
<p class="subtitle">
纯前端转换:把注册机 <code>xai-*.json</code> / SSO / accounts / sessions
转成 CPA、sub2api 导入包、batch 建号、或 sso-to-oauth 请求体。本地解析,不上传。
</p>
</div>
<div class="chips">
<span class="chip">platform: grok</span>
<span class="chip">base: cli-chat-proxy</span>
<span class="chip">有 refresh 不写账号 6h 死期</span>
</div>
</div>
<div class="toolbar">
<div class="segmented" role="tablist" aria-label="输出格式">
<button type="button" data-format="cpa" aria-pressed="true">CPA</button>
<button type="button" data-format="sub2api" aria-pressed="false">sub2api 导入</button>
<button type="button" data-format="batch" aria-pressed="false">batch 建号</button>
<button type="button" data-format="sso" aria-pressed="false">sso-to-oauth</button>
</div>
<div class="actions">
<button class="button button-secondary" id="btn-sample" type="button">填示例</button>
<button class="button button-secondary" id="btn-clear" type="button">清空</button>
<button class="button button-secondary" id="btn-copy" type="button" disabled>复制输出</button>
<button class="button button-primary" id="btn-download" type="button" disabled>下载 JSON</button>
</div>
</div>
<div class="workspace">
<section class="panel" id="input-panel">
<div class="panel-head">
<div>
<h2>输入</h2>
<p>粘贴 / 拖入 JSON、JSON 数组、jsonl、accounts 行、纯 SSO 行</p>
</div>
</div>
<div class="panel-body">
<div class="guide">
<strong>支持输入</strong><br />
1. <code>authenticated/xai-*.json</code><code>cpa_ready/xai-*.json</code><br />
2. sub2api account / accounts 数组 / sub2api-data 包<br />
3. <code>auth-sessions.jsonl</code>email + cookies.sso<br />
4. <code>accounts.txt</code><code>email:password:sso</code><br />
5. <code>grok.txt</code>:每行一个 SSO JWT
</div>
<textarea id="session-input" spellcheck="false" placeholder="在此粘贴 xai-*.json / SSO / accounts 行…"></textarea>
<div class="status-row">
<span class="status" id="input-status">等待输入</span>
<span class="status" id="convert-status">未转换</span>
</div>
<div class="accounts" id="accounts"></div>
<div class="issues" id="issues"></div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>输出</h2>
<p id="output-help">CPAtype=xai对齐注册机 cpa_ready / new-api 风格</p>
</div>
</div>
<div class="panel-body">
<textarea id="output" readonly spellcheck="false" placeholder="转换结果会显示在这里"></textarea>
<div class="status-row">
<span class="status" id="output-status"></span>
</div>
<p class="footer-note">
规则:<br />
• free 号 <code>base_url</code> 固定为 <code>https://cli-chat-proxy.grok.com/v1</code>(忽略 authenticated 里的 api.x.ai<br />
• 有 <code>refresh_token</code>sub2api 账号<strong>不写</strong>顶层 <code>expires_at/auto_pause</code>,避免 6h 被 pause<br />
• 无 refresh 时:写 access 的 exp + auto_pause到期停用<br />
• 纯 SSO 输入只能出 <code>sso-to-oauth</code> 请求体CPA/sub2api 需要 OAuth token
</p>
</div>
</section>
</div>
</div>
<script>
(() => {
const CLI_BASE = "https://cli-chat-proxy.grok.com/v1";
const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
const HELP = {
cpa: "CPAtype=xai对齐注册机 cpa_ready / cli-proxy 风格",
sub2api: "sub2api 数据导入type=sub2api-data / accounts[] platform=grok",
batch: "batch 建号POST /api/v1/admin/accounts/batch 的 {accounts:[]}",
sso: "sso-to-oauthPOST /api/v1/admin/grok/sso-to-oauth 的 {sso_tokens:[]}",
};
const els = {
input: document.querySelector("#session-input"),
output: document.querySelector("#output"),
inputStatus: document.querySelector("#input-status"),
convertStatus: document.querySelector("#convert-status"),
outputStatus: document.querySelector("#output-status"),
accounts: document.querySelector("#accounts"),
issues: document.querySelector("#issues"),
outputHelp: document.querySelector("#output-help"),
inputPanel: document.querySelector("#input-panel"),
btnCopy: document.querySelector("#btn-copy"),
btnDownload: document.querySelector("#btn-download"),
btnClear: document.querySelector("#btn-clear"),
btnSample: document.querySelector("#btn-sample"),
formatButtons: [...document.querySelectorAll("[data-format]")],
};
const state = {
format: "cpa",
converted: [],
ssoTokens: [],
skipped: [],
timer: null,
outputText: "",
};
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function firstNonEmpty(...values) {
for (const value of values) {
if (value === undefined || value === null) continue;
if (typeof value === "string" && value.trim() === "") continue;
return typeof value === "string" ? value.trim() : value;
}
return undefined;
}
function setStatus(el, text, tone = "") {
el.textContent = text;
el.className = "status" + (tone ? ` ${tone}` : "");
}
function decodeBase64Url(value) {
const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
try {
return decodeURIComponent(
Array.from(atob(padded), (c) => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")
);
} catch {
return null;
}
}
function parseJwtPayload(token) {
if (!token || typeof token !== "string" || token.split(".").length < 2) return null;
try {
const raw = decodeBase64Url(token.split(".")[1]);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function looksLikeJwt(value) {
return typeof value === "string" && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value.trim());
}
function normalizeTimestamp(value) {
if (value === undefined || value === null || value === "") return undefined;
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().replace(/\.\d{3}Z$/, "Z");
if (typeof value === "number" && Number.isFinite(value)) {
const ms = value > 1e12 ? value : value * 1000;
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
}
if (typeof value === "string") {
const n = Number(value);
if (Number.isFinite(n) && /^\d+(\.\d+)?$/.test(value.trim())) {
const ms = n > 1e12 ? n : n * 1000;
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
}
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
return undefined;
}
function unixSecondsFromValue(value) {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number" && Number.isFinite(value)) return Math.floor(value > 1e12 ? value / 1000 : value);
if (typeof value === "string") {
const n = Number(value);
if (Number.isFinite(n) && /^\d+(\.\d+)?$/.test(value.trim())) return Math.floor(n > 1e12 ? n / 1000 : n);
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
}
return undefined;
}
function stripUndefined(obj) {
if (Array.isArray(obj)) return obj.map(stripUndefined).filter((v) => v !== undefined);
if (!isPlainObject(obj)) return obj;
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (v === undefined || v === null || v === "") continue;
const next = stripUndefined(v);
if (next === undefined) continue;
if (isPlainObject(next) && !Object.keys(next).length) continue;
out[k] = next;
}
return out;
}
function extractSsoFromCookies(cookies) {
if (!Array.isArray(cookies)) return undefined;
let sso = "";
let fallback = "";
for (const cookie of cookies) {
if (!isPlainObject(cookie)) continue;
const name = String(cookie.name || "");
const value = String(cookie.value || "").trim();
if (!value) continue;
if (name === "sso" && !sso) sso = value;
if (name === "sso-rw" && !fallback) fallback = value;
}
return sso || fallback || undefined;
}
function collectOAuthLikeObjects(value, sourceName = "pasted-json") {
const found = [];
const visited = new WeakSet();
function visit(item, path) {
if (!isPlainObject(item) && !Array.isArray(item)) return;
if (Array.isArray(item)) {
item.forEach((child, i) => visit(child, `${path}[${i}]`));
return;
}
if (visited.has(item)) return;
visited.add(item);
const access = firstNonEmpty(
item.access_token, item.accessToken,
item.tokens?.access_token, item.tokens?.accessToken,
item.credentials?.access_token, item.credentials?.accessToken,
);
const refresh = firstNonEmpty(
item.refresh_token, item.refreshToken,
item.tokens?.refresh_token, item.tokens?.refreshToken,
item.credentials?.refresh_token, item.credentials?.refreshToken,
);
const idToken = firstNonEmpty(
item.id_token, item.idToken,
item.tokens?.id_token, item.tokens?.idToken,
item.credentials?.id_token, item.credentials?.idToken,
);
const email = firstNonEmpty(item.email, item.user?.email, item.credentials?.email, item.name);
const sub = firstNonEmpty(item.sub, item.credentials?.sub, item.subject);
const sso = firstNonEmpty(item.sso, item.sso_token, item.ssoToken, extractSsoFromCookies(item.cookies));
const isXai = item.type === "xai" || item.auth_kind === "oauth" || item.platform === "grok";
const isSub2 = item.platform === "grok" && item.credentials;
if (access || (refresh && (email || sub || isXai || isSub2)) || (sso && (email || item.cookies))) {
found.push({ value: item, sourceName, path, kind: access || refresh ? "oauth" : "sso" });
return;
}
// sub2api-data envelope
if (Array.isArray(item.accounts)) {
visit(item.accounts, `${path}.accounts`);
return;
}
for (const [key, child] of Object.entries(item)) {
if (["access_token", "accessToken", "refresh_token", "id_token", "cookies"].includes(key)) continue;
visit(child, `${path}.${key}`);
}
}
visit(value, "$");
return found;
}
function parseTextInput(text) {
const raw = String(text || "").trim();
if (!raw) return { oauthSources: [], ssoTokens: [], skipped: [] };
// Try full JSON first
try {
const parsed = JSON.parse(raw);
const oauthSources = collectOAuthLikeObjects(parsed);
const ssoTokens = [];
// also collect pure sso fields if any
for (const src of oauthSources) {
if (src.kind === "sso") {
const sso = firstNonEmpty(src.value.sso, src.value.sso_token, extractSsoFromCookies(src.value.cookies));
if (sso) ssoTokens.push({ token: sso, email: firstNonEmpty(src.value.email, src.value.user?.email), path: src.path });
}
}
if (oauthSources.length || ssoTokens.length) {
return { oauthSources: oauthSources.filter((s) => s.kind === "oauth"), ssoTokens, skipped: [] };
}
} catch {
// line mode below
}
const oauthSources = [];
const ssoTokens = [];
const skipped = [];
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
lines.forEach((line, index) => {
// jsonl
if (line.startsWith("{") || line.startsWith("[")) {
try {
const parsed = JSON.parse(line);
const found = collectOAuthLikeObjects(parsed, `line-${index + 1}`);
for (const src of found) {
if (src.kind === "oauth") oauthSources.push(src);
else {
const sso = firstNonEmpty(src.value.sso, src.value.sso_token, extractSsoFromCookies(src.value.cookies));
if (sso) ssoTokens.push({ token: sso, email: firstNonEmpty(src.value.email), path: src.path });
}
}
if (!found.length) skipped.push({ path: `line-${index + 1}`, reason: "JSON 行未识别为 Grok 凭证" });
return;
} catch (error) {
skipped.push({ path: `line-${index + 1}`, reason: `JSON 行解析失败:${error.message}` });
return;
}
}
// accounts.txt: email:password:sso
if (line.includes(":") && !looksLikeJwt(line)) {
const parts = line.split(":");
if (parts.length >= 3) {
const email = parts[0];
const sso = parts.slice(2).join(":");
if (looksLikeJwt(sso)) {
ssoTokens.push({ token: sso.trim(), email: email.trim(), path: `accounts:${index + 1}` });
return;
}
}
}
// pure SSO jwt line
if (looksLikeJwt(line)) {
ssoTokens.push({ token: line.trim(), path: `sso:${index + 1}` });
return;
}
skipped.push({ path: `line-${index + 1}`, reason: "无法识别该行" });
});
return { oauthSources, ssoTokens, skipped };
}
function convertOAuthRecord(record, options = {}) {
if (!isPlainObject(record)) throw new Error("记录不是对象");
// unwrap sub2api account
const credsIn = isPlainObject(record.credentials) ? record.credentials : {};
const accessToken = firstNonEmpty(
record.access_token, record.accessToken,
credsIn.access_token, credsIn.accessToken,
record.tokens?.access_token,
);
if (!accessToken) throw new Error("缺少 access_token");
const refreshToken = firstNonEmpty(
record.refresh_token, record.refreshToken,
credsIn.refresh_token, credsIn.refreshToken,
record.tokens?.refresh_token,
);
const idToken = firstNonEmpty(
record.id_token, record.idToken,
credsIn.id_token, credsIn.idToken,
record.tokens?.id_token,
);
const tokenType = firstNonEmpty(record.token_type, credsIn.token_type, "Bearer");
const accessClaims = parseJwtPayload(accessToken) || {};
const idClaims = parseJwtPayload(idToken) || {};
const email = firstNonEmpty(
record.email, credsIn.email, record.user?.email, record.name,
idClaims.email, accessClaims.email, accessClaims.preferred_username,
);
const sub = firstNonEmpty(
record.sub, credsIn.sub, record.subject,
idClaims.sub, accessClaims.sub, accessClaims.principal_id,
);
const expiresIn = firstNonEmpty(
record.expires_in, credsIn.expires_in,
accessClaims.exp && accessClaims.iat ? Number(accessClaims.exp) - Number(accessClaims.iat) : undefined,
21600,
);
const expiredIso = firstNonEmpty(
normalizeTimestamp(record.expired),
normalizeTimestamp(record.expires_at),
normalizeTimestamp(credsIn.expires_at),
normalizeTimestamp(record.expiresAt),
accessClaims.exp ? normalizeTimestamp(accessClaims.exp) : undefined,
);
const expiresUnix = firstNonEmpty(
unixSecondsFromValue(expiredIso),
unixSecondsFromValue(accessClaims.exp),
);
const lastRefresh = firstNonEmpty(
normalizeTimestamp(record.last_refresh),
normalizeTimestamp(credsIn.last_refresh),
normalizeTimestamp(options.now || new Date()),
);
const baseUrl = CLI_BASE; // free CLI 强制
const hasRefresh = Boolean(refreshToken);
const name = firstNonEmpty(email, sub && `grok-${String(sub).slice(0, 8)}`, options.sourceName, "Grok Account");
const headers = isPlainObject(record.headers) ? record.headers : {
"x-grok-client-version": "0.2.93",
"x-xai-token-auth": "xai-grok-cli",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-identifier": "grok-shell",
"x-compaction-at": "400000",
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
};
const cpa = stripUndefined({
type: "xai",
access_token: accessToken,
refresh_token: refreshToken,
id_token: idToken,
token_type: tokenType,
expires_in: Number(expiresIn) || undefined,
expired: expiredIso,
last_refresh: lastRefresh,
sub,
base_url: baseUrl,
token_endpoint: firstNonEmpty(record.token_endpoint, TOKEN_ENDPOINT),
auth_kind: "oauth",
headers,
email,
});
// sub2api credentials always keep token-level expires_at
const credentials = stripUndefined({
access_token: accessToken,
refresh_token: refreshToken,
id_token: idToken,
token_type: tokenType,
expires_at: expiredIso,
email,
sub,
base_url: baseUrl,
});
// 有 refresh不写账号级 expires_at避免 6h auto-pause
// 无 refresh写 access exp + auto_pause
const sub2apiAccount = stripUndefined({
name,
platform: "grok",
type: "oauth",
credentials,
concurrency: Number.isFinite(Number(record.concurrency)) ? Number(record.concurrency) : 1,
priority: Number.isFinite(Number(record.priority)) ? Number(record.priority) : 0,
expires_at: hasRefresh ? undefined : expiresUnix,
auto_pause_on_expired: hasRefresh ? undefined : (expiresUnix ? true : undefined),
notes: firstNonEmpty(record.notes, record.note, record.remark),
});
return {
sourceName: options.sourceName,
sourcePath: options.sourcePath,
email,
name,
sub,
hasRefresh,
expiresAt: expiredIso,
expiresUnix,
cpa,
sub2apiAccount,
};
}
function convertFromText(text) {
const { oauthSources, ssoTokens, skipped } = parseTextInput(text);
const converted = [];
const allSkipped = [...skipped];
const now = new Date();
oauthSources.forEach((item, index) => {
try {
converted.push(convertOAuthRecord(item.value, {
now,
sourceName: item.sourceName,
sourcePath: item.path || `$[${index}]`,
}));
} catch (error) {
allSkipped.push({
path: item.path || `$[${index}]`,
reason: error instanceof Error ? error.message : "无法转换",
});
}
});
// de-dupe sso
const seenSso = new Set();
const uniqueSso = [];
for (const item of ssoTokens) {
const token = String(item.token || "").trim();
if (!token || seenSso.has(token)) continue;
seenSso.add(token);
uniqueSso.push(item);
}
if (!converted.length && !uniqueSso.length) {
allSkipped.push({ path: "$", reason: "未找到可识别的 Grok OAuth / SSO 记录" });
}
return { converted, ssoTokens: uniqueSso, skipped: allSkipped };
}
function buildSub2apiDocument(converted, now = new Date()) {
return {
type: "sub2api-data",
version: 1,
exported_at: normalizeTimestamp(now),
proxies: [],
accounts: converted.map((item) => item.sub2apiAccount),
};
}
function buildBatchDocument(converted) {
return { accounts: converted.map((item) => item.sub2apiAccount) };
}
function buildSsoDocument(ssoTokens) {
return {
sso_tokens: ssoTokens.map((item) => item.token),
// 便于对照API 实际只吃 sso_tokens
_meta: ssoTokens.map((item, i) => stripUndefined({
index: i + 1,
email: item.email,
path: item.path,
token_head: String(item.token).slice(0, 24) + "...",
})),
};
}
function buildCpaDocument(converted) {
return converted.length === 1
? converted[0].cpa
: converted.map((item) => item.cpa);
}
function buildOutputDocument() {
const now = new Date();
if (state.format === "cpa") {
if (!state.converted.length) throw new Error("CPA 需要 OAuth 凭证access_token。纯 SSO 请切换到 sso-to-oauth");
return buildCpaDocument(state.converted);
}
if (state.format === "sub2api") {
if (!state.converted.length) throw new Error("sub2api 导入需要 OAuth 凭证。纯 SSO 请切换到 sso-to-oauth");
return buildSub2apiDocument(state.converted, now);
}
if (state.format === "batch") {
if (!state.converted.length) throw new Error("batch 建号需要 OAuth 凭证。纯 SSO 请切换到 sso-to-oauth");
return buildBatchDocument(state.converted);
}
if (state.format === "sso") {
if (!state.ssoTokens.length) {
// also allow extracting nothing useful message
throw new Error("未找到 SSO token。可粘贴 grok.txt / accounts.txt / auth-sessions.jsonl");
}
return buildSsoDocument(state.ssoTokens);
}
return buildSub2apiDocument(state.converted, now);
}
function renderAccounts() {
if (!state.converted.length && !state.ssoTokens.length) {
els.accounts.innerHTML = "";
return;
}
const oauthCards = state.converted.map((item) => `
<div class="card">
<div class="title">${escapeHtml(item.name || item.email || "Grok Account")}</div>
<div class="meta">
email=${escapeHtml(item.email || "-")}
· sub=${escapeHtml(item.sub || "-")}
· refresh=${item.hasRefresh ? "yes" : "no"}
· exp=${escapeHtml(item.expiresAt || "-")}
</div>
</div>
`).join("");
const ssoCards = state.ssoTokens.slice(0, 20).map((item, i) => `
<div class="card">
<div class="title">SSO #${i + 1}${item.email ? " · " + escapeHtml(item.email) : ""}</div>
<div class="meta">${escapeHtml(String(item.token).slice(0, 48))}...</div>
</div>
`).join("");
els.accounts.innerHTML = oauthCards + ssoCards;
}
function renderIssues() {
if (!state.skipped.length) {
els.issues.innerHTML = "";
return;
}
els.issues.innerHTML = state.skipped.map((item) => `
<div class="card">
<div class="title">跳过 ${escapeHtml(item.path || "?")}</div>
<div class="meta">${escapeHtml(item.reason || "")}</div>
</div>
`).join("");
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function updateOutput() {
els.outputHelp.textContent = HELP[state.format] || "";
try {
if (!els.input.value.trim()) {
state.outputText = "";
els.output.value = "";
els.btnCopy.disabled = true;
els.btnDownload.disabled = true;
setStatus(els.outputStatus, "空");
setStatus(els.convertStatus, "未转换");
return;
}
const doc = buildOutputDocument();
state.outputText = JSON.stringify(doc, null, 2);
els.output.value = state.outputText;
els.btnCopy.disabled = false;
els.btnDownload.disabled = false;
const count = state.format === "sso" ? state.ssoTokens.length : state.converted.length;
setStatus(els.outputStatus, `已生成 · ${count}`, "ok");
setStatus(els.convertStatus, `OAuth ${state.converted.length} / SSO ${state.ssoTokens.length}`, "ok");
} catch (error) {
state.outputText = "";
els.output.value = "";
els.btnCopy.disabled = true;
els.btnDownload.disabled = true;
setStatus(els.outputStatus, error instanceof Error ? error.message : "生成失败", "err");
setStatus(els.convertStatus, `OAuth ${state.converted.length} / SSO ${state.ssoTokens.length}`, state.converted.length || state.ssoTokens.length ? "warn" : "err");
}
}
function scheduleConvert() {
clearTimeout(state.timer);
state.timer = setTimeout(() => {
try {
const result = convertFromText(els.input.value);
state.converted = result.converted;
state.ssoTokens = result.ssoTokens;
state.skipped = result.skipped;
const total = state.converted.length + state.ssoTokens.length;
setStatus(
els.inputStatus,
total ? `识别 OAuth ${state.converted.length} · SSO ${state.ssoTokens.length}` : "未识别到凭证",
total ? "ok" : "warn",
);
renderAccounts();
renderIssues();
updateOutput();
} catch (error) {
state.converted = [];
state.ssoTokens = [];
state.skipped = [{ path: "$", reason: error instanceof Error ? error.message : "转换失败" }];
setStatus(els.inputStatus, error instanceof Error ? error.message : "转换失败", "err");
renderAccounts();
renderIssues();
updateOutput();
}
}, 120);
}
function downloadOutput() {
if (!state.outputText) return;
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const name = `grok-${state.format}-${stamp}.json`;
const blob = new Blob([state.outputText], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
async function copyOutput() {
if (!state.outputText) return;
try {
await navigator.clipboard.writeText(state.outputText);
setStatus(els.outputStatus, "已复制", "ok");
} catch {
els.output.select();
document.execCommand("copy");
setStatus(els.outputStatus, "已复制", "ok");
}
}
function setFormat(format) {
state.format = format;
els.formatButtons.forEach((btn) => {
btn.setAttribute("aria-pressed", btn.dataset.format === format ? "true" : "false");
});
updateOutput();
}
function fillSample() {
const now = Math.floor(Date.now() / 1000);
const exp = now + 21600;
// fake jwt-like payload (not signed; only for local demo decode)
const header = btoa(JSON.stringify({ alg: "none", typ: "JWT" })).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
const payload = btoa(JSON.stringify({
sub: "55c526cb-9f70-4486-a232-3710e6f3df38",
email: "demo@example.com",
iat: now,
exp,
})).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
const access = `${header}.${payload}.demo`;
const sample = {
type: "xai",
access_token: access,
refresh_token: "demo-refresh-token-replace-me",
id_token: access,
token_type: "Bearer",
expires_in: 21600,
expired: new Date(exp * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
last_refresh: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
sub: "55c526cb-9f70-4486-a232-3710e6f3df38",
base_url: "https://api.x.ai/v1",
token_endpoint: TOKEN_ENDPOINT,
auth_kind: "oauth",
email: "demo@example.com",
};
els.input.value = JSON.stringify(sample, null, 2);
scheduleConvert();
}
// events
els.input.addEventListener("input", scheduleConvert);
els.btnClear.addEventListener("click", () => {
els.input.value = "";
scheduleConvert();
});
els.btnSample.addEventListener("click", fillSample);
els.btnCopy.addEventListener("click", copyOutput);
els.btnDownload.addEventListener("click", downloadOutput);
els.formatButtons.forEach((btn) => {
btn.addEventListener("click", () => setFormat(btn.dataset.format));
});
// drag-drop
["dragenter", "dragover"].forEach((type) => {
els.inputPanel.addEventListener(type, (event) => {
event.preventDefault();
els.inputPanel.classList.add("drop-active");
});
});
["dragleave", "drop"].forEach((type) => {
els.inputPanel.addEventListener(type, (event) => {
event.preventDefault();
if (type === "drop") {
const files = [...(event.dataTransfer?.files || [])];
if (!files.length) return;
Promise.all(files.map((file) => file.text())).then((parts) => {
const existing = els.input.value.trim();
const incoming = parts.join("\n");
els.input.value = existing ? `${existing}\n${incoming}` : incoming;
scheduleConvert();
});
}
els.inputPanel.classList.remove("drop-active");
});
});
setFormat("cpa");
})();
</script>
</body>
</html>