Initial commit: grok-free-register-oss
Open-source Grok free registration CLI, xai_enroller auth pipeline, local auth service, tests and docs.
This commit is contained in:
commit
d10009d639
72 files changed
+18752
No files matched your search
@@ -0,0 +1,100 @@
|
||||
# Grok Session → CPA / sub2api
|
||||
|
||||
纯前端单页工具:把 grok-free-register 产出的凭证,转成 CPA、sub2api 导入包、batch 建号、或 sso-to-oauth 请求体。
|
||||
|
||||
对照思路:GPT session → CPA / sub2api 纯前端转换器(同类工具,无外部依赖)。
|
||||
|
||||
## 本地使用
|
||||
|
||||
直接打开:
|
||||
|
||||
```text
|
||||
tools/GrokSession2CPAAndSub2API/index.html
|
||||
```
|
||||
|
||||
所有解析和转换都在浏览器本地完成,不上传 token。
|
||||
|
||||
## 支持输入
|
||||
|
||||
| 来源 | 说明 |
|
||||
|------|------|
|
||||
| `authenticated/xai-*.json` | 注册成功 OAuth(base_url 常是 api.x.ai) |
|
||||
| `cpa_ready/xai-*.json` | watchdog 终态(带 headers/email) |
|
||||
| `sub2api-data` / accounts 数组 | 已转换过的包,可再导成其它格式 |
|
||||
| `auth-sessions.jsonl` | `email` + `cookies.sso` |
|
||||
| `accounts.txt` | `email:password:sso` |
|
||||
| `grok.txt` | 每行一个 SSO JWT |
|
||||
|
||||
可粘贴 JSON / 数组 / jsonl,或拖入多个文件。
|
||||
|
||||
## 输出格式
|
||||
|
||||
### CPA
|
||||
|
||||
对齐注册机 cpa_ready:
|
||||
|
||||
- `type: "xai"`
|
||||
- `access_token` / `refresh_token` / `id_token`
|
||||
- `base_url` **强制** `https://cli-chat-proxy.grok.com/v1`(忽略 authenticated 里的 `api.x.ai`)
|
||||
- `headers`:保留源 headers,否则写 grok-cli 默认头
|
||||
- `email` / `sub` / `expired` / `expires_in`
|
||||
|
||||
单条输出对象,多条输出数组。
|
||||
|
||||
### sub2api 导入
|
||||
|
||||
`type: "sub2api-data"` 包,可走 sub2api 数据导入:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "sub2api-data",
|
||||
"version": 1,
|
||||
"exported_at": "...",
|
||||
"proxies": [],
|
||||
"accounts": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
账号字段:
|
||||
|
||||
- `platform: "grok"`, `type: "oauth"`
|
||||
- `credentials.access_token` / `refresh_token` / `id_token` / `expires_at` / `email` / `sub` / `base_url`
|
||||
- **有 `refresh_token`**:不写账号顶层 `expires_at` / `auto_pause_on_expired`(避免 access 6h 到期被 pause)
|
||||
- **无 `refresh_token`**:写 access JWT exp + `auto_pause_on_expired: true`
|
||||
|
||||
### batch 建号
|
||||
|
||||
`POST /api/v1/admin/accounts/batch` 体:
|
||||
|
||||
```json
|
||||
{ "accounts": [ /* 同上 sub2api 账号对象 */ ] }
|
||||
```
|
||||
|
||||
### sso-to-oauth
|
||||
|
||||
`POST /api/v1/admin/grok/sso-to-oauth` 体:
|
||||
|
||||
```json
|
||||
{
|
||||
"sso_tokens": ["eyJ...", "..."],
|
||||
"_meta": [ /* 仅本地对照,API 可忽略 */ ]
|
||||
}
|
||||
```
|
||||
|
||||
纯 SSO 输入(grok.txt / accounts.txt / auth-sessions)**只能**走这个格式;CPA / sub2api / batch 需要 OAuth。
|
||||
|
||||
## 关键规则(别踩坑)
|
||||
|
||||
1. free 号 API 走 `cli-chat-proxy.grok.com`,不是 `api.x.ai`。
|
||||
2. free OAuth access ≈ 6h(`expires_in=21600`)。有 refresh 时让 sub2api 自己刷;别把账号级 `expires_at` 钉成 access exp。
|
||||
3. 当前线上常见:refresh 被整批 revoke,access 在 exp 前仍可用。这种号导入后到期无法续命,只能重铸。
|
||||
4. 注册机 `grok.txt` / `accounts.txt` / `auth-sessions.jsonl` 只有 SSO,没有 OAuth;要进 sub2api 要么走 sso-to-oauth,要么先走 xai_enroller 出 `authenticated/` 再转。
|
||||
|
||||
## 与注册机衔接
|
||||
|
||||
```
|
||||
register → grok.txt / accounts.txt / auth-sessions.jsonl (SSO)
|
||||
→ xai_enroller → authenticated/xai-*.json (OAuth)
|
||||
→ acpa_watchdog → cpa_ready/xai-*.json + acc.md
|
||||
→ 本工具 → sub2api-data / batch / CPA / sso-to-oauth
|
||||
```
|
||||
@@ -0,0 +1,902 @@
|
||||
<!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">CPA:type=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: "CPA:type=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-oauth:POST /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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in new issue
Block a user