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:
100
tools/GrokSession2CPAAndSub2API/README.md
Normal file
100
tools/GrokSession2CPAAndSub2API/README.md
Normal file
@@ -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
|
||||
```
|
||||
902
tools/GrokSession2CPAAndSub2API/index.html
Normal file
902
tools/GrokSession2CPAAndSub2API/index.html
Normal file
@@ -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>
|
||||
1
tools/__init__.py
Normal file
1
tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Developer-only analysis and stress tools."""
|
||||
419
tools/runtime_log_analyzer.py
Normal file
419
tools/runtime_log_analyzer.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Parse real runtime monitor logs from both CSP and legacy state-machine runs.
|
||||
|
||||
The analyzer is intentionally read-only. It does not tune parameters or modify
|
||||
runtime state; it only turns production logs into comparable stage rates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
_CSP_RE = re.compile(
|
||||
r"^\[\*\] T:(?P<t>\d+) Q:(?P<q>\d+) phys:(?P<phys>\d+) "
|
||||
r"(?:p_send:(?P<p_send>\d+) )?t_slot:(?P<t_slot>\d+) q_slot:(?P<q_slot>\d+) q_pend:(?P<q_pend>\d+)"
|
||||
r"(?P<rest>.*)rate:(?P<rate>[0-9.]+)/min #(?P<ok>\d+)"
|
||||
)
|
||||
|
||||
_STATE_RE = re.compile(
|
||||
r"^\[\*\] slots:(?P<slots>\d+)/(?P<max_slots>\d+) act:(?P<active>\d+) "
|
||||
r"cpu:(?P<cpu>[0-9.]+)% avg:(?P<cpu_avg>[0-9.]+)%/(?P<cpu_target>[0-9.]+) "
|
||||
r"mem:(?P<mem>\d+)M T:(?P<t>\d+) Q:(?P<q>\d+) "
|
||||
r"sent:(?P<q_sent>\d+) got:(?P<q_ret>\d+)\((?P<q_hit>[0-9.]+)%\) "
|
||||
r"rate:(?P<rate>[0-9.]+)/min #(?P<ok>\d+)"
|
||||
)
|
||||
|
||||
_SOLVER_TIMELINE_PREFIX = "[solver_timeline] "
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonitorRow:
|
||||
kind: str
|
||||
t: int
|
||||
q: int
|
||||
ok: int
|
||||
rate: float
|
||||
phys: int | None = None
|
||||
p_send: int | None = None
|
||||
t_slot: int | None = None
|
||||
q_slot: int | None = None
|
||||
q_pend: int | None = None
|
||||
p_batch: float | None = None
|
||||
t_prog: int | None = None
|
||||
q_inflight: int | None = None
|
||||
t_prod: int | None = None
|
||||
q_sent: int | None = None
|
||||
q_ret: int | None = None
|
||||
q_adm: int | None = None
|
||||
pair: int | None = None
|
||||
fail: int | None = None
|
||||
s_phys_wait: float | None = None
|
||||
s_phys_hold: float | None = None
|
||||
p_phys_wait: float | None = None
|
||||
p_phys_hold: float | None = None
|
||||
c_phys_wait: float | None = None
|
||||
c_phys_hold: float | None = None
|
||||
p_email_create: float | None = None
|
||||
p_page_prepare: float | None = None
|
||||
p_send_stage: float | None = None
|
||||
c_page_acquire: float | None = None
|
||||
c_verify: float | None = None
|
||||
c_register: float | None = None
|
||||
c_hot_hits: int | None = None
|
||||
c_hot_misses: int | None = None
|
||||
solver_goto: float | None = None
|
||||
solver_inject: float | None = None
|
||||
solver_initial: float | None = None
|
||||
solver_click: float | None = None
|
||||
solver_wait: float | None = None
|
||||
solver_reuse: float | None = None
|
||||
solver_visible: float | None = None
|
||||
slots: int | None = None
|
||||
max_slots: int | None = None
|
||||
active: int | None = None
|
||||
|
||||
@property
|
||||
def elapsed_min(self) -> float | None:
|
||||
if self.ok <= 0 or self.rate <= 0:
|
||||
return None
|
||||
return self.ok / self.rate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolverTimeline:
|
||||
events: list[dict]
|
||||
|
||||
|
||||
def _int_field(rest: str, name: str) -> int | None:
|
||||
match = re.search(rf"\b{name}:(\d+)", rest)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _float_field(rest: str, name: str) -> float | None:
|
||||
match = re.search(rf"\b{name}:([0-9.]+)", rest)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _float_pair_field(rest: str, name: str) -> tuple[float | None, float | None]:
|
||||
match = re.search(rf"\b{name}:([0-9.]+)/([0-9.]+)", rest)
|
||||
if not match:
|
||||
return None, None
|
||||
return float(match.group(1)), float(match.group(2))
|
||||
|
||||
|
||||
def _float_triple_field(rest: str, name: str) -> tuple[float | None, float | None, float | None]:
|
||||
match = re.search(rf"\b{name}:([0-9.]+)/([0-9.]+)/([0-9.]+)", rest)
|
||||
if not match:
|
||||
return None, None, None
|
||||
return float(match.group(1)), float(match.group(2)), float(match.group(3))
|
||||
|
||||
|
||||
def _int_pair_field(rest: str, name: str) -> tuple[int | None, int | None]:
|
||||
match = re.search(rf"\b{name}:(\d+)/(\d+)", rest)
|
||||
if not match:
|
||||
return None, None
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
def parse_monitor_lines(text_or_lines: str | Iterable[str]) -> list[MonitorRow]:
|
||||
if isinstance(text_or_lines, str):
|
||||
lines = text_or_lines.splitlines()
|
||||
else:
|
||||
lines = list(text_or_lines)
|
||||
|
||||
rows: list[MonitorRow] = []
|
||||
for line in lines:
|
||||
csp = _CSP_RE.match(line)
|
||||
if csp:
|
||||
rest = csp.group("rest")
|
||||
s_phys_wait, s_phys_hold = _float_pair_field(rest, "s_phys")
|
||||
p_phys_wait, p_phys_hold = _float_pair_field(rest, "p_phys")
|
||||
c_phys_wait, c_phys_hold = _float_pair_field(rest, "c_phys")
|
||||
p_email_create, p_page_prepare, p_send_stage = _float_triple_field(rest, "p_stage")
|
||||
c_page_acquire, c_verify, c_register = _float_triple_field(rest, "c_stage")
|
||||
c_hot_hits, c_hot_misses = _int_pair_field(rest, "c_hot")
|
||||
rows.append(
|
||||
MonitorRow(
|
||||
kind="csp",
|
||||
t=int(csp.group("t")),
|
||||
q=int(csp.group("q")),
|
||||
ok=int(csp.group("ok")),
|
||||
rate=float(csp.group("rate")),
|
||||
phys=int(csp.group("phys")),
|
||||
p_send=int(csp.group("p_send")) if csp.group("p_send") is not None else None,
|
||||
t_slot=int(csp.group("t_slot")),
|
||||
q_slot=int(csp.group("q_slot")),
|
||||
q_pend=int(csp.group("q_pend")),
|
||||
p_batch=_float_field(rest, "p_batch"),
|
||||
t_prog=_int_field(rest, "t_prog"),
|
||||
q_inflight=_int_field(rest, "q_inflight"),
|
||||
t_prod=_int_field(rest, "t_prod"),
|
||||
q_sent=_int_field(rest, "q_sent"),
|
||||
q_ret=_int_field(rest, "q_ret"),
|
||||
q_adm=_int_field(rest, "q_adm"),
|
||||
pair=_int_field(rest, "pair"),
|
||||
fail=_int_field(rest, "fail"),
|
||||
s_phys_wait=s_phys_wait,
|
||||
s_phys_hold=s_phys_hold,
|
||||
p_phys_wait=p_phys_wait,
|
||||
p_phys_hold=p_phys_hold,
|
||||
c_phys_wait=c_phys_wait,
|
||||
c_phys_hold=c_phys_hold,
|
||||
p_email_create=p_email_create,
|
||||
p_page_prepare=p_page_prepare,
|
||||
p_send_stage=p_send_stage,
|
||||
c_page_acquire=c_page_acquire,
|
||||
c_verify=c_verify,
|
||||
c_register=c_register,
|
||||
c_hot_hits=c_hot_hits,
|
||||
c_hot_misses=c_hot_misses,
|
||||
solver_goto=_float_field(rest, "solver_goto"),
|
||||
solver_inject=_float_field(rest, "solver_inject"),
|
||||
solver_initial=_float_field(rest, "solver_initial"),
|
||||
solver_click=_float_field(rest, "solver_click"),
|
||||
solver_wait=_float_field(rest, "solver_wait"),
|
||||
solver_reuse=_float_field(rest, "solver_reuse"),
|
||||
solver_visible=_float_field(rest, "solver_visible"),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
state = _STATE_RE.match(line)
|
||||
if state:
|
||||
rows.append(
|
||||
MonitorRow(
|
||||
kind="state_machine",
|
||||
t=int(state.group("t")),
|
||||
q=int(state.group("q")),
|
||||
ok=int(state.group("ok")),
|
||||
rate=float(state.group("rate")),
|
||||
q_sent=int(state.group("q_sent")),
|
||||
q_ret=int(state.group("q_ret")),
|
||||
slots=int(state.group("slots")),
|
||||
max_slots=int(state.group("max_slots")),
|
||||
active=int(state.group("active")),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _rate_delta(rows: list[MonitorRow], attr: str) -> float | None:
|
||||
candidates = [r for r in rows if r.elapsed_min is not None and getattr(r, attr) is not None]
|
||||
if len(candidates) < 2:
|
||||
return None
|
||||
first = candidates[0]
|
||||
last = candidates[-1]
|
||||
dt = (last.elapsed_min or 0) - (first.elapsed_min or 0)
|
||||
if dt <= 0:
|
||||
return None
|
||||
return (getattr(last, attr) - getattr(first, attr)) / dt
|
||||
|
||||
|
||||
def _leader(values: dict[str, float | None]) -> str | None:
|
||||
if any(value is None for value in values.values()):
|
||||
return None
|
||||
return max(values, key=lambda key: values[key] or 0)
|
||||
|
||||
|
||||
def _avg(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), 2)
|
||||
|
||||
|
||||
def _ratio(numerator: int, denominator: int) -> float | None:
|
||||
if denominator <= 0:
|
||||
return None
|
||||
return round(numerator / denominator, 3)
|
||||
|
||||
|
||||
def summarize_monitor_rows(rows: list[MonitorRow], recent_count: int = 6) -> dict[str, float | int | str | None]:
|
||||
if not rows:
|
||||
return {"rows": 0}
|
||||
|
||||
last = rows[-1]
|
||||
recent = rows[-recent_count:]
|
||||
summary: dict[str, float | int | str | None] = {
|
||||
"rows": len(rows),
|
||||
"kind": last.kind,
|
||||
"last_ok": last.ok,
|
||||
"last_cumulative_rate": last.rate,
|
||||
"last_q_minus_t": last.q - last.t,
|
||||
"last_phys": last.phys,
|
||||
"last_p_send_sem": last.p_send,
|
||||
"last_t_slot": last.t_slot,
|
||||
"last_q_slot": last.q_slot,
|
||||
"last_q_pend": last.q_pend,
|
||||
"last_p_batch": last.p_batch,
|
||||
"last_t_prog": last.t_prog,
|
||||
"last_q_inflight": last.q_inflight,
|
||||
"last_q_return_minus_t_prod": (
|
||||
last.q_ret - last.t_prod
|
||||
if last.q_ret is not None and last.t_prod is not None
|
||||
else None
|
||||
),
|
||||
"last_slots": last.slots,
|
||||
"last_t_prod": last.t_prod,
|
||||
"last_q_ret": last.q_ret,
|
||||
"last_q_adm": last.q_adm,
|
||||
"last_pair": last.pair,
|
||||
"last_fail": last.fail,
|
||||
"last_s_phys_wait": last.s_phys_wait,
|
||||
"last_s_phys_hold": last.s_phys_hold,
|
||||
"last_p_phys_wait": last.p_phys_wait,
|
||||
"last_p_phys_hold": last.p_phys_hold,
|
||||
"last_c_phys_wait": last.c_phys_wait,
|
||||
"last_c_phys_hold": last.c_phys_hold,
|
||||
"last_p_email_create": last.p_email_create,
|
||||
"last_p_page_prepare": last.p_page_prepare,
|
||||
"last_p_send": last.p_send_stage,
|
||||
"last_c_page_acquire": last.c_page_acquire,
|
||||
"last_c_verify": last.c_verify,
|
||||
"last_c_register": last.c_register,
|
||||
"last_c_hot_hits": last.c_hot_hits,
|
||||
"last_c_hot_misses": last.c_hot_misses,
|
||||
"last_physical_hold_leader": _leader({
|
||||
"s": last.s_phys_hold,
|
||||
"p": last.p_phys_hold,
|
||||
"c": last.c_phys_hold,
|
||||
}),
|
||||
"last_physical_wait_leader": _leader({
|
||||
"s": last.s_phys_wait,
|
||||
"p": last.p_phys_wait,
|
||||
"c": last.c_phys_wait,
|
||||
}),
|
||||
"last_solver_goto": last.solver_goto,
|
||||
"last_solver_inject": last.solver_inject,
|
||||
"last_solver_initial": last.solver_initial,
|
||||
"last_solver_click": last.solver_click,
|
||||
"last_solver_wait": last.solver_wait,
|
||||
"last_solver_reuse": last.solver_reuse,
|
||||
"last_solver_visible": last.solver_visible,
|
||||
"recent_ok_per_min": _rate_delta(recent, "ok"),
|
||||
"recent_t_prod_per_min": _rate_delta(recent, "t_prod"),
|
||||
"recent_q_ret_per_min": _rate_delta(recent, "q_ret"),
|
||||
"recent_pair_per_min": _rate_delta(recent, "pair"),
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def parse_solver_timelines(text_or_lines: str | Iterable[str]) -> list[SolverTimeline]:
|
||||
if isinstance(text_or_lines, str):
|
||||
lines = text_or_lines.splitlines()
|
||||
else:
|
||||
lines = list(text_or_lines)
|
||||
|
||||
timelines: list[SolverTimeline] = []
|
||||
for line in lines:
|
||||
if not line.startswith(_SOLVER_TIMELINE_PREFIX):
|
||||
continue
|
||||
payload = line[len(_SOLVER_TIMELINE_PREFIX):]
|
||||
try:
|
||||
events = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(events, list):
|
||||
timelines.append(SolverTimeline(events=[e for e in events if isinstance(e, dict)]))
|
||||
return timelines
|
||||
|
||||
|
||||
def _last_page_trace(events: list[dict]) -> dict | None:
|
||||
for event in reversed(events):
|
||||
page_trace = event.get("page_trace")
|
||||
if isinstance(page_trace, dict):
|
||||
return page_trace
|
||||
return None
|
||||
|
||||
|
||||
def summarize_solver_timelines(timelines: list[SolverTimeline]) -> dict[str, float | int | None]:
|
||||
if not timelines:
|
||||
return {"solver_timeline_count": 0}
|
||||
|
||||
ok_count = 0
|
||||
click_calls: list[float] = []
|
||||
click_move_ms: list[float] = []
|
||||
click_down_up_ms: list[float] = []
|
||||
render_to_token_ms: list[float] = []
|
||||
token_write_to_poll_done_ms: list[float] = []
|
||||
poll_attempts: list[float] = []
|
||||
poll_read_avg_ms: list[float] = []
|
||||
click_before_count = 0
|
||||
center_iframe_hits = 0
|
||||
turnstile_iframe_seen = 0
|
||||
widget_seen = 0
|
||||
|
||||
for timeline in timelines:
|
||||
events = timeline.events
|
||||
page_trace = _last_page_trace(events)
|
||||
if page_trace:
|
||||
render_called = page_trace.get("render_called_at")
|
||||
token_written = page_trace.get("token_written_at")
|
||||
if isinstance(render_called, (int, float)) and isinstance(token_written, (int, float)):
|
||||
render_to_token_ms.append(float(token_written) - float(render_called))
|
||||
|
||||
for event in events:
|
||||
if event.get("event") == "click_before":
|
||||
click_before_count += 1
|
||||
dom = event.get("dom") if isinstance(event.get("dom"), dict) else {}
|
||||
widget = dom.get("widget") if isinstance(dom.get("widget"), dict) else {}
|
||||
center = dom.get("element_at_center") if isinstance(dom.get("element_at_center"), dict) else {}
|
||||
if widget.get("present") and widget.get("visible"):
|
||||
widget_seen += 1
|
||||
if center.get("is_iframe"):
|
||||
center_iframe_hits += 1
|
||||
if (dom.get("turnstile_iframe_count") or 0) > 0:
|
||||
turnstile_iframe_seen += 1
|
||||
|
||||
if event.get("event") == "click_after":
|
||||
call_ms = event.get("click_call_ms")
|
||||
if isinstance(call_ms, (int, float)):
|
||||
click_calls.append(float(call_ms))
|
||||
trace = event.get("click_trace") if isinstance(event.get("click_trace"), dict) else {}
|
||||
move1 = trace.get("mouse_move1_ms")
|
||||
move2 = trace.get("mouse_move2_ms")
|
||||
down = trace.get("mouse_down_ms")
|
||||
up = trace.get("mouse_up_ms")
|
||||
move_total = sum(float(v) for v in (move1, move2) if isinstance(v, (int, float)))
|
||||
down_up_total = sum(float(v) for v in (down, up) if isinstance(v, (int, float)))
|
||||
if move_total:
|
||||
click_move_ms.append(move_total)
|
||||
if down_up_total:
|
||||
click_down_up_ms.append(down_up_total)
|
||||
|
||||
if event.get("event") == "poll_done":
|
||||
if event.get("ok"):
|
||||
ok_count += 1
|
||||
attempts = event.get("poll_attempts")
|
||||
if isinstance(attempts, (int, float)):
|
||||
poll_attempts.append(float(attempts))
|
||||
read_avg = event.get("poll_read_ms_avg")
|
||||
if isinstance(read_avg, (int, float)):
|
||||
poll_read_avg_ms.append(float(read_avg))
|
||||
page_trace = event.get("page_trace") if isinstance(event.get("page_trace"), dict) else {}
|
||||
token_written = page_trace.get("token_written_at")
|
||||
event_t = event.get("t")
|
||||
created_at = page_trace.get("created_at")
|
||||
if all(isinstance(v, (int, float)) for v in (token_written, event_t, created_at)):
|
||||
token_write_to_poll_done_ms.append((float(event_t) * 1000.0) - (float(token_written) - float(created_at)))
|
||||
|
||||
return {
|
||||
"solver_timeline_count": len(timelines),
|
||||
"ok_count": ok_count,
|
||||
"avg_click_call_ms": _avg(click_calls),
|
||||
"avg_click_mouse_move_ms": _avg(click_move_ms),
|
||||
"avg_click_down_up_ms": _avg(click_down_up_ms),
|
||||
"avg_render_to_token_ms": _avg(render_to_token_ms),
|
||||
"avg_token_write_to_poll_done_ms": _avg(token_write_to_poll_done_ms),
|
||||
"avg_poll_attempts": _avg(poll_attempts),
|
||||
"avg_poll_read_ms": _avg(poll_read_avg_ms),
|
||||
"widget_seen_ratio": _ratio(widget_seen, click_before_count),
|
||||
"center_iframe_hit_ratio": _ratio(center_iframe_hits, click_before_count),
|
||||
"turnstile_iframe_seen_ratio": _ratio(turnstile_iframe_seen, click_before_count),
|
||||
}
|
||||
|
||||
|
||||
def analyze_text(text: str) -> dict[str, float | int | str | None]:
|
||||
return summarize_monitor_rows(parse_monitor_lines(text))
|
||||
Reference in New Issue
Block a user