如图 千万别点修复 血泪教训 你的脚本会全部清空并且不可找回 别问我怎么知道的 出现这种情况我是重启几次可能突然就好了 这次的扩展损坏重启几次也不能恢复了 有佬真正弄明白原理吗 怎么才能避免这种情况 9 个帖子 - 6 位参与者 阅读完整话题
换了新电脑,浏览器浏览一些论坛一堆广告,刚刚去油猴市场搜,发现老牌的ADGuard都不见了,难道被下架了? 5 个帖子 - 5 位参与者 阅读完整话题
sub2api测活+cpa->sub2api 油猴脚本分享 开发调优 因为bugteam,论坛好多号可以蹬,碍于本人现在都用SUB2API,大多佬友分享的都是CPA。 故寻找脚本,基于佬友分享的脚本,补充了一下功能。 [image] 测活,失败后直接删除。 cpa->sub2api,根据选择的分组,自动下载sub2api的json,并且自动导入+批量选择分组。 // ==UserScript== // @name Sub2API 账号模型… 感谢大佬们的帮助,继续接力,用了大佬的脚本,报错了400,修复了一下,给出了符合我的脚本,也分享给有这方面问题的小伙伴们: // ==UserScript== // @name Sub2API 账号模型巡检并自动下线 // @namespace https://sinry.example // @version 0.1.8 // @description 按当前页面分组批量测试账号模型;支持 CPA 认证文件批量转换为 sub2api JSON // @match http://127.0.0.1:8080/admin/accounts* // @run-at document-start // @grant none // ==/UserScript== (function () { 'use strict'; const CONFIG = { apiBase: location.origin, pageSize: 100, defaultTimeoutMs: 45000, defaultConcurrency: 8, maxConcurrency: 50, prompt: 'hi', onlyCheckSchedulable: false, stopOnFirstModelFailure: true, preferredModels: ['gpt-5.4', 'gpt-4o-mini', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini'], defaultTestModel: 'gpt-5.4', groupParamNames: ['group', 'groups', 'account_group', 'accountGroup'], pageAuthTokenKey: 'auth_token', authStorageKey: '__sub2api_checker_auth__', timeoutStorageKey: '__sub2api_checker_timeout_ms__', concurrencyStorageKey: '__sub2api_checker_concurrency__', testModelStorageKey: '__sub2api_checker_test_model__', currentGroupStorageKey: '__sub2api_checker_current_group__', autoDisableStorageKey: '__sub2api_checker_auto_disable__', autoDeleteStorageKey: '__sub2api_checker_auto_delete__', }; function getCachedAuthToken() { const raw = localStorage.getItem(CONFIG.pageAuthTokenKey) || sessionStorage.getItem(CONFIG.pageAuthTokenKey) || localStorage.getItem(CONFIG.authStorageKey) || ''; return raw ? (raw.startsWith('Bearer ') ? raw : `Bearer ${raw}`) : ''; } function clampConcurrency(value) { const n = Math.floor(Number(value)); if (!Number.isFinite(n)) return CONFIG.defaultConcurrency; return Math.min(CONFIG.maxConcurrency, Math.max(1, n)); } function readSavedConcurrency() { return clampConcurrency(localStorage.getItem(CONFIG.concurrencyStorageKey) || CONFIG.defaultConcurrency); } function readSavedAutoDisable() { return localStorage.getItem(CONFIG.autoDisableStorageKey) !== 'false'; } function readSavedAutoDelete() { return localStorage.getItem(CONFIG.autoDeleteStorageKey) === 'true'; } const state = { authHeader: getCachedAuthToken(), timeoutMs: Number(localStorage.getItem(CONFIG.timeoutStorageKey) || CONFIG.defaultTimeoutMs), concurrency: readSavedConcurrency(), testModel: localStorage.getItem(CONFIG.testModelStorageKey) || CONFIG.defaultTestModel, autoDisable: readSavedAutoDisable(), autoDelete: readSavedAutoDelete(), currentGroup: '', currentGroupId: '', running: false, stopRequested: false, panelReady: false, collapsed: true, stats: { total: 0, checked: 0, active: 0, started: 0, ok: 0, enabled: 0, disabled: 0, deleted: 0, skipped: 0, failed: 0, }, }; if (state.autoDelete && state.autoDisable) { state.autoDisable = false; localStorage.setItem(CONFIG.autoDisableStorageKey, 'false'); } function log(msg, type = 'info') { const time = new Date().toLocaleTimeString(); const line = `[${time}] ${msg}`; console[type === 'error' ? 'error' : 'log'](`[sub2api-checker] ${line}`); const box = document.querySelector('#sub2api-checker-log'); if (!box) return; const color = type === 'error' ? '#ff7875' : type === 'warn' ? '#ffd666' : type === 'success' ? '#95de64' : '#d9d9d9'; const row = document.createElement('div'); row.style.color = color; row.textContent = line; box.appendChild(row); box.scrollTop = box.scrollHeight; } function saveAuth(auth) { if (!auth || typeof auth !== 'string') return; const normalized = auth.startsWith('Bearer ') ? auth : `Bearer ${auth}`; const changed = state.authHeader !== normalized; state.authHeader = normalized; localStorage.setItem(CONFIG.authStorageKey, normalized); const input = document.querySelector('#sub2api-checker-auth'); if (input && !input.value) input.value = normalized; if (changed) log('已捕获 Authorization', 'success'); } function saveTimeoutMs(timeoutMs) { const n = Number(timeoutMs); if (!Number.isFinite(n) || n < 1000) return false; state.timeoutMs = n; localStorage.setItem(CONFIG.timeoutStorageKey, String(n)); const input = document.querySelector('#sub2api-checker-timeout'); if (input) input.value = String(Math.floor(n / 1000)); return true; } function saveConcurrency(concurrency) { const n = clampConcurrency(concurrency); state.concurrency = n; localStorage.setItem(CONFIG.concurrencyStorageKey, String(n)); const input = document.querySelector('#sub2api-checker-concurrency'); if (input) input.value = String(n); return true; } function saveTestModel(model) { const normalized = String(model || '').trim(); if (!normalized) return false; state.testModel = normalized; localStorage.setItem(CONFIG.testModelStorageKey, normalized); const input = document.querySelector('#sub2api-checker-test-model'); if (input) input.value = normalized; return true; } function saveAutoDisable(enabled) { state.autoDisable = !!enabled; if (state.autoDisable) { state.autoDelete = false; localStorage.setItem(CONFIG.autoDeleteStorageKey, 'false'); const autoDeleteInput = document.querySelector('#sub2api-checker-auto-delete'); if (autoDeleteInput) autoDeleteInput.checked = false; } localStorage.setItem(CONFIG.autoDisableStorageKey, String(state.autoDisable)); const input = document.querySelector('#sub2api-checker-auto-disable'); if (input) input.checked = state.autoDisable; return true; } function saveAutoDelete(enabled) { state.autoDelete = !!enabled; if (state.autoDelete) { state.autoDisable = false; localStorage.setItem(CONFIG.autoDisableStorageKey, 'false'); const autoDisableInput = document.querySelector('#sub2api-checker-auto-disable'); if (autoDisableInput) autoDisableInput.checked = false; } localStorage.setItem(CONFIG.autoDeleteStorageKey, String(state.autoDelete)); const input = document.querySelector('#sub2api-checker-auto-delete'); if (input) input.checked = state.autoDelete; return true; } function normalizeGroup(value) { if (value === undefined || value === null) return ''; const text = String(value).trim(); if (!text) return ''; if (['全部', '全部分组', 'all', 'null', 'undefined'].includes(text.toLowerCase())) return ''; return text; } function normalizeGroupId(value) { const text = normalizeGroup(value); if (!text) return ''; if (text === 'ungrouped') return text; const n = Number(text); if (Number.isInteger(n) && n > 0) return String(n); return ''; } function getGroupFromUrl(urlLike = location.href) { try { const url = new URL(String(urlLike), location.origin); for (const name of CONFIG.groupParamNames) { const value = normalizeGroup(url.searchParams.get(name)); if (value) return value; } } catch (_) {} return ''; } function getElementMeta(el) { const parts = [ el.getAttribute('name'), el.id, el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('placeholder'), typeof el.className === 'string' ? el.className : '', ]; if (el.labels) { parts.push(...Array.from(el.labels).map((label) => label.textContent || '')); } const closestLabel = el.closest('label')?.textContent || ''; if (closestLabel) parts.push(closestLabel); const parentText = el.parentElement?.textContent || ''; if (parentText.length < 120) parts.push(parentText); return parts.filter(Boolean).join(' ').toLowerCase(); } function looksLikeGroupControl(el) { const meta = getElementMeta(el); return meta.includes('group') || meta.includes('分组'); } function getGroupFromDom() { const controls = Array.from(document.querySelectorAll('select,input')).filter(looksLikeGroupControl); for (const el of controls) { const value = normalizeGroup( el.tagName === 'SELECT' ? el.value || el.selectedOptions?.[0]?.value || el.selectedOptions?.[0]?.textContent || '' : el.value || el.getAttribute('value') || '' ); if (value) return value; } const labelEls = Array.from(document.querySelectorAll('label,.ant-form-item-label,[class*="label"],[class*="Label"]')) .filter((el) => /分组|group/i.test(el.textContent || '')); for (const label of labelEls) { const container = label.closest('.ant-form-item') || label.parentElement; const candidates = [ container?.querySelector('select')?.value, container?.querySelector('select')?.selectedOptions?.[0]?.value, container?.querySelector('select')?.selectedOptions?.[0]?.textContent, container?.querySelector('input')?.value, container?.querySelector('.ant-select-selection-item')?.getAttribute('title'), container?.querySelector('.ant-select-selection-item')?.textContent, container?.querySelector('[class*="singleValue"]')?.textContent, container?.querySelector('[class*="selected"]')?.textContent, ]; for (const candidate of candidates) { const value = normalizeGroup(candidate); if (value) return value; } } return ''; } function readCurrentGroup() { return getGroupFromUrl(location.href) || getGroupFromDom(); } function updateGroupDisplay() { const el = document.querySelector('#sub2api-checker-current-group'); if (!el) return; el.textContent = state.currentGroup ? (state.currentGroupId && state.currentGroupId !== state.currentGroup ? `${state.currentGroup} (#${state.currentGroupId})` : state.currentGroup) : '未识别'; el.style.color = state.currentGroup ? '#95de64' : '#ffd666'; } function saveCurrentGroup(group, source = '') { const normalized = normalizeGroup(group); if (!normalized) return false; const normalizedGroupId = normalizeGroupId(normalized); const changed = state.currentGroup !== normalized; state.currentGroup = normalized; state.currentGroupId = normalizedGroupId; localStorage.setItem(CONFIG.currentGroupStorageKey, normalized); updateGroupDisplay(); if (source && changed) { log(`已识别当前分组:${normalized}(${source})`, 'success'); } return true; } function startGroupSelectionWatcher() { document.addEventListener('change', (event) => { const target = event.target; if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) return; if (!looksLikeGroupControl(target)) return; const group = readCurrentGroup(); if (group) saveCurrentGroup(group, '页面选择'); }, true); document.addEventListener('click', () => { setTimeout(() => { const group = readCurrentGroup(); if (group) saveCurrentGroup(group, '页面选择'); }, 120); }, true); } async function injectAuthSniffer() { while (!document.documentElement) { await new Promise((resolve) => setTimeout(resolve, 0)); } const script = document.createElement('script'); const nonce = document.querySelector('script[nonce]')?.nonce || document.querySelector('script[nonce]')?.getAttribute('nonce'); if (nonce) script.nonce = nonce; script.textContent = ` (() => { const groupParamNames = ${JSON.stringify(CONFIG.groupParamNames)}; const emit = (auth) => { if (!auth) return; document.dispatchEvent(new CustomEvent('__sub2api_checker_auth__', { detail: auth })); }; const emitGroup = (urlLike) => { try { if (!urlLike) return; const url = new URL(String(urlLike), location.origin); if (!url.pathname.includes('/api/v1/admin/accounts')) return; for (const name of groupParamNames) { const value = (url.searchParams.get(name) || '').trim(); if (value) { document.dispatchEvent(new CustomEvent('__sub2api_checker_group__', { detail: value })); return; } } } catch (_) {} }; const pickAuth = (headersLike) => { try { if (!headersLike) return ''; if (headersLike instanceof Headers) { return headersLike.get('Authorization') || headersLike.get('authorization') || ''; } if (Array.isArray(headersLike)) { for (const [k, v] of headersLike) { if (String(k).toLowerCase() === 'authorization') return v || ''; } return ''; } if (typeof headersLike === 'object') { for (const key of Object.keys(headersLike)) { if (key.toLowerCase() === 'authorization') return headersLike[key] || ''; } } } catch (_) {} return ''; }; const origFetch = window.fetch; if (origFetch) { window.fetch = function(input, init) { const auth = pickAuth(init && init.headers) || pickAuth(input && input.headers); if (auth) emit(auth); const requestUrl = typeof input === 'string' || input instanceof URL ? String(input) : input && input.url; emitGroup(requestUrl); return origFetch.apply(this, arguments); }; } const origOpen = XMLHttpRequest.prototype.open; const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function() { this.__sub2apiAuth = ''; emitGroup(arguments[1]); return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function(name, value) { if (String(name).toLowerCase() === 'authorization' && value) { this.__sub2apiAuth = value; emit(value); } return origSetHeader.apply(this, arguments); }; })(); `; document.documentElement.appendChild(script); script.remove(); document.addEventListener('__sub2api_checker_auth__', (event) => { saveAuth(event.detail); }); document.addEventListener('__sub2api_checker_group__', (event) => { saveCurrentGroup(event.detail, '账号列表请求'); }); } function updateStats() { const el = document.querySelector('#sub2api-checker-stats'); if (!el) return; const s = state.stats; const queued = Math.max(0, s.total - s.checked - s.active); el.textContent = `总数 ${s.total} | 运行中 ${s.active} | 队列 ${queued} | 已处理 ${s.checked} | 正常 ${s.ok} | 已启用 ${s.enabled} | 已关闭 ${s.disabled} | 已删除 ${s.deleted} | 跳过 ${s.skipped} | 异常 ${s.failed}`; } function decodeJwtPayload(token) { try { const parts = String(token || '').split('.'); if (parts.length !== 3) return {}; let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); const padding = payload.length % 4; if (padding) payload += '='.repeat(4 - padding); const jsonText = decodeURIComponent( atob(payload) .split('') .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`) .join('') ); return JSON.parse(jsonText); } catch (_) { return {}; } } function firstNonEmptyString(candidates) { for (const value of candidates) { if (typeof value === 'string' && value.trim()) return value.trim(); } return ''; } function parseExpiredTime(expiredString) { try { if (!expiredString) return 0; const date = String(expiredString).includes('+') ? new Date(expiredString) : new Date(String(expiredString).replace('Z', '+00:00')); const timestamp = Math.floor(date.getTime() / 1000); return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0; } catch (_) { return 0; } } function extractAccountIdFromPayload(payload) { if (!payload || typeof payload !== 'object') return ''; const authInfo = payload['https://api.openai.com/auth'] || {}; return firstNonEmptyString([ authInfo.chatgpt_account_id, authInfo.chatgptAccountId, authInfo.account_id, authInfo.accountId, payload.chatgpt_account_id, payload.chatgptAccountId, payload.account_id, payload.accountId, payload.openai_account_id, ]); } function extractAccountIdFromCpa(sourceData) { const credentials = sourceData?.credentials && typeof sourceData.credentials === 'object' ? sourceData.credentials : {}; const extra = sourceData?.extra && typeof sourceData.extra === 'object' ? sourceData.extra : {}; const metadata = sourceData?.metadata && typeof sourceData.metadata === 'object' ? sourceData.metadata : {}; const accessToken = firstNonEmptyString([ sourceData?.access_token, sourceData?.accessToken, credentials.access_token, credentials.accessToken, metadata.access_token, metadata.accessToken, ]); const idToken = firstNonEmptyString([ sourceData?.id_token, sourceData?.idToken, credentials.id_token, credentials.idToken, metadata.id_token, metadata.idToken, ]); return firstNonEmptyString([ sourceData?.chatgpt_account_id, sourceData?.chatgptAccountId, sourceData?.account_id, sourceData?.accountId, sourceData?.openai_account_id, credentials.chatgpt_account_id, credentials.chatgptAccountId, credentials.account_id, credentials.accountId, credentials.openai_account_id, extra.chatgpt_account_id, extra.chatgptAccountId, extra.account_id, extra.accountId, metadata.chatgpt_account_id, metadata.chatgptAccountId, metadata.account_id, metadata.accountId, extractAccountIdFromPayload(decodeJwtPayload(idToken)), extractAccountIdFromPayload(decodeJwtPayload(accessToken)), ]); } function normalizeCpaInput(data) { if (Array.isArray(data)) return data; if (data && Array.isArray(data.accounts)) return data.accounts; if (data && Array.isArray(data.files)) return data.files.map((item) => item?.json || item).filter(Boolean); if (data && typeof data === 'object') return [data]; return []; } function applySub2apiAccountGroup(account, group) { const normalized = normalizeGroup(group); if (!normalized) return account; account.group = normalized; account.groups = [normalized]; account.account_group = normalized; account.accountGroup = normalized; account.extra = { ...(account.extra || {}), group: normalized, groups: [normalized], account_group: normalized, accountGroup: normalized, }; return account; } function buildSub2apiAccountFromCpa(sourceData, index, group = '', batchTag = '') { const credentials = sourceData?.credentials && typeof sourceData.credentials === 'object' ? sourceData.credentials : {}; const accessToken = firstNonEmptyString([sourceData?.access_token, sourceData?.accessToken, credentials.access_token, credentials.accessToken]); const refreshToken = firstNonEmptyString([sourceData?.refresh_token, sourceData?.refreshToken, credentials.refresh_token, credentials.refreshToken]); const accessPayload = decodeJwtPayload(accessToken); const authInfo = accessPayload['https://api.openai.com/auth'] || {}; const idPayload = decodeJwtPayload(firstNonEmptyString([sourceData?.id_token, sourceData?.idToken, credentials.id_token, credentials.idToken])); const idAuthInfo = idPayload['https://api.openai.com/auth'] || {}; const organizations = Array.isArray(idAuthInfo.organizations) ? idAuthInfo.organizations : []; const accountId = extractAccountIdFromCpa(sourceData); const expiresAt = parseExpiredTime(sourceData?.expired || credentials.expired) || Number(credentials.expires_at || credentials.expiresAt || accessPayload.exp || 0); const accountType = firstNonEmptyString([sourceData?.type, credentials.type, 'unknown']); const email = firstNonEmptyString([sourceData?.email, sourceData?.account, sourceData?.label, sourceData?.extra?.email, credentials.email, idPayload.email, accessPayload.email]); const account = { name: `${accountType}-普号-${batchTag ? `${batchTag}-` : ''}${String(index).padStart(4, '0')}`, platform: 'openai', type: 'oauth', credentials: { access_token: accessToken, chatgpt_account_id: accountId, chatgpt_user_id: firstNonEmptyString([authInfo.chatgpt_user_id, credentials.chatgpt_user_id, credentials.chatgptUserId]), expires_at: expiresAt, expires_in: Number(credentials.expires_in || credentials.expiresIn || 864000), organization_id: firstNonEmptyString([credentials.organization_id, credentials.organizationId, organizations[0]?.id]), refresh_token: refreshToken, }, extra: { email, }, concurrency: Number(sourceData?.concurrency || credentials.concurrency || 10), priority: Number(sourceData?.priority || credentials.priority || 1), rate_multiplier: Number(sourceData?.rate_multiplier || sourceData?.rateMultiplier || credentials.rate_multiplier || credentials.rateMultiplier || 1), auto_pause_on_expired: true, }; return applySub2apiAccountGroup(account, group); } function convertCpaAuthItemsToSub2api(items, group = '', batchTag = '') { const accounts = []; const issues = []; const normalizedGroup = normalizeGroup(group); normalizeCpaInput(items).forEach((sourceData, index) => { if (!sourceData || typeof sourceData !== 'object' || Array.isArray(sourceData)) { issues.push(`第 ${index + 1} 项不是对象`); return; } const accountId = extractAccountIdFromCpa(sourceData); const credentials = sourceData.credentials && typeof sourceData.credentials === 'object' ? sourceData.credentials : {}; const accessToken = firstNonEmptyString([sourceData.access_token, sourceData.accessToken, credentials.access_token, credentials.accessToken]); if (!accessToken || !accountId) { issues.push(`第 ${index + 1} 项缺少 access_token 或 account_id`); return; } accounts.push(buildSub2apiAccountFromCpa(sourceData, accounts.length + 1, normalizedGroup, batchTag)); }); const output = { exported_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), proxies: [], accounts, }; if (normalizedGroup) { output.current_group = normalizedGroup; output.default_group = normalizedGroup; output.group = normalizedGroup; output.groups = [normalizedGroup]; } return { output, issues, }; } function downloadText(filename, text) { const blob = new Blob([text], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function readJsonFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { try { resolve(JSON.parse(String(reader.result || ''))); } catch (err) { reject(new Error(`${file.name} JSON 解析失败:${err?.message || String(err)}`)); } }; reader.onerror = () => reject(new Error(`${file.name} 读取失败`)); reader.readAsText(file, 'utf-8'); }); } function updateCpaConvertStatus(message, type = 'info') { const el = document.querySelector('#sub2api-checker-cpa-status'); if (!el) return; el.textContent = message; el.style.color = type === 'error' ? '#ff7875' : type === 'success' ? '#95de64' : type === 'warn' ? '#ffd666' : '#bfbfbf'; } function unwrapApiData(json) { return json && typeof json === 'object' && Object.prototype.hasOwnProperty.call(json, 'data') ? json.data : json; } async function readApiJson(resp) { const text = await resp.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (_) {} if (!resp.ok) throw new Error(`HTTP ${resp.status}${text ? `:${text.slice(0, 300)}` : ''}`); if (json && json.code !== undefined && json.code !== 0) throw new Error(json.message || `code=${json.code}`); return unwrapApiData(json); } function normalizeGroupList(data) { if (Array.isArray(data)) return data; if (Array.isArray(data?.items)) return data.items; if (Array.isArray(data?.groups)) return data.groups; return []; } async function resolveGroupId(groupName) { const target = normalizeGroup(groupName); if (!target) return ''; const directGroupId = normalizeGroupId(target); if (directGroupId) return directGroupId; const endpoints = [ `${CONFIG.apiBase}/api/v1/admin/groups/all?platform=openai`, `${CONFIG.apiBase}/api/v1/admin/groups?page=1&page_size=100&search=${encodeURIComponent(target)}`, ]; for (const endpoint of endpoints) { try { const data = await readApiJson(await apiFetch(endpoint, { headers: { Accept: 'application/json, text/plain, */*' }, })); const matched = normalizeGroupList(data).find((group) => { const id = String(group?.id ?? group?.group_id ?? group?.groupId ?? '').trim(); const name = String(group?.name ?? group?.group_name ?? group?.groupName ?? group?.label ?? '').trim(); return id === target || name === target; }); if (matched) return String(matched.id ?? matched.group_id ?? matched.groupId ?? '').trim(); } catch (err) { log(`读取分组接口失败:${err?.message || String(err)}`, 'warn'); } } return ''; } async function resolveAccountListGroupParam(groupName) { const target = normalizeGroup(groupName); if (!target) return ''; const directGroupId = normalizeGroupId(target); if (directGroupId) { state.currentGroupId = directGroupId; updateGroupDisplay(); return directGroupId; } const resolvedGroupId = await resolveGroupId(target); if (!resolvedGroupId) { throw new Error(`未找到分组 ID:${target}。账号列表接口的 group 参数必须传数字 ID,不接受分组名称。`); } state.currentGroupId = resolvedGroupId; updateGroupDisplay(); log(`已解析分组 ID:${target} -> ${resolvedGroupId}`, 'success'); return resolvedGroupId; } function normalizeAccountList(data) { if (Array.isArray(data)) return data; if (Array.isArray(data?.items)) return data.items; if (Array.isArray(data?.accounts)) return data.accounts; return []; } async function findImportedAccountIdsByBatch(batchTag, expectedCount) { const ids = new Set(); let page = 1; const pageSize = Math.min(Math.max(Number(expectedCount) || CONFIG.pageSize, 20), 100); while (true) { const url = new URL('/api/v1/admin/accounts', CONFIG.apiBase); url.searchParams.set('page', String(page)); url.searchParams.set('page_size', String(pageSize)); url.searchParams.set('platform', 'openai'); url.searchParams.set('type', ''); url.searchParams.set('status', ''); url.searchParams.set('privacy_mode', ''); url.searchParams.set('group', ''); url.searchParams.set('search', batchTag); url.searchParams.set('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'); const data = await readApiJson(await apiFetch(url.toString(), { headers: { Accept: 'application/json, text/plain, */*' }, })); const accounts = normalizeAccountList(data); accounts .filter((item) => String(item?.name || '').includes(batchTag)) .forEach((item) => { const id = item?.id; if (id !== undefined && id !== null) ids.add(id); }); if (ids.size >= expectedCount || accounts.length < pageSize) break; page += 1; } return Array.from(ids); } async function bindAccountsToGroup(accountIds, groupId) { const normalizedIds = Array.from(new Set(accountIds)); await readApiJson(await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/bulk-update`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ account_ids: normalizedIds, group_ids: [Number(groupId) || groupId], confirm_mixed_channel_risk: true, }), })); return { success: normalizedIds.length, total: normalizedIds.length }; } async function bindImportedAccountsToCurrentGroup(payload, groupName, batchTag) { const target = normalizeGroup(groupName); if (!target) return { ok: false, reason: '未识别到当前分组' }; if (!batchTag) return { ok: false, reason: '缺少批次标记,无法定位本批账号' }; const groupId = await resolveGroupId(target); if (!groupId) return { ok: false, reason: `未找到分组 ID:${target}` }; const accountIds = await findImportedAccountIdsByBatch(batchTag, (payload.accounts || []).length); if (!accountIds.length) return { ok: false, reason: '导入后未定位到本批账号' }; updateCpaConvertStatus(`已通过批次标记 ${batchTag} 定位 ${accountIds.length} 个账号,正在批量修改分组...`); const result = await bindAccountsToGroup(accountIds, groupId); return { ok: true, count: result.success, groupId }; } async function handleCpaFilesToSub2api() { const input = document.querySelector('#sub2api-checker-cpa-files'); const files = Array.from(input?.files || []); if (!files.length) { updateCpaConvertStatus('请选择 CPA 认证 JSON 文件', 'error'); return; } if (!(await ensureAuth())) { updateCpaConvertStatus('缺少 Authorization,无法自动导入 sub2api', 'error'); return; } const detectedGroup = readCurrentGroup(); const targetGroup = normalizeGroup(detectedGroup || state.currentGroup || localStorage.getItem(CONFIG.currentGroupStorageKey)); if (targetGroup) saveCurrentGroup(targetGroup, detectedGroup ? '当前页面' : '本地缓存'); updateCpaConvertStatus(`正在读取 ${files.length} 个文件${targetGroup ? `,将导入到分组:${targetGroup}` : ''}...`); try { const batchTag = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').toLowerCase(); const parsedFiles = await Promise.all(files.map(readJsonFile)); const items = parsedFiles.flatMap((data) => normalizeCpaInput(data)); const result = convertCpaAuthItemsToSub2api(items, targetGroup, batchTag); if (!result.output.accounts.length) { throw new Error(`没有生成有效账号${result.issues.length ? `:${result.issues.join(';')}` : ''}`); } const text = JSON.stringify(result.output, null, 2); downloadText(`sub2api-cpa-import-${batchTag}.json`, text); updateCpaConvertStatus(`已生成备份 JSON,正在自动导入 sub2api${targetGroup ? `(分组:${targetGroup})` : ''}...`); const imported = await importSub2apiData(result.output); if (!imported.ok) { throw new Error(`自动导入失败:${imported.reason}`); } let bindText = ''; if (targetGroup) { updateCpaConvertStatus(`导入完成,正在绑定分组:${targetGroup}...`); const bound = await bindImportedAccountsToCurrentGroup(result.output, targetGroup, batchTag); bindText = bound.ok ? `,已绑定 ${bound.count} 个账号到分组:${targetGroup}` : `,分组绑定失败:${bound.reason}`; } const issueText = result.issues.length ? `,跳过 ${result.issues.length} 项:${result.issues.slice(0, 3).join(';')}` : ''; const groupText = targetGroup ? bindText : ',已导入但未识别到当前分组'; updateCpaConvertStatus(`已生成备份 JSON,并自动导入 ${result.output.accounts.length} 个账号${groupText}${issueText}`, result.issues.length ? 'warn' : 'success'); log(`CPA -> Sub2API 转换并导入完成:${result.output.accounts.length} 个账号${groupText}${issueText}`, result.issues.length ? 'warn' : 'success'); } catch (err) { updateCpaConvertStatus(err?.message || String(err), 'error'); log(`CPA -> Sub2API 转换失败:${err?.message || String(err)}`, 'error'); } } function updatePanelCollapsed() { const shell = document.querySelector('#sub2api-checker-shell'); const root = document.querySelector('#sub2api-checker-panel'); const toggle = document.querySelector('#sub2api-checker-toggle'); if (!root || !toggle || !shell) return; root.style.width = state.collapsed ? '0px' : '460px'; root.style.opacity = state.collapsed ? '0' : '1'; root.style.marginRight = state.collapsed ? '0px' : '12px'; root.style.pointerEvents = state.collapsed ? 'none' : 'auto'; root.style.transform = state.collapsed ? 'translateX(12px)' : 'translateX(0)'; toggle.textContent = state.collapsed ? '账号巡检' : '收起'; toggle.style.borderRadius = state.collapsed ? '10px 0 0 10px' : '10px'; shell.style.pointerEvents = 'auto'; } function ensurePanel() { if (state.panelReady) return; state.panelReady = true; const shell = document.createElement('div'); shell.id = 'sub2api-checker-shell'; shell.style.cssText = ` position: fixed; right: 0; top: 120px; z-index: 1000000; display: flex; flex-direction: row; align-items: flex-start; pointer-events: auto; `; document.body.appendChild(shell); const toggle = document.createElement('button'); toggle.id = 'sub2api-checker-toggle'; toggle.style.cssText = ` padding: 10px 8px; border: 0; border-radius: 10px 0 0 10px; background: #1677ff; color: #fff; cursor: pointer; writing-mode: vertical-rl; text-orientation: mixed; box-shadow: 0 8px 24px rgba(0,0,0,.25); transition: transform .28s ease, box-shadow .28s ease, border-radius .28s ease; font: 12px/1.2 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,PingFang SC,Microsoft YaHei,sans-serif; `; toggle.addEventListener('mouseenter', () => { toggle.style.transform = 'translateX(-2px)'; toggle.style.boxShadow = '0 10px 28px rgba(0,0,0,.32)'; }); toggle.addEventListener('mouseleave', () => { toggle.style.transform = 'translateX(0)'; toggle.style.boxShadow = '0 8px 24px rgba(0,0,0,.25)'; }); toggle.addEventListener('click', () => { state.collapsed = !state.collapsed; updatePanelCollapsed(); }); shell.appendChild(toggle); const root = document.createElement('div'); root.id = 'sub2api-checker-panel'; root.style.cssText = ` width: 0; opacity: 0; overflow: hidden; transition: width .28s ease, opacity .22s ease, margin-right .28s ease, transform .28s ease; transform: translateX(12px); `; root.innerHTML = ` <div id="sub2api-checker-panel-inner" style=" width:460px; max-height:calc(100vh - 140px); background:rgba(16, 18, 27, 0.96); color:#fff; border:1px solid #30363d; border-radius:12px; box-shadow:0 8px 24px rgba(0,0,0,.35); font:12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,PingFang SC,Microsoft YaHei,sans-serif; overflow-y:auto; overflow-x:hidden; scrollbar-width:thin; "> <div style="padding:12px 14px;border-bottom:1px solid #30363d;font-weight:700;">Sub2API 账号模型巡检</div> <div style="padding:12px 14px;display:flex;flex-direction:column;gap:8px;"> <label style="display:flex;flex-direction:column;gap:4px;"> <span>Authorization(优先自动捕获,抓不到再手填)</span> <input id="sub2api-checker-auth" type="text" placeholder="Bearer xxxxxx" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;flex-direction:column;gap:4px;"> <span>单模型超时时间(秒)</span> <input id="sub2api-checker-timeout" type="number" min="1" step="1" placeholder="45" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;flex-direction:column;gap:4px;"> <span>账号并发数(1-${CONFIG.maxConcurrency},建议 5-15)</span> <input id="sub2api-checker-concurrency" type="number" min="1" max="${CONFIG.maxConcurrency}" step="1" placeholder="8" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;flex-direction:column;gap:4px;"> <span>测试模型</span> <input id="sub2api-checker-test-model" type="text" placeholder="gpt-5.4" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;gap:8px;align-items:center;color:#d9d9d9;"> <input id="sub2api-checker-auto-disable" type="checkbox" style="margin:0;" /> <span>模型异常时自动关闭账号调度</span> </label> <label style="display:flex;gap:8px;align-items:center;color:#d9d9d9;"> <input id="sub2api-checker-auto-delete" type="checkbox" style="margin:0;" /> <span>模型异常时自动删除账号</span> </label> <div style="display:flex;gap:8px;align-items:center;color:#bfbfbf;"> <span>当前页面分组:</span> <strong id="sub2api-checker-current-group" style="flex:1;color:#ffd666;">未识别</strong> <button id="sub2api-checker-refresh-group" style="padding:6px 8px;border:0;border-radius:8px;background:#434a57;color:#fff;cursor:pointer;">重新读取</button> </div> <div style="display:flex;gap:8px;align-items:center;"> <button id="sub2api-checker-start" style="flex:1;padding:8px 10px;border:0;border-radius:8px;background:#1677ff;color:#fff;cursor:pointer;">开始巡检</button> <button id="sub2api-checker-stop" style="flex:1;padding:8px 10px;border:0;border-radius:8px;background:#fa541c;color:#fff;cursor:pointer;">停止</button> </div> <div style="border:1px solid #13c2c2;border-radius:8px;background:#071f24;padding:10px;display:flex;flex-direction:column;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;color:#e6fffb;font-weight:700;"> <span style="display:inline-block;width:6px;height:18px;border-radius:4px;background:#13c2c2;"></span> <span>CPA -> Sub2API JSON</span> </div> <input id="sub2api-checker-cpa-files" type="file" multiple accept=".json,application/json" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #2f6f76;background:#111723;color:#fff;" /> <button id="sub2api-checker-cpa-convert" type="button" style="padding:8px 10px;border:0;border-radius:8px;background:#13c2c2;color:#001314;font-weight:700;cursor:pointer;">生成备份 JSON 并自动导入当前分组</button> <div id="sub2api-checker-cpa-status" style="color:#bfbfbf;min-height:18px;">选择一个或多个 CPA 认证 JSON 文件后自动导入。</div> </div> <div id="sub2api-checker-stats" style="color:#bfbfbf;">总数 0 | 运行中 0 | 队列 0 | 已处理 0 | 正常 0 | 已启用 0 | 已关闭 0 | 已删除 0 | 跳过 0 | 异常 0</div> <div id="sub2api-checker-log" style="height:min(320px, 36vh);overflow:auto;background:#0b0f17;border:1px solid #30363d;border-radius:8px;padding:8px;"></div> </div> </div> `; shell.appendChild(root); const authInput = root.querySelector('#sub2api-checker-auth'); authInput.value = state.authHeader; authInput.addEventListener('change', () => { const v = authInput.value.trim(); if (v) saveAuth(v); }); const timeoutInput = root.querySelector('#sub2api-checker-timeout'); timeoutInput.value = String(Math.floor(state.timeoutMs / 1000)); timeoutInput.addEventListener('change', () => { const sec = Number(timeoutInput.value || 0); if (!saveTimeoutMs(sec * 1000)) { timeoutInput.value = String(Math.floor(state.timeoutMs / 1000)); log('超时时间无效,需大于等于 1 秒', 'error'); return; } log(`已设置单模型超时 ${sec} 秒`, 'success'); }); const concurrencyInput = root.querySelector('#sub2api-checker-concurrency'); concurrencyInput.value = String(state.concurrency); concurrencyInput.addEventListener('change', () => { saveConcurrency(concurrencyInput.value || CONFIG.defaultConcurrency); log(`已设置账号并发数 ${state.concurrency}`, 'success'); }); const testModelInput = root.querySelector('#sub2api-checker-test-model'); testModelInput.value = state.testModel; testModelInput.addEventListener('change', () => { const model = testModelInput.value.trim(); if (!saveTestModel(model)) { testModelInput.value = state.testModel; log('测试模型不能为空', 'error'); return; } log(`已设置测试模型 ${state.testModel}`, 'success'); }); const autoDisableInput = root.querySelector('#sub2api-checker-auto-disable'); autoDisableInput.checked = state.autoDisable; autoDisableInput.addEventListener('change', () => { saveAutoDisable(autoDisableInput.checked); log(`模型异常时${state.autoDisable ? '会' : '不会'}自动关闭账号调度`, 'success'); }); const autoDeleteInput = root.querySelector('#sub2api-checker-auto-delete'); autoDeleteInput.checked = state.autoDelete; autoDeleteInput.addEventListener('change', () => { saveAutoDelete(autoDeleteInput.checked); log(`模型异常时${state.autoDelete ? '会' : '不会'}自动删除账号`, 'success'); }); root.querySelector('#sub2api-checker-refresh-group').addEventListener('click', () => { const group = readCurrentGroup(); if (group) { saveCurrentGroup(group, '当前页面'); } else { updateGroupDisplay(); log('未能从当前页面识别分组,开始时会弹窗确认', 'warn'); } }); root.querySelector('#sub2api-checker-start').addEventListener('click', () => run().catch((err) => { log(`运行异常:${err.message}`, 'error'); state.running = false; })); root.querySelector('#sub2api-checker-stop').addEventListener('click', () => { state.stopRequested = true; log('已请求停止,当前请求结束后退出', 'warn'); }); root.querySelector('#sub2api-checker-cpa-convert').addEventListener('click', () => { handleCpaFilesToSub2api(); }); root.querySelector('#sub2api-checker-cpa-files').addEventListener('change', (event) => { const count = event.target.files?.length || 0; updateCpaConvertStatus(count ? `已选择 ${count} 个 CPA 认证 JSON 文件` : '选择一个或多个 CPA 认证 JSON 文件后生成。'); }); updateGroupDisplay(); updatePanelCollapsed(); } async function waitDomReady() { if (document.body) return; await new Promise((resolve) => { const timer = setInterval(() => { if (document.body) { clearInterval(timer); resolve(); } }, 50); }); } async function apiFetch(url, options = {}) { const headers = new Headers(options.headers || {}); if (state.authHeader && !headers.has('Authorization')) { headers.set('Authorization', state.authHeader); } const resp = await fetch(url, { ...options, headers, credentials: 'include', }); return resp; } async function fetchAccounts() { let page = 1; const items = []; const groupParam = await resolveAccountListGroupParam(state.currentGroup); while (true) { const url = new URL('/api/v1/admin/accounts', CONFIG.apiBase); url.searchParams.set('page', String(page)); url.searchParams.set('page_size', String(CONFIG.pageSize)); if (groupParam) url.searchParams.set('group', groupParam); const resp = await apiFetch(url.toString(), { headers: { Accept: 'application/json, text/plain, */*' }, }); if (!resp.ok) { const text = await resp.text(); throw new Error(`账号列表请求失败:HTTP ${resp.status}${text ? `:${text.slice(0, 300)}` : ''}`); } const json = await resp.json(); if (json.code !== 0) throw new Error(`账号列表返回异常:${json.message || json.code}`); const pageItems = json?.data?.items || []; items.push(...pageItems); const pages = Number(json?.data?.pages || 1); if (page >= pages || pageItems.length === 0) break; page += 1; } return items; } function getModels(account) { const targetModel = String(state.testModel || '').trim(); if (targetModel) return [targetModel]; const mapping = account?.credentials?.model_mapping || {}; const keys = Object.keys(mapping).filter(Boolean); if (keys.length <= 1) return keys; const preferred = []; for (const model of CONFIG.preferredModels) { if (keys.includes(model)) preferred.push(model); } const rest = keys.filter((k) => !preferred.includes(k)).sort(); return [...preferred, ...rest]; } async function testModel(accountId, modelId) { const controller = new AbortController(); let timer = null; const resetTimer = () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(new Error(`模型 ${modelId} 流式超时`)), state.timeoutMs); }; try { resetTimer(); const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/${accountId}/test`, { method: 'POST', headers: { Accept: '*/*', 'Content-Type': 'application/json', }, body: JSON.stringify({ model_id: modelId, prompt: CONFIG.prompt }), signal: controller.signal, }); if (!resp.ok) { clearTimeout(timer); return { ok: false, reason: `HTTP ${resp.status}` }; } const reader = resp.body?.getReader(); if (!reader) { clearTimeout(timer); const text = await resp.text(); return { ok: false, reason: `无响应流:${text.slice(0, 200)}` }; } const decoder = new TextDecoder(); let buffer = ''; while (true) { resetTimer(); const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }).replace(/\r/g, ''); let splitIndex; while ((splitIndex = buffer.indexOf('\n\n')) >= 0) { const chunk = buffer.slice(0, splitIndex); buffer = buffer.slice(splitIndex + 2); const dataLines = chunk .split('\n') .map((line) => line.trim()) .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trim()); for (const line of dataLines) { if (!line) continue; let event; try { event = JSON.parse(line); } catch (_) { continue; } if (event.type === 'error') { clearTimeout(timer); return { ok: false, reason: event.error || '未知错误' }; } if (event.type === 'test_complete') { clearTimeout(timer); return { ok: !!event.success, reason: event.success ? 'success' : 'test_complete=false' }; } } } } clearTimeout(timer); return { ok: false, reason: '响应流结束但没有 test_complete' }; } catch (err) { clearTimeout(timer); return { ok: false, reason: err?.name === 'AbortError' ? '请求超时' : (err?.message || String(err)), }; } } async function setAccountSchedulable(accountId, schedulable) { const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/${accountId}/schedulable`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ schedulable: !!schedulable }), }); if (!resp.ok) { return { ok: false, reason: `HTTP ${resp.status}` }; } const json = await resp.json(); if (json.code !== 0) { return { ok: false, reason: json.message || `code=${json.code}` }; } return { ok: true, data: json.data }; } async function deleteAccount(accountId) { const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/${accountId}`, { method: 'DELETE', headers: { Accept: 'application/json, text/plain, */*', }, }); if (!resp.ok) { return { ok: false, reason: `HTTP ${resp.status}` }; } const json = await resp.json(); if (json.code !== 0) { return { ok: false, reason: json.message || `code=${json.code}` }; } return { ok: true, data: json.data }; } async function importSub2apiData(payload) { const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/data`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ data: payload, skip_default_group_bind: false, }), }); const text = await resp.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (_) {} if (!resp.ok) { return { ok: false, reason: `HTTP ${resp.status}${text ? `:${text.slice(0, 300)}` : ''}` }; } if (json && json.code !== 0) { return { ok: false, reason: json.message || `code=${json.code}` }; } return { ok: true, data: json?.data ?? json }; } async function handleModelFailure(account, title, reason) { if (state.autoDelete) { log(`${title} ${reason},准备删除账号`, 'error'); const deleted = await deleteAccount(account.id); if (deleted.ok) { state.stats.deleted += 1; log(`${title} 已删除账号(原因:${reason})`, 'success'); } else { log(`${title} 删除账号失败:${deleted.reason}`, 'error'); } return; } if (state.autoDisable) { log(`${title} ${reason},准备关闭 schedulable`, 'error'); const off = await setAccountSchedulable(account.id, false); if (off.ok) { state.stats.disabled += 1; log(`${title} 已关闭 schedulable(原因:${reason})`, 'success'); } else { log(`${title} 关闭失败:${off.reason}`, 'error'); } return; } log(`${title} 检测到异常但未处理账号(原因:${reason})`, 'warn'); } function resetStats() { state.stats = { total: 0, checked: 0, active: 0, started: 0, ok: 0, enabled: 0, disabled: 0, deleted: 0, skipped: 0, failed: 0, }; updateStats(); const logBox = document.querySelector('#sub2api-checker-log'); if (logBox) logBox.innerHTML = ''; } async function ensureAuth() { const cached = getCachedAuthToken(); if (cached) { saveAuth(cached); return true; } if (state.authHeader) return true; const fromInput = document.querySelector('#sub2api-checker-auth')?.value?.trim(); if (fromInput) { saveAuth(fromInput); return true; } const manual = prompt('没有自动捕获到 Authorization,请粘贴 Bearer token'); if (!manual) return false; saveAuth(manual.trim()); return true; } async function ensureGroup() { const detected = readCurrentGroup(); if (detected) { return saveCurrentGroup(detected, '当前页面'); } if (state.currentGroup) return true; const lastGroup = normalizeGroup(localStorage.getItem(CONFIG.currentGroupStorageKey)); const manual = prompt( `没有识别到当前页面选择的分组,请填写本次测活分组。${lastGroup ? `\n上次识别到:${lastGroup}` : ''}`, lastGroup || '' ); if (!manual) return false; return saveCurrentGroup(manual.trim(), '手动确认'); } async function processAccount(account) { const title = `#${account.id} ${account.name || '(未命名)'}`; try { if (CONFIG.onlyCheckSchedulable && !account.schedulable) { state.stats.skipped += 1; log(`${title} 已去掉调度,跳过测试`, 'warn'); return; } const models = getModels(account); if (!models.length) { state.stats.failed += 1; await handleModelFailure(account, title, '没有 model_mapping'); return; } log(`${title} 开始测试 ${models.length} 个模型`); let accountOk = true; let failReason = ''; let testedCount = 0; for (const model of models) { if (state.stopRequested) break; log(`${title} 测试模型 ${model}`); const result = await testModel(account.id, model); testedCount += 1; if (!result.ok) { accountOk = false; failReason = `模型 ${model} 异常:${result.reason}`; log(`${title} ${failReason}`, 'error'); if (CONFIG.stopOnFirstModelFailure) break; } else { log(`${title} 模型 ${model} 正常`, 'success'); } } if (state.stopRequested && testedCount < models.length && accountOk) { state.stats.skipped += 1; log(`${title} 因停止请求未完成全部模型测试,未改动 schedulable`, 'warn'); return; } if (accountOk) { state.stats.ok += 1; if (!account.schedulable) { const on = await setAccountSchedulable(account.id, true); if (on.ok) { state.stats.enabled += 1; log(`${title} 全部模型正常,已重新启用 schedulable`, 'success'); } else { log(`${title} 模型正常但重新启用失败:${on.reason}`, 'error'); } } else { log(`${title} 全部模型正常`, 'success'); } } else { state.stats.failed += 1; await handleModelFailure(account, title, failReason); } } finally { state.stats.checked += 1; updateStats(); } } async function runWorkerPool(accounts) { const concurrency = clampConcurrency(state.concurrency); log(`账号级并发 ${concurrency},单账号内模型按顺序测试`); async function worker(workerIndex) { while (!state.stopRequested) { const index = state.stats.started; if (index >= accounts.length) break; state.stats.started += 1; state.stats.active += 1; updateStats(); try { await processAccount(accounts[index]); } catch (err) { state.stats.failed += 1; state.stats.checked += 1; log(`工作线程 ${workerIndex} 处理账号异常:${err?.message || String(err)}`, 'error'); updateStats(); } finally { state.stats.active -= 1; updateStats(); } } } const workerCount = Math.min(concurrency, accounts.length); await Promise.all(Array.from({ length: workerCount }, (_, index) => worker(index + 1))); } async function run() { if (state.running) { log('已有任务在运行', 'warn'); return; } if (!(await ensureAuth())) { log('缺少 Authorization,已取消', 'error'); return; } if (!(await ensureGroup())) { log('缺少测活分组,已取消', 'error'); return; } state.running = true; state.stopRequested = false; resetStats(); try { state.collapsed = false; updatePanelCollapsed(); log(`开始拉取账号列表(分组:${state.currentGroup})`); const accounts = await fetchAccounts(); state.stats.total = accounts.length; updateStats(); log(`共获取 ${accounts.length} 个账号`, 'success'); await runWorkerPool(accounts); if (state.stopRequested) { log('任务已按要求停止', 'warn'); } else { log('巡检完成', 'success'); } } finally { state.running = false; state.stats.active = 0; updateStats(); } } injectAuthSniffer(); waitDomReady().then(() => { ensurePanel(); startGroupSelectionWatcher(); const group = readCurrentGroup(); if (group) saveCurrentGroup(group, '当前页面'); if (state.authHeader) { log('脚本已就绪,已从本地缓存 auth_token 读取 Authorization', 'success'); } else { log('脚本已就绪,未发现 auth_token;可刷新页面自动捕获或手动粘贴'); } }); })(); 效果: 1 个帖子 - 1 位参与者 阅读完整话题
一直在用L站拓展收藏插件 【开源自荐】LinuxDoStar: 为 L 站拓展收藏能力的chrome 浏览器插件 ,非常好用,除了常见的收藏帖子、收藏夹分类功能,还支持 收藏评论和云同步 。 但是原项目仅支持 Chrome 扩展,最近换了mac,safari用的比较多,所以在它的基础上vibe了 油猴版 ,并且新增了 拖拽 功能,在此分享给大家。 基本功能: 收藏帖子、评论 收藏夹分类 通过GitHub进行云同步 新增功能: 常驻收藏悬浮,快速打开收藏夹 拖拽功能,快速给收藏夹、收藏内容排序,快速移动收藏到其他收藏夹 修复了删除收藏后,点击同步又会将已删除的收藏同步回来的bug 安装链接: LinuxDo Star 实际效果: 收藏帖子、评论: 收藏界面: vibe coding完成,可能会有小问题,欢迎提出 非常感谢 codedogQBY 佬的开源,原贴链接 【开源自荐】LinuxDoStar: 为 L 站拓展收藏能力的chrome 浏览器插件 1 个帖子 - 1 位参与者 阅读完整话题
因为bugteam,论坛好多号可以蹬,碍于本人现在都用SUB2API,大多佬友分享的都是CPA。 故寻找脚本,基于佬友分享的脚本,补充了一下功能。 sub2api测活工具,个人自用分享一下 开发调优 Sub2API 账号模型巡检油猴脚本改版分享 说明:这个脚本是在参考站内一位大佬的油猴脚本基础上改出来的,主要是按我自己的使用场景做了一些调整。原思路来自前辈,感谢站内大佬的分享;如果有不合适的地方可以提醒我修改或删除。 主要修改 这版主要改了两个地方: 支持按分组收集账号并测活 脚本会尽量识别当前账号列表页面的分组。 拉取账号时只处理当前分组下的账号。 如果没识别到分组,会弹窗让… 测活,失败后直接删除。 cpa->sub2api,根据选择的分组,自动下载sub2api的json,并且自动导入+批量选择分组。 // ==UserScript== // @name Sub2API 账号模型巡检并自动下线 // @namespace https://sinry.example // @version 0.1.7 // @description 按当前页面分组批量测试账号模型;支持 CPA 认证文件批量转换为 sub2api JSON // @match https://{你的域名}/admin/accounts* // @run-at document-start // @grant none // ==/UserScript== (function () { 'use strict'; const CONFIG = { apiBase: location.origin, pageSize: 100, defaultTimeoutMs: 45000, defaultConcurrency: 8, maxConcurrency: 50, prompt: 'hi', onlyCheckSchedulable: false, stopOnFirstModelFailure: true, preferredModels: ['gpt-5.4', 'gpt-4o-mini', 'gpt-4o', 'gpt-4.1', 'gpt-4.1-mini'], defaultTestModel: 'gpt-5.4', groupParamNames: ['group', 'groups', 'account_group', 'accountGroup'], pageAuthTokenKey: 'auth_token', authStorageKey: '__sub2api_checker_auth__', timeoutStorageKey: '__sub2api_checker_timeout_ms__', concurrencyStorageKey: '__sub2api_checker_concurrency__', testModelStorageKey: '__sub2api_checker_test_model__', currentGroupStorageKey: '__sub2api_checker_current_group__', autoDisableStorageKey: '__sub2api_checker_auto_disable__', autoDeleteStorageKey: '__sub2api_checker_auto_delete__', }; function getCachedAuthToken() { const raw = localStorage.getItem(CONFIG.pageAuthTokenKey) || sessionStorage.getItem(CONFIG.pageAuthTokenKey) || localStorage.getItem(CONFIG.authStorageKey) || ''; return raw ? (raw.startsWith('Bearer ') ? raw : `Bearer ${raw}`) : ''; } function clampConcurrency(value) { const n = Math.floor(Number(value)); if (!Number.isFinite(n)) return CONFIG.defaultConcurrency; return Math.min(CONFIG.maxConcurrency, Math.max(1, n)); } function readSavedConcurrency() { return clampConcurrency(localStorage.getItem(CONFIG.concurrencyStorageKey) || CONFIG.defaultConcurrency); } function readSavedAutoDisable() { return localStorage.getItem(CONFIG.autoDisableStorageKey) !== 'false'; } function readSavedAutoDelete() { return localStorage.getItem(CONFIG.autoDeleteStorageKey) === 'true'; } const state = { authHeader: getCachedAuthToken(), timeoutMs: Number(localStorage.getItem(CONFIG.timeoutStorageKey) || CONFIG.defaultTimeoutMs), concurrency: readSavedConcurrency(), testModel: localStorage.getItem(CONFIG.testModelStorageKey) || CONFIG.defaultTestModel, autoDisable: readSavedAutoDisable(), autoDelete: readSavedAutoDelete(), currentGroup: '', running: false, stopRequested: false, panelReady: false, collapsed: true, stats: { total: 0, checked: 0, active: 0, started: 0, ok: 0, enabled: 0, disabled: 0, deleted: 0, skipped: 0, failed: 0, }, }; if (state.autoDelete && state.autoDisable) { state.autoDisable = false; localStorage.setItem(CONFIG.autoDisableStorageKey, 'false'); } function log(msg, type = 'info') { const time = new Date().toLocaleTimeString(); const line = `[${time}] ${msg}`; console[type === 'error' ? 'error' : 'log'](`[sub2api-checker] ${line}`); const box = document.querySelector('#sub2api-checker-log'); if (!box) return; const color = type === 'error' ? '#ff7875' : type === 'warn' ? '#ffd666' : type === 'success' ? '#95de64' : '#d9d9d9'; const row = document.createElement('div'); row.style.color = color; row.textContent = line; box.appendChild(row); box.scrollTop = box.scrollHeight; } function saveAuth(auth) { if (!auth || typeof auth !== 'string') return; const normalized = auth.startsWith('Bearer ') ? auth : `Bearer ${auth}`; const changed = state.authHeader !== normalized; state.authHeader = normalized; localStorage.setItem(CONFIG.authStorageKey, normalized); const input = document.querySelector('#sub2api-checker-auth'); if (input && !input.value) input.value = normalized; if (changed) log('已捕获 Authorization', 'success'); } function saveTimeoutMs(timeoutMs) { const n = Number(timeoutMs); if (!Number.isFinite(n) || n < 1000) return false; state.timeoutMs = n; localStorage.setItem(CONFIG.timeoutStorageKey, String(n)); const input = document.querySelector('#sub2api-checker-timeout'); if (input) input.value = String(Math.floor(n / 1000)); return true; } function saveConcurrency(concurrency) { const n = clampConcurrency(concurrency); state.concurrency = n; localStorage.setItem(CONFIG.concurrencyStorageKey, String(n)); const input = document.querySelector('#sub2api-checker-concurrency'); if (input) input.value = String(n); return true; } function saveTestModel(model) { const normalized = String(model || '').trim(); if (!normalized) return false; state.testModel = normalized; localStorage.setItem(CONFIG.testModelStorageKey, normalized); const input = document.querySelector('#sub2api-checker-test-model'); if (input) input.value = normalized; return true; } function saveAutoDisable(enabled) { state.autoDisable = !!enabled; if (state.autoDisable) { state.autoDelete = false; localStorage.setItem(CONFIG.autoDeleteStorageKey, 'false'); const autoDeleteInput = document.querySelector('#sub2api-checker-auto-delete'); if (autoDeleteInput) autoDeleteInput.checked = false; } localStorage.setItem(CONFIG.autoDisableStorageKey, String(state.autoDisable)); const input = document.querySelector('#sub2api-checker-auto-disable'); if (input) input.checked = state.autoDisable; return true; } function saveAutoDelete(enabled) { state.autoDelete = !!enabled; if (state.autoDelete) { state.autoDisable = false; localStorage.setItem(CONFIG.autoDisableStorageKey, 'false'); const autoDisableInput = document.querySelector('#sub2api-checker-auto-disable'); if (autoDisableInput) autoDisableInput.checked = false; } localStorage.setItem(CONFIG.autoDeleteStorageKey, String(state.autoDelete)); const input = document.querySelector('#sub2api-checker-auto-delete'); if (input) input.checked = state.autoDelete; return true; } function normalizeGroup(value) { if (value === undefined || value === null) return ''; const text = String(value).trim(); if (!text) return ''; if (['全部', '全部分组', 'all', 'null', 'undefined'].includes(text.toLowerCase())) return ''; return text; } function getGroupFromUrl(urlLike = location.href) { try { const url = new URL(String(urlLike), location.origin); for (const name of CONFIG.groupParamNames) { const value = normalizeGroup(url.searchParams.get(name)); if (value) return value; } } catch (_) {} return ''; } function getElementMeta(el) { const parts = [ el.getAttribute('name'), el.id, el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('placeholder'), typeof el.className === 'string' ? el.className : '', ]; if (el.labels) { parts.push(...Array.from(el.labels).map((label) => label.textContent || '')); } const closestLabel = el.closest('label')?.textContent || ''; if (closestLabel) parts.push(closestLabel); const parentText = el.parentElement?.textContent || ''; if (parentText.length < 120) parts.push(parentText); return parts.filter(Boolean).join(' ').toLowerCase(); } function looksLikeGroupControl(el) { const meta = getElementMeta(el); return meta.includes('group') || meta.includes('分组'); } function getGroupFromDom() { const controls = Array.from(document.querySelectorAll('select,input')).filter(looksLikeGroupControl); for (const el of controls) { const value = normalizeGroup( el.tagName === 'SELECT' ? el.value || el.selectedOptions?.[0]?.value || el.selectedOptions?.[0]?.textContent || '' : el.value || el.getAttribute('value') || '' ); if (value) return value; } const labelEls = Array.from(document.querySelectorAll('label,.ant-form-item-label,[class*="label"],[class*="Label"]')) .filter((el) => /分组|group/i.test(el.textContent || '')); for (const label of labelEls) { const container = label.closest('.ant-form-item') || label.parentElement; const candidates = [ container?.querySelector('select')?.value, container?.querySelector('select')?.selectedOptions?.[0]?.value, container?.querySelector('select')?.selectedOptions?.[0]?.textContent, container?.querySelector('input')?.value, container?.querySelector('.ant-select-selection-item')?.getAttribute('title'), container?.querySelector('.ant-select-selection-item')?.textContent, container?.querySelector('[class*="singleValue"]')?.textContent, container?.querySelector('[class*="selected"]')?.textContent, ]; for (const candidate of candidates) { const value = normalizeGroup(candidate); if (value) return value; } } return ''; } function readCurrentGroup() { return getGroupFromUrl(location.href) || getGroupFromDom(); } function updateGroupDisplay() { const el = document.querySelector('#sub2api-checker-current-group'); if (!el) return; el.textContent = state.currentGroup || '未识别'; el.style.color = state.currentGroup ? '#95de64' : '#ffd666'; } function saveCurrentGroup(group, source = '') { const normalized = normalizeGroup(group); if (!normalized) return false; const changed = state.currentGroup !== normalized; state.currentGroup = normalized; localStorage.setItem(CONFIG.currentGroupStorageKey, normalized); updateGroupDisplay(); if (source && changed) { log(`已识别当前分组:${normalized}(${source})`, 'success'); } return true; } function startGroupSelectionWatcher() { document.addEventListener('change', (event) => { const target = event.target; if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) return; if (!looksLikeGroupControl(target)) return; const group = readCurrentGroup(); if (group) saveCurrentGroup(group, '页面选择'); }, true); document.addEventListener('click', () => { setTimeout(() => { const group = readCurrentGroup(); if (group) saveCurrentGroup(group, '页面选择'); }, 120); }, true); } async function injectAuthSniffer() { while (!document.documentElement) { await new Promise((resolve) => setTimeout(resolve, 0)); } const script = document.createElement('script'); const nonce = document.querySelector('script[nonce]')?.nonce || document.querySelector('script[nonce]')?.getAttribute('nonce'); if (nonce) script.nonce = nonce; script.textContent = ` (() => { const groupParamNames = ${JSON.stringify(CONFIG.groupParamNames)}; const emit = (auth) => { if (!auth) return; document.dispatchEvent(new CustomEvent('__sub2api_checker_auth__', { detail: auth })); }; const emitGroup = (urlLike) => { try { if (!urlLike) return; const url = new URL(String(urlLike), location.origin); if (!url.pathname.includes('/api/v1/admin/accounts')) return; for (const name of groupParamNames) { const value = (url.searchParams.get(name) || '').trim(); if (value) { document.dispatchEvent(new CustomEvent('__sub2api_checker_group__', { detail: value })); return; } } } catch (_) {} }; const pickAuth = (headersLike) => { try { if (!headersLike) return ''; if (headersLike instanceof Headers) { return headersLike.get('Authorization') || headersLike.get('authorization') || ''; } if (Array.isArray(headersLike)) { for (const [k, v] of headersLike) { if (String(k).toLowerCase() === 'authorization') return v || ''; } return ''; } if (typeof headersLike === 'object') { for (const key of Object.keys(headersLike)) { if (key.toLowerCase() === 'authorization') return headersLike[key] || ''; } } } catch (_) {} return ''; }; const origFetch = window.fetch; if (origFetch) { window.fetch = function(input, init) { const auth = pickAuth(init && init.headers) || pickAuth(input && input.headers); if (auth) emit(auth); const requestUrl = typeof input === 'string' || input instanceof URL ? String(input) : input && input.url; emitGroup(requestUrl); return origFetch.apply(this, arguments); }; } const origOpen = XMLHttpRequest.prototype.open; const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function() { this.__sub2apiAuth = ''; emitGroup(arguments[1]); return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function(name, value) { if (String(name).toLowerCase() === 'authorization' && value) { this.__sub2apiAuth = value; emit(value); } return origSetHeader.apply(this, arguments); }; })(); `; document.documentElement.appendChild(script); script.remove(); document.addEventListener('__sub2api_checker_auth__', (event) => { saveAuth(event.detail); }); document.addEventListener('__sub2api_checker_group__', (event) => { saveCurrentGroup(event.detail, '账号列表请求'); }); } function updateStats() { const el = document.querySelector('#sub2api-checker-stats'); if (!el) return; const s = state.stats; const queued = Math.max(0, s.total - s.checked - s.active); el.textContent = `总数 ${s.total} | 运行中 ${s.active} | 队列 ${queued} | 已处理 ${s.checked} | 正常 ${s.ok} | 已启用 ${s.enabled} | 已关闭 ${s.disabled} | 已删除 ${s.deleted} | 跳过 ${s.skipped} | 异常 ${s.failed}`; } function decodeJwtPayload(token) { try { const parts = String(token || '').split('.'); if (parts.length !== 3) return {}; let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); const padding = payload.length % 4; if (padding) payload += '='.repeat(4 - padding); const jsonText = decodeURIComponent( atob(payload) .split('') .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`) .join('') ); return JSON.parse(jsonText); } catch (_) { return {}; } } function firstNonEmptyString(candidates) { for (const value of candidates) { if (typeof value === 'string' && value.trim()) return value.trim(); } return ''; } function parseExpiredTime(expiredString) { try { if (!expiredString) return 0; const date = String(expiredString).includes('+') ? new Date(expiredString) : new Date(String(expiredString).replace('Z', '+00:00')); const timestamp = Math.floor(date.getTime() / 1000); return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0; } catch (_) { return 0; } } function extractAccountIdFromPayload(payload) { if (!payload || typeof payload !== 'object') return ''; const authInfo = payload['https://api.openai.com/auth'] || {}; return firstNonEmptyString([ authInfo.chatgpt_account_id, authInfo.chatgptAccountId, authInfo.account_id, authInfo.accountId, payload.chatgpt_account_id, payload.chatgptAccountId, payload.account_id, payload.accountId, payload.openai_account_id, ]); } function extractAccountIdFromCpa(sourceData) { const credentials = sourceData?.credentials && typeof sourceData.credentials === 'object' ? sourceData.credentials : {}; const extra = sourceData?.extra && typeof sourceData.extra === 'object' ? sourceData.extra : {}; const metadata = sourceData?.metadata && typeof sourceData.metadata === 'object' ? sourceData.metadata : {}; const accessToken = firstNonEmptyString([ sourceData?.access_token, sourceData?.accessToken, credentials.access_token, credentials.accessToken, metadata.access_token, metadata.accessToken, ]); const idToken = firstNonEmptyString([ sourceData?.id_token, sourceData?.idToken, credentials.id_token, credentials.idToken, metadata.id_token, metadata.idToken, ]); return firstNonEmptyString([ sourceData?.chatgpt_account_id, sourceData?.chatgptAccountId, sourceData?.account_id, sourceData?.accountId, sourceData?.openai_account_id, credentials.chatgpt_account_id, credentials.chatgptAccountId, credentials.account_id, credentials.accountId, credentials.openai_account_id, extra.chatgpt_account_id, extra.chatgptAccountId, extra.account_id, extra.accountId, metadata.chatgpt_account_id, metadata.chatgptAccountId, metadata.account_id, metadata.accountId, extractAccountIdFromPayload(decodeJwtPayload(idToken)), extractAccountIdFromPayload(decodeJwtPayload(accessToken)), ]); } function normalizeCpaInput(data) { if (Array.isArray(data)) return data; if (data && Array.isArray(data.accounts)) return data.accounts; if (data && Array.isArray(data.files)) return data.files.map((item) => item?.json || item).filter(Boolean); if (data && typeof data === 'object') return [data]; return []; } function applySub2apiAccountGroup(account, group) { const normalized = normalizeGroup(group); if (!normalized) return account; account.group = normalized; account.groups = [normalized]; account.account_group = normalized; account.accountGroup = normalized; account.extra = { ...(account.extra || {}), group: normalized, groups: [normalized], account_group: normalized, accountGroup: normalized, }; return account; } function buildSub2apiAccountFromCpa(sourceData, index, group = '', batchTag = '') { const credentials = sourceData?.credentials && typeof sourceData.credentials === 'object' ? sourceData.credentials : {}; const accessToken = firstNonEmptyString([sourceData?.access_token, sourceData?.accessToken, credentials.access_token, credentials.accessToken]); const refreshToken = firstNonEmptyString([sourceData?.refresh_token, sourceData?.refreshToken, credentials.refresh_token, credentials.refreshToken]); const accessPayload = decodeJwtPayload(accessToken); const authInfo = accessPayload['https://api.openai.com/auth'] || {}; const idPayload = decodeJwtPayload(firstNonEmptyString([sourceData?.id_token, sourceData?.idToken, credentials.id_token, credentials.idToken])); const idAuthInfo = idPayload['https://api.openai.com/auth'] || {}; const organizations = Array.isArray(idAuthInfo.organizations) ? idAuthInfo.organizations : []; const accountId = extractAccountIdFromCpa(sourceData); const expiresAt = parseExpiredTime(sourceData?.expired || credentials.expired) || Number(credentials.expires_at || credentials.expiresAt || accessPayload.exp || 0); const accountType = firstNonEmptyString([sourceData?.type, credentials.type, 'unknown']); const email = firstNonEmptyString([sourceData?.email, sourceData?.account, sourceData?.label, sourceData?.extra?.email, credentials.email, idPayload.email, accessPayload.email]); const account = { name: `${accountType}-普号-${batchTag ? `${batchTag}-` : ''}${String(index).padStart(4, '0')}`, platform: 'openai', type: 'oauth', credentials: { access_token: accessToken, chatgpt_account_id: accountId, chatgpt_user_id: firstNonEmptyString([authInfo.chatgpt_user_id, credentials.chatgpt_user_id, credentials.chatgptUserId]), expires_at: expiresAt, expires_in: Number(credentials.expires_in || credentials.expiresIn || 864000), organization_id: firstNonEmptyString([credentials.organization_id, credentials.organizationId, organizations[0]?.id]), refresh_token: refreshToken, }, extra: { email, }, concurrency: Number(sourceData?.concurrency || credentials.concurrency || 10), priority: Number(sourceData?.priority || credentials.priority || 1), rate_multiplier: Number(sourceData?.rate_multiplier || sourceData?.rateMultiplier || credentials.rate_multiplier || credentials.rateMultiplier || 1), auto_pause_on_expired: true, }; return applySub2apiAccountGroup(account, group); } function convertCpaAuthItemsToSub2api(items, group = '', batchTag = '') { const accounts = []; const issues = []; const normalizedGroup = normalizeGroup(group); normalizeCpaInput(items).forEach((sourceData, index) => { if (!sourceData || typeof sourceData !== 'object' || Array.isArray(sourceData)) { issues.push(`第 ${index + 1} 项不是对象`); return; } const accountId = extractAccountIdFromCpa(sourceData); const credentials = sourceData.credentials && typeof sourceData.credentials === 'object' ? sourceData.credentials : {}; const accessToken = firstNonEmptyString([sourceData.access_token, sourceData.accessToken, credentials.access_token, credentials.accessToken]); if (!accessToken || !accountId) { issues.push(`第 ${index + 1} 项缺少 access_token 或 account_id`); return; } accounts.push(buildSub2apiAccountFromCpa(sourceData, accounts.length + 1, normalizedGroup, batchTag)); }); const output = { exported_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), proxies: [], accounts, }; if (normalizedGroup) { output.current_group = normalizedGroup; output.default_group = normalizedGroup; output.group = normalizedGroup; output.groups = [normalizedGroup]; } return { output, issues, }; } function downloadText(filename, text) { const blob = new Blob([text], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function readJsonFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { try { resolve(JSON.parse(String(reader.result || ''))); } catch (err) { reject(new Error(`${file.name} JSON 解析失败:${err?.message || String(err)}`)); } }; reader.onerror = () => reject(new Error(`${file.name} 读取失败`)); reader.readAsText(file, 'utf-8'); }); } function updateCpaConvertStatus(message, type = 'info') { const el = document.querySelector('#sub2api-checker-cpa-status'); if (!el) return; el.textContent = message; el.style.color = type === 'error' ? '#ff7875' : type === 'success' ? '#95de64' : type === 'warn' ? '#ffd666' : '#bfbfbf'; } function unwrapApiData(json) { return json && typeof json === 'object' && Object.prototype.hasOwnProperty.call(json, 'data') ? json.data : json; } async function readApiJson(resp) { const text = await resp.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (_) {} if (!resp.ok) throw new Error(`HTTP ${resp.status}${text ? `:${text.slice(0, 300)}` : ''}`); if (json && json.code !== undefined && json.code !== 0) throw new Error(json.message || `code=${json.code}`); return unwrapApiData(json); } function normalizeGroupList(data) { if (Array.isArray(data)) return data; if (Array.isArray(data?.items)) return data.items; if (Array.isArray(data?.groups)) return data.groups; return []; } async function resolveGroupId(groupName) { const target = normalizeGroup(groupName); if (!target) return ''; const endpoints = [ `${CONFIG.apiBase}/api/v1/admin/groups/all?platform=openai`, `${CONFIG.apiBase}/api/v1/admin/groups?page=1&page_size=100&search=${encodeURIComponent(target)}`, ]; for (const endpoint of endpoints) { try { const data = await readApiJson(await apiFetch(endpoint, { headers: { Accept: 'application/json, text/plain, */*' }, })); const matched = normalizeGroupList(data).find((group) => { const id = String(group?.id ?? group?.group_id ?? group?.groupId ?? '').trim(); const name = String(group?.name ?? group?.group_name ?? group?.groupName ?? group?.label ?? '').trim(); return id === target || name === target; }); if (matched) return String(matched.id ?? matched.group_id ?? matched.groupId ?? '').trim(); } catch (err) { log(`读取分组接口失败:${err?.message || String(err)}`, 'warn'); } } return ''; } function normalizeAccountList(data) { if (Array.isArray(data)) return data; if (Array.isArray(data?.items)) return data.items; if (Array.isArray(data?.accounts)) return data.accounts; return []; } async function findImportedAccountIdsByBatch(batchTag, expectedCount) { const ids = new Set(); let page = 1; const pageSize = Math.min(Math.max(Number(expectedCount) || CONFIG.pageSize, 20), 100); while (true) { const url = new URL('/api/v1/admin/accounts', CONFIG.apiBase); url.searchParams.set('page', String(page)); url.searchParams.set('page_size', String(pageSize)); url.searchParams.set('platform', 'openai'); url.searchParams.set('type', ''); url.searchParams.set('status', ''); url.searchParams.set('privacy_mode', ''); url.searchParams.set('group', ''); url.searchParams.set('search', batchTag); url.searchParams.set('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'); const data = await readApiJson(await apiFetch(url.toString(), { headers: { Accept: 'application/json, text/plain, */*' }, })); const accounts = normalizeAccountList(data); accounts .filter((item) => String(item?.name || '').includes(batchTag)) .forEach((item) => { const id = item?.id; if (id !== undefined && id !== null) ids.add(id); }); if (ids.size >= expectedCount || accounts.length < pageSize) break; page += 1; } return Array.from(ids); } async function bindAccountsToGroup(accountIds, groupId) { const normalizedIds = Array.from(new Set(accountIds)); await readApiJson(await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/bulk-update`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ account_ids: normalizedIds, group_ids: [Number(groupId) || groupId], confirm_mixed_channel_risk: true, }), })); return { success: normalizedIds.length, total: normalizedIds.length }; } async function bindImportedAccountsToCurrentGroup(payload, groupName, batchTag) { const target = normalizeGroup(groupName); if (!target) return { ok: false, reason: '未识别到当前分组' }; if (!batchTag) return { ok: false, reason: '缺少批次标记,无法定位本批账号' }; const groupId = await resolveGroupId(target); if (!groupId) return { ok: false, reason: `未找到分组 ID:${target}` }; const accountIds = await findImportedAccountIdsByBatch(batchTag, (payload.accounts || []).length); if (!accountIds.length) return { ok: false, reason: '导入后未定位到本批账号' }; updateCpaConvertStatus(`已通过批次标记 ${batchTag} 定位 ${accountIds.length} 个账号,正在批量修改分组...`); const result = await bindAccountsToGroup(accountIds, groupId); return { ok: true, count: result.success, groupId }; } async function handleCpaFilesToSub2api() { const input = document.querySelector('#sub2api-checker-cpa-files'); const files = Array.from(input?.files || []); if (!files.length) { updateCpaConvertStatus('请选择 CPA 认证 JSON 文件', 'error'); return; } if (!(await ensureAuth())) { updateCpaConvertStatus('缺少 Authorization,无法自动导入 sub2api', 'error'); return; } const detectedGroup = readCurrentGroup(); const targetGroup = normalizeGroup(detectedGroup || state.currentGroup || localStorage.getItem(CONFIG.currentGroupStorageKey)); if (targetGroup) saveCurrentGroup(targetGroup, detectedGroup ? '当前页面' : '本地缓存'); updateCpaConvertStatus(`正在读取 ${files.length} 个文件${targetGroup ? `,将导入到分组:${targetGroup}` : ''}...`); try { const batchTag = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').toLowerCase(); const parsedFiles = await Promise.all(files.map(readJsonFile)); const items = parsedFiles.flatMap((data) => normalizeCpaInput(data)); const result = convertCpaAuthItemsToSub2api(items, targetGroup, batchTag); if (!result.output.accounts.length) { throw new Error(`没有生成有效账号${result.issues.length ? `:${result.issues.join(';')}` : ''}`); } const text = JSON.stringify(result.output, null, 2); downloadText(`sub2api-cpa-import-${batchTag}.json`, text); updateCpaConvertStatus(`已生成备份 JSON,正在自动导入 sub2api${targetGroup ? `(分组:${targetGroup})` : ''}...`); const imported = await importSub2apiData(result.output); if (!imported.ok) { throw new Error(`自动导入失败:${imported.reason}`); } let bindText = ''; if (targetGroup) { updateCpaConvertStatus(`导入完成,正在绑定分组:${targetGroup}...`); const bound = await bindImportedAccountsToCurrentGroup(result.output, targetGroup, batchTag); bindText = bound.ok ? `,已绑定 ${bound.count} 个账号到分组:${targetGroup}` : `,分组绑定失败:${bound.reason}`; } const issueText = result.issues.length ? `,跳过 ${result.issues.length} 项:${result.issues.slice(0, 3).join(';')}` : ''; const groupText = targetGroup ? bindText : ',已导入但未识别到当前分组'; updateCpaConvertStatus(`已生成备份 JSON,并自动导入 ${result.output.accounts.length} 个账号${groupText}${issueText}`, result.issues.length ? 'warn' : 'success'); log(`CPA -> Sub2API 转换并导入完成:${result.output.accounts.length} 个账号${groupText}${issueText}`, result.issues.length ? 'warn' : 'success'); } catch (err) { updateCpaConvertStatus(err?.message || String(err), 'error'); log(`CPA -> Sub2API 转换失败:${err?.message || String(err)}`, 'error'); } } function updatePanelCollapsed() { const shell = document.querySelector('#sub2api-checker-shell'); const root = document.querySelector('#sub2api-checker-panel'); const toggle = document.querySelector('#sub2api-checker-toggle'); if (!root || !toggle || !shell) return; root.style.width = state.collapsed ? '0px' : '460px'; root.style.opacity = state.collapsed ? '0' : '1'; root.style.marginRight = state.collapsed ? '0px' : '12px'; root.style.pointerEvents = state.collapsed ? 'none' : 'auto'; root.style.transform = state.collapsed ? 'translateX(12px)' : 'translateX(0)'; toggle.textContent = state.collapsed ? '账号巡检' : '收起'; toggle.style.borderRadius = state.collapsed ? '10px 0 0 10px' : '10px'; shell.style.pointerEvents = 'auto'; } function ensurePanel() { if (state.panelReady) return; state.panelReady = true; const shell = document.createElement('div'); shell.id = 'sub2api-checker-shell'; shell.style.cssText = ` position: fixed; right: 0; top: 120px; z-index: 1000000; display: flex; flex-direction: row; align-items: flex-start; pointer-events: auto; `; document.body.appendChild(shell); const toggle = document.createElement('button'); toggle.id = 'sub2api-checker-toggle'; toggle.style.cssText = ` padding: 10px 8px; border: 0; border-radius: 10px 0 0 10px; background: #1677ff; color: #fff; cursor: pointer; writing-mode: vertical-rl; text-orientation: mixed; box-shadow: 0 8px 24px rgba(0,0,0,.25); transition: transform .28s ease, box-shadow .28s ease, border-radius .28s ease; font: 12px/1.2 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,PingFang SC,Microsoft YaHei,sans-serif; `; toggle.addEventListener('mouseenter', () => { toggle.style.transform = 'translateX(-2px)'; toggle.style.boxShadow = '0 10px 28px rgba(0,0,0,.32)'; }); toggle.addEventListener('mouseleave', () => { toggle.style.transform = 'translateX(0)'; toggle.style.boxShadow = '0 8px 24px rgba(0,0,0,.25)'; }); toggle.addEventListener('click', () => { state.collapsed = !state.collapsed; updatePanelCollapsed(); }); shell.appendChild(toggle); const root = document.createElement('div'); root.id = 'sub2api-checker-panel'; root.style.cssText = ` width: 0; opacity: 0; overflow: hidden; transition: width .28s ease, opacity .22s ease, margin-right .28s ease, transform .28s ease; transform: translateX(12px); `; root.innerHTML = ` <div id="sub2api-checker-panel-inner" style=" width:460px; max-height:calc(100vh - 140px); background:rgba(16, 18, 27, 0.96); color:#fff; border:1px solid #30363d; border-radius:12px; box-shadow:0 8px 24px rgba(0,0,0,.35); font:12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,PingFang SC,Microsoft YaHei,sans-serif; overflow-y:auto; overflow-x:hidden; scrollbar-width:thin; "> <div style="padding:12px 14px;border-bottom:1px solid #30363d;font-weight:700;">Sub2API 账号模型巡检</div> <div style="padding:12px 14px;display:flex;flex-direction:column;gap:8px;"> <label style="display:flex;flex-direction:column;gap:4px;"> <span>Authorization(优先自动捕获,抓不到再手填)</span> <input id="sub2api-checker-auth" type="text" placeholder="Bearer xxxxxx" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;flex-direction:column;gap:4px;"> <span>单模型超时时间(秒)</span> <input id="sub2api-checker-timeout" type="number" min="1" step="1" placeholder="45" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;flex-direction:column;gap:4px;"> <span>账号并发数(1-${CONFIG.maxConcurrency},建议 5-15)</span> <input id="sub2api-checker-concurrency" type="number" min="1" max="${CONFIG.maxConcurrency}" step="1" placeholder="8" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;flex-direction:column;gap:4px;"> <span>测试模型</span> <input id="sub2api-checker-test-model" type="text" placeholder="gpt-5.4" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #434a57;background:#111723;color:#fff;" /> </label> <label style="display:flex;gap:8px;align-items:center;color:#d9d9d9;"> <input id="sub2api-checker-auto-disable" type="checkbox" style="margin:0;" /> <span>模型异常时自动关闭账号调度</span> </label> <label style="display:flex;gap:8px;align-items:center;color:#d9d9d9;"> <input id="sub2api-checker-auto-delete" type="checkbox" style="margin:0;" /> <span>模型异常时自动删除账号</span> </label> <div style="display:flex;gap:8px;align-items:center;color:#bfbfbf;"> <span>当前页面分组:</span> <strong id="sub2api-checker-current-group" style="flex:1;color:#ffd666;">未识别</strong> <button id="sub2api-checker-refresh-group" style="padding:6px 8px;border:0;border-radius:8px;background:#434a57;color:#fff;cursor:pointer;">重新读取</button> </div> <div style="display:flex;gap:8px;align-items:center;"> <button id="sub2api-checker-start" style="flex:1;padding:8px 10px;border:0;border-radius:8px;background:#1677ff;color:#fff;cursor:pointer;">开始巡检</button> <button id="sub2api-checker-stop" style="flex:1;padding:8px 10px;border:0;border-radius:8px;background:#fa541c;color:#fff;cursor:pointer;">停止</button> </div> <div style="border:1px solid #13c2c2;border-radius:8px;background:#071f24;padding:10px;display:flex;flex-direction:column;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;color:#e6fffb;font-weight:700;"> <span style="display:inline-block;width:6px;height:18px;border-radius:4px;background:#13c2c2;"></span> <span>CPA -> Sub2API JSON</span> </div> <input id="sub2api-checker-cpa-files" type="file" multiple accept=".json,application/json" style="width:100%;box-sizing:border-box;padding:8px;border-radius:8px;border:1px solid #2f6f76;background:#111723;color:#fff;" /> <button id="sub2api-checker-cpa-convert" type="button" style="padding:8px 10px;border:0;border-radius:8px;background:#13c2c2;color:#001314;font-weight:700;cursor:pointer;">生成备份 JSON 并自动导入当前分组</button> <div id="sub2api-checker-cpa-status" style="color:#bfbfbf;min-height:18px;">选择一个或多个 CPA 认证 JSON 文件后自动导入。</div> </div> <div id="sub2api-checker-stats" style="color:#bfbfbf;">总数 0 | 运行中 0 | 队列 0 | 已处理 0 | 正常 0 | 已启用 0 | 已关闭 0 | 已删除 0 | 跳过 0 | 异常 0</div> <div id="sub2api-checker-log" style="height:min(320px, 36vh);overflow:auto;background:#0b0f17;border:1px solid #30363d;border-radius:8px;padding:8px;"></div> </div> </div> `; shell.appendChild(root); const authInput = root.querySelector('#sub2api-checker-auth'); authInput.value = state.authHeader; authInput.addEventListener('change', () => { const v = authInput.value.trim(); if (v) saveAuth(v); }); const timeoutInput = root.querySelector('#sub2api-checker-timeout'); timeoutInput.value = String(Math.floor(state.timeoutMs / 1000)); timeoutInput.addEventListener('change', () => { const sec = Number(timeoutInput.value || 0); if (!saveTimeoutMs(sec * 1000)) { timeoutInput.value = String(Math.floor(state.timeoutMs / 1000)); log('超时时间无效,需大于等于 1 秒', 'error'); return; } log(`已设置单模型超时 ${sec} 秒`, 'success'); }); const concurrencyInput = root.querySelector('#sub2api-checker-concurrency'); concurrencyInput.value = String(state.concurrency); concurrencyInput.addEventListener('change', () => { saveConcurrency(concurrencyInput.value || CONFIG.defaultConcurrency); log(`已设置账号并发数 ${state.concurrency}`, 'success'); }); const testModelInput = root.querySelector('#sub2api-checker-test-model'); testModelInput.value = state.testModel; testModelInput.addEventListener('change', () => { const model = testModelInput.value.trim(); if (!saveTestModel(model)) { testModelInput.value = state.testModel; log('测试模型不能为空', 'error'); return; } log(`已设置测试模型 ${state.testModel}`, 'success'); }); const autoDisableInput = root.querySelector('#sub2api-checker-auto-disable'); autoDisableInput.checked = state.autoDisable; autoDisableInput.addEventListener('change', () => { saveAutoDisable(autoDisableInput.checked); log(`模型异常时${state.autoDisable ? '会' : '不会'}自动关闭账号调度`, 'success'); }); const autoDeleteInput = root.querySelector('#sub2api-checker-auto-delete'); autoDeleteInput.checked = state.autoDelete; autoDeleteInput.addEventListener('change', () => { saveAutoDelete(autoDeleteInput.checked); log(`模型异常时${state.autoDelete ? '会' : '不会'}自动删除账号`, 'success'); }); root.querySelector('#sub2api-checker-refresh-group').addEventListener('click', () => { const group = readCurrentGroup(); if (group) { saveCurrentGroup(group, '当前页面'); } else { updateGroupDisplay(); log('未能从当前页面识别分组,开始时会弹窗确认', 'warn'); } }); root.querySelector('#sub2api-checker-start').addEventListener('click', () => run().catch((err) => { log(`运行异常:${err.message}`, 'error'); state.running = false; })); root.querySelector('#sub2api-checker-stop').addEventListener('click', () => { state.stopRequested = true; log('已请求停止,当前请求结束后退出', 'warn'); }); root.querySelector('#sub2api-checker-cpa-convert').addEventListener('click', () => { handleCpaFilesToSub2api(); }); root.querySelector('#sub2api-checker-cpa-files').addEventListener('change', (event) => { const count = event.target.files?.length || 0; updateCpaConvertStatus(count ? `已选择 ${count} 个 CPA 认证 JSON 文件` : '选择一个或多个 CPA 认证 JSON 文件后生成。'); }); updateGroupDisplay(); updatePanelCollapsed(); } async function waitDomReady() { if (document.body) return; await new Promise((resolve) => { const timer = setInterval(() => { if (document.body) { clearInterval(timer); resolve(); } }, 50); }); } async function apiFetch(url, options = {}) { const headers = new Headers(options.headers || {}); if (state.authHeader && !headers.has('Authorization')) { headers.set('Authorization', state.authHeader); } const resp = await fetch(url, { ...options, headers, credentials: 'include', }); return resp; } async function fetchAccounts() { let page = 1; const items = []; while (true) { const url = new URL('/api/v1/admin/accounts', CONFIG.apiBase); url.searchParams.set('page', String(page)); url.searchParams.set('page_size', String(CONFIG.pageSize)); url.searchParams.set('platform', ''); url.searchParams.set('type', ''); url.searchParams.set('status', ''); url.searchParams.set('privacy_mode', ''); url.searchParams.set('group', state.currentGroup); url.searchParams.set('search', ''); url.searchParams.set('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'); const resp = await apiFetch(url.toString(), { headers: { Accept: 'application/json, text/plain, */*' }, }); if (!resp.ok) throw new Error(`账号列表请求失败:HTTP ${resp.status}`); const json = await resp.json(); if (json.code !== 0) throw new Error(`账号列表返回异常:${json.message || json.code}`); const pageItems = json?.data?.items || []; items.push(...pageItems); const pages = Number(json?.data?.pages || 1); if (page >= pages || pageItems.length === 0) break; page += 1; } return items; } function getModels(account) { const targetModel = String(state.testModel || '').trim(); if (targetModel) return [targetModel]; const mapping = account?.credentials?.model_mapping || {}; const keys = Object.keys(mapping).filter(Boolean); if (keys.length <= 1) return keys; const preferred = []; for (const model of CONFIG.preferredModels) { if (keys.includes(model)) preferred.push(model); } const rest = keys.filter((k) => !preferred.includes(k)).sort(); return [...preferred, ...rest]; } async function testModel(accountId, modelId) { const controller = new AbortController(); let timer = null; const resetTimer = () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(new Error(`模型 ${modelId} 流式超时`)), state.timeoutMs); }; try { resetTimer(); const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/${accountId}/test`, { method: 'POST', headers: { Accept: '*/*', 'Content-Type': 'application/json', }, body: JSON.stringify({ model_id: modelId, prompt: CONFIG.prompt }), signal: controller.signal, }); if (!resp.ok) { clearTimeout(timer); return { ok: false, reason: `HTTP ${resp.status}` }; } const reader = resp.body?.getReader(); if (!reader) { clearTimeout(timer); const text = await resp.text(); return { ok: false, reason: `无响应流:${text.slice(0, 200)}` }; } const decoder = new TextDecoder(); let buffer = ''; while (true) { resetTimer(); const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }).replace(/\r/g, ''); let splitIndex; while ((splitIndex = buffer.indexOf('\n\n')) >= 0) { const chunk = buffer.slice(0, splitIndex); buffer = buffer.slice(splitIndex + 2); const dataLines = chunk .split('\n') .map((line) => line.trim()) .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trim()); for (const line of dataLines) { if (!line) continue; let event; try { event = JSON.parse(line); } catch (_) { continue; } if (event.type === 'error') { clearTimeout(timer); return { ok: false, reason: event.error || '未知错误' }; } if (event.type === 'test_complete') { clearTimeout(timer); return { ok: !!event.success, reason: event.success ? 'success' : 'test_complete=false' }; } } } } clearTimeout(timer); return { ok: false, reason: '响应流结束但没有 test_complete' }; } catch (err) { clearTimeout(timer); return { ok: false, reason: err?.name === 'AbortError' ? '请求超时' : (err?.message || String(err)), }; } } async function setAccountSchedulable(accountId, schedulable) { const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/${accountId}/schedulable`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ schedulable: !!schedulable }), }); if (!resp.ok) { return { ok: false, reason: `HTTP ${resp.status}` }; } const json = await resp.json(); if (json.code !== 0) { return { ok: false, reason: json.message || `code=${json.code}` }; } return { ok: true, data: json.data }; } async function deleteAccount(accountId) { const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/${accountId}`, { method: 'DELETE', headers: { Accept: 'application/json, text/plain, */*', }, }); if (!resp.ok) { return { ok: false, reason: `HTTP ${resp.status}` }; } const json = await resp.json(); if (json.code !== 0) { return { ok: false, reason: json.message || `code=${json.code}` }; } return { ok: true, data: json.data }; } async function importSub2apiData(payload) { const resp = await apiFetch(`${CONFIG.apiBase}/api/v1/admin/accounts/data`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', }, body: JSON.stringify({ data: payload, skip_default_group_bind: false, }), }); const text = await resp.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (_) {} if (!resp.ok) { return { ok: false, reason: `HTTP ${resp.status}${text ? `:${text.slice(0, 300)}` : ''}` }; } if (json && json.code !== 0) { return { ok: false, reason: json.message || `code=${json.code}` }; } return { ok: true, data: json?.data ?? json }; } async function handleModelFailure(account, title, reason) { if (state.autoDelete) { log(`${title} ${reason},准备删除账号`, 'error'); const deleted = await deleteAccount(account.id); if (deleted.ok) { state.stats.deleted += 1; log(`${title} 已删除账号(原因:${reason})`, 'success'); } else { log(`${title} 删除账号失败:${deleted.reason}`, 'error'); } return; } if (state.autoDisable) { log(`${title} ${reason},准备关闭 schedulable`, 'error'); const off = await setAccountSchedulable(account.id, false); if (off.ok) { state.stats.disabled += 1; log(`${title} 已关闭 schedulable(原因:${reason})`, 'success'); } else { log(`${title} 关闭失败:${off.reason}`, 'error'); } return; } log(`${title} 检测到异常但未处理账号(原因:${reason})`, 'warn'); } function resetStats() { state.stats = { total: 0, checked: 0, active: 0, started: 0, ok: 0, enabled: 0, disabled: 0, deleted: 0, skipped: 0, failed: 0, }; updateStats(); const logBox = document.querySelector('#sub2api-checker-log'); if (logBox) logBox.innerHTML = ''; } async function ensureAuth() { const cached = getCachedAuthToken(); if (cached) { saveAuth(cached); return true; } if (state.authHeader) return true; const fromInput = document.querySelector('#sub2api-checker-auth')?.value?.trim(); if (fromInput) { saveAuth(fromInput); return true; } const manual = prompt('没有自动捕获到 Authorization,请粘贴 Bearer token'); if (!manual) return false; saveAuth(manual.trim()); return true; } async function ensureGroup() { const detected = readCurrentGroup(); if (detected) { return saveCurrentGroup(detected, '当前页面'); } if (state.currentGroup) return true; const lastGroup = normalizeGroup(localStorage.getItem(CONFIG.currentGroupStorageKey)); const manual = prompt( `没有识别到当前页面选择的分组,请填写本次测活分组。${lastGroup ? `\n上次识别到:${lastGroup}` : ''}`, lastGroup || '' ); if (!manual) return false; return saveCurrentGroup(manual.trim(), '手动确认'); } async function processAccount(account) { const title = `#${account.id} ${account.name || '(未命名)'}`; try { if (CONFIG.onlyCheckSchedulable && !account.schedulable) { state.stats.skipped += 1; log(`${title} 已去掉调度,跳过测试`, 'warn'); return; } const models = getModels(account); if (!models.length) { state.stats.failed += 1; await handleModelFailure(account, title, '没有 model_mapping'); return; } log(`${title} 开始测试 ${models.length} 个模型`); let accountOk = true; let failReason = ''; let testedCount = 0; for (const model of models) { if (state.stopRequested) break; log(`${title} 测试模型 ${model}`); const result = await testModel(account.id, model); testedCount += 1; if (!result.ok) { accountOk = false; failReason = `模型 ${model} 异常:${result.reason}`; log(`${title} ${failReason}`, 'error'); if (CONFIG.stopOnFirstModelFailure) break; } else { log(`${title} 模型 ${model} 正常`, 'success'); } } if (state.stopRequested && testedCount < models.length && accountOk) { state.stats.skipped += 1; log(`${title} 因停止请求未完成全部模型测试,未改动 schedulable`, 'warn'); return; } if (accountOk) { state.stats.ok += 1; if (!account.schedulable) { const on = await setAccountSchedulable(account.id, true); if (on.ok) { state.stats.enabled += 1; log(`${title} 全部模型正常,已重新启用 schedulable`, 'success'); } else { log(`${title} 模型正常但重新启用失败:${on.reason}`, 'error'); } } else { log(`${title} 全部模型正常`, 'success'); } } else { state.stats.failed += 1; await handleModelFailure(account, title, failReason); } } finally { state.stats.checked += 1; updateStats(); } } async function runWorkerPool(accounts) { const concurrency = clampConcurrency(state.concurrency); log(`账号级并发 ${concurrency},单账号内模型按顺序测试`); async function worker(workerIndex) { while (!state.stopRequested) { const index = state.stats.started; if (index >= accounts.length) break; state.stats.started += 1; state.stats.active += 1; updateStats(); try { await processAccount(accounts[index]); } catch (err) { state.stats.failed += 1; state.stats.checked += 1; log(`工作线程 ${workerIndex} 处理账号异常:${err?.message || String(err)}`, 'error'); updateStats(); } finally { state.stats.active -= 1; updateStats(); } } } const workerCount = Math.min(concurrency, accounts.length); await Promise.all(Array.from({ length: workerCount }, (_, index) => worker(index + 1))); } async function run() { if (state.running) { log('已有任务在运行', 'warn'); return; } if (!(await ensureAuth())) { log('缺少 Authorization,已取消', 'error'); return; } if (!(await ensureGroup())) { log('缺少测活分组,已取消', 'error'); return; } state.running = true; state.stopRequested = false; resetStats(); try { state.collapsed = false; updatePanelCollapsed(); log(`开始拉取账号列表(分组:${state.currentGroup})`); const accounts = await fetchAccounts(); state.stats.total = accounts.length; updateStats(); log(`共获取 ${accounts.length} 个账号`, 'success'); await runWorkerPool(accounts); if (state.stopRequested) { log('任务已按要求停止', 'warn'); } else { log('巡检完成', 'success'); } } finally { state.running = false; state.stats.active = 0; updateStats(); } } injectAuthSniffer(); waitDomReady().then(() => { ensurePanel(); startGroupSelectionWatcher(); const group = readCurrentGroup(); if (group) saveCurrentGroup(group, '当前页面'); if (state.authHeader) { log('脚本已就绪,已从本地缓存 auth_token 读取 Authorization', 'success'); } else { log('脚本已就绪,未发现 auth_token;可刷新页面自动捕获或手动粘贴'); } }); })(); 第6行记得更改成自己的sub2api地址,刷新页面就可以看到右侧的按钮啦 1 个帖子 - 1 位参与者 阅读完整话题
本帖使用社区开源推广,符合推广要求。我申明并遵循社区要求的以下内容: 我的帖子已经打上 开源推广 标签: 是 我的开源项目完整开源,无未开源部分: 是 我的开源项目已链接认可 LINUX DO 社区: 是 我帖子内的项目介绍,AI生成、润色内容部分已截图发出: 是 以上选择我承诺是永久有效的,接受社区和佬友监督: 是 以下为项目介绍正文内容,AI生成、润色内容已使用截图方式发出 项目开源地址: GitHub - zouchenzhen/chatgpt-default-thinking-extended-userscript github.com GitHub - zouchenzhen/chatgpt-default-thinking-extended-userscript: Userscript that selects Thinking -> Extended on... Userscript that selects Thinking -> Extended on new ChatGPT chats via visible UI automation. 脚本安装地址: Greasy Fork: https://greasyfork.org/en/scripts/581739-chatgpt-默认-thinking-extended GitHub Raw: https://raw.githubusercontent.com/zouchenzhen/chatgpt-default-thinking-extended-userscript/main/chatgpt-default-thinking-extended.user.js 各位佬友好,开源自荐一个很小但对我自己很刚需的油猴脚本: ChatGPT 默认 Thinking Extended 。 起因是我自己在用 K12 ChatGPT 账号的时候,发现网页端每次新建对话都会默认回到 Instant ,不会保存上一次对话里选过的模型模式。也就是说我每开一个新对话,都要手动点模型菜单,再切到当前账号可用的最高级模式 Thinking -> Extended 。 次数多了就挺烦,于是顺手写了这个油猴脚本,把这个重复点击流程自动化一下。 实现方式比较保守,模拟网页端可见 UI 的点击。 欢迎各位佬友使用反馈。 1 个帖子 - 1 位参与者 阅读完整话题
用法: 打开 Tampermonkey 新建脚本。 粘贴这个文件内容保存。 访问: https://chatgpt.com/codex/team/checkout?checkout_from=codex_app 页面右下角会出现按钮: 执行 checkout/update quantity=13 等页面出现有效 checkout_session_id 后点按钮。 注意:如果右下角显示: 当前 checkout_session_id: (未检测到,等页面跳转后再点) 说明当前 URL 还是 /checkout?..,还没拿到真正的 checkout session id,不能点。 // ==UserScript== // @name CTF Codex Checkout Update Helper // @namespace ctf-sandbox // @version 0.1.0 // @description Run the Codex checkout/update request from the logged-in browser page context. // @match https://chatgpt.com/codex/team/checkout * // @match https://chatgpt.com/codex/team/checkout/ * // @run-at document-idle // @grant none // ==/UserScript== (() => { ‘use strict’; const CONFIG = { processor_entity: ‘openai_llc’, credit_purchase_quantity: 13, language: ‘zh-CN’, updateUrl: ‘ https://chatgpt.com/backend-api/payments/checkout/update ’, }; function log(…args) { console.log(‘[CTF checkout helper]’, …args); } function getCheckoutSessionId() { const url = new URL(window.location.href); const fromQuery = url.searchParams.get(‘checkout_session_id’) || url.searchParams.get(‘checkoutSessionId’); const parts = url.pathname.split(‘/’).filter(Boolean); const fromPath = parts[parts.length - 1] || ‘’; const id = fromQuery || fromPath; // The entry URL ends in /checkout. That is not a real checkout_session_id. if (!id || id === 'checkout' || id === 'team' || id === 'codex') return ''; return id; } async function getAccessToken() { const sessionRes = await fetch(‘/api/auth/session’, { credentials: ‘include’ }); if (!sessionRes.ok) { const text = await sessionRes.text().catch(() => ‘’); throw new Error( 获取 session 失败 (HTTP ${sessionRes.status}): ${text.slice(0, 120)} ); } const sessionData = await sessionRes.json(); const accessToken = sessionData?.accessToken; if (!accessToken) throw new Error(‘未找到 accessToken,请确认已登录’); return accessToken; } async function runUpdate() { const checkoutSessionId = getCheckoutSessionId(); if (!checkoutSessionId) { throw new Error( 当前 URL 还没有有效 checkout_session_id: ${window.location.href} ); } const accessToken = await getAccessToken(); const body = { checkout_session_id: checkoutSessionId, processor_entity: CONFIG.processor_entity, credit_purchase_quantity: CONFIG.credit_purchase_quantity, }; log('request body:', body); const res = await fetch(CONFIG.updateUrl, { method: 'POST', credentials: 'include', referrer: window.location.href, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, 'oai-device-id': localStorage.getItem('oai-device-id') || '', 'oai-language': CONFIG.language, }, body: JSON.stringify(body), }); const text = await res.clone().text().catch(() => ''); let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) {} if (!res.ok) { throw new Error(`请求失败 (HTTP ${res.status}): ${text.slice(0, 200)}`); } return data ?? text; } function installButton() { if (document.getElementById(‘ctf-checkout-helper-btn’)) return; const box = document.createElement('div'); box.id = 'ctf-checkout-helper-box'; box.style.cssText = [ 'position:fixed', 'z-index:2147483647', 'right:16px', 'bottom:16px', 'padding:12px', 'background:#111827', 'color:#fff', 'border:1px solid #374151', 'border-radius:10px', 'font:13px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif', 'box-shadow:0 8px 24px rgba(0,0,0,.3)', 'max-width:360px', ].join(';'); const btn = document.createElement('button'); btn.id = 'ctf-checkout-helper-btn'; btn.textContent = `执行 checkout/update quantity=${CONFIG.credit_purchase_quantity}`; btn.style.cssText = 'cursor:pointer;padding:8px 10px;border-radius:8px;border:0;background:#10a37f;color:white;font-weight:600'; const status = document.createElement('div'); status.id = 'ctf-checkout-helper-status'; status.style.cssText = 'margin-top:8px;white-space:pre-wrap;word-break:break-word;color:#d1d5db'; status.textContent = `当前 checkout_session_id: ${getCheckoutSessionId() || '(未检测到,等页面跳转后再点)'}`; btn.addEventListener('click', async () => { btn.disabled = true; status.textContent = '执行中...'; try { const data = await runUpdate(); log('成功:', data); status.textContent = `成功:\n${JSON.stringify(data, null, 2).slice(0, 1000)}`; setTimeout(() => window.location.reload(), 800); } catch (err) { console.error('[CTF checkout helper] 失败:', err); status.textContent = `失败: ${err?.message || err}`; } finally { btn.disabled = false; } }); box.appendChild(btn); box.appendChild(status); document.documentElement.appendChild(box); setInterval(() => { status.textContent = status.textContent.startsWith('当前 checkout_session_id:') ? `当前 checkout_session_id: ${getCheckoutSessionId() || '(未检测到,等页面跳转后再点)'}` : status.textContent; }, 1000); } installButton(); log(‘loaded on’, window.location.href); })(); 1 个帖子 - 1 位参与者 阅读完整话题
vibe了一个油猴脚本统计 Connect站 上的邀请数量,供感兴趣的佬友观测。 欢迎新来的佬友们 油猴脚本在这里 (点击了解更多详细信息) 3 个帖子 - 3 位参与者 阅读完整话题
之前看到web端收藏功能的油猴插件还挺好用的,手机端有吗佬们? 1 个帖子 - 1 位参与者 阅读完整话题
佬友分享 AI Key 时,会进行 Base64 编码, 用Key,都得复制→各种操作解码→再复制,切来切去。周末动手搓了个油猴脚本。 完全寄生在论坛自带的交互上: 随便选一段 Base64 密文 论坛会自动弹出那个带“引用”“分享”的小浮动菜单 菜单里多了一个 “ Base64解码” 按钮,点它 解码结果会直接以代码块的形式插入到选区下方,还带 复制 和 关闭 按钮 5Luj56CB5Y2D5LiH6KGM77yM5pG46bG856ys5LiA5p2h77yb5YiH5bGP5LiN6KeE6IyD77yM6ICB5p2/5Lik6KGM55uu44CC 脚本代码 (点击了解更多详细信息) 2 个帖子 - 2 位参与者 阅读完整话题
写个了网页游戏的自动化脚本,但是经常因为有其他 最大化 的窗口挡住了谷歌浏览器导致脚本无法自动运行了,有没有什么法子能够让油猴脚本后台自动运行啊(不需要最小化也能运行只需要被遮挡也能运行)? 2 个帖子 - 2 位参与者 阅读完整话题
本帖使用社区开源推广,符合推广要求。我申明并遵循社区要求的以下内容: 我的帖子已经打上 开源推广 标签: 是 我的开源项目完整开源,无未开源部分: 是 我的开源项目已链接认可 LINUX DO 社区: 是 我帖子内的项目介绍,AI生成、润色内容部分已截图发出: 是 以上选择我承诺是永久有效的,接受社区和佬友监督: 是 以下为项目介绍正文内容,AI生成、润色内容已使用截图方式发出 不知道佬友们有没有这个苦恼:在浏览完一个帖子时还需要返回上一级才能看见帖子列表,虽然并不麻烦,但随着你浏览量的增加,返回操作的数量也在快速增长,这些大量本可避免的返回操作占用了不小的时间… 于是用cc写了个简单的话题侧栏油猴插件,这个脚本会 在L站帖子详情页的左侧显示当前所在频道的话题列表 ,方便在阅读帖子时快速切换同频道的其他主帖。 将原本的侧栏折叠至图标,当鼠标浮动在上方时展开,为话题侧栏腾出位置。 当鼠标浮动在话题侧栏上的某个帖子上时,会延长该话题的卡片完整显示帖子标题。 显示话题所属板块、标签和浏览量,适配置顶话题。 模仿原生" 查看新的或更新的话题 "按钮,点击按钮即可显示新的话题。 适配了深色模式。 目前只支持默认主题,没有适配Horizon主题 ,Horizon能用但是界面有点丑丑的,用Horizon的佬们可以自己优化一下(x 插件下载: https://greasyfork.org/zh-CN/scripts/581345-linux-do-%E9%A2%91%E9%81%93%E4%B8%BB%E5%B8%96%E4%BE%A7%E6%A0%8F 1 个帖子 - 1 位参与者 阅读完整话题
最近发帖老被举报,有些有确定原因(不清楚社区规则、漏看),有些莫名其妙,属于可举报可不举报,按说我这种老实人应该挺遵守社区规则的,但是冷不丁被举报总会印象心情。即便是身为三级佬,不小心的话还是会中招的,于是有了这个脚本。 所有LLM用于判断是否违规的规则都来自于社区公开的数个规则公告帖子。 以下为使用方法,图中内容为测试所需,与本人意愿无关,若有想法,纯属胡思乱想。 1.配置(还是用了LLM) 小齿轮点一点 搞点平时不太用的api,屯屯鼠可以拿些想不起来的公益站用了 2.发帖窗口检测(2s自动检测或按按钮) 3.回复窗口的检测(2s自动检测或按按钮) 4.红包帖子警告窗口(2s自动关闭,刷新不再出现) PS:本贴已过检,人工和LLM双重。 好用给一个 特性(bug): 闲置Token消耗 - 二次编辑的帖子不会触发(你应该自己上道了【其实是懒得用ai修】) 可是我比你更上道,更新了二次编辑的帖子不会触发的特性 经过本工具能够让大家都成为良民: 本帖修改前: 本贴修改后 添加回帖检测 最新脚本: linux-do-post-risk-checker.user.txt (58.6 KB) 10 个帖子 - 9 位参与者 阅读完整话题
最近清理C盘把油猴脚本全删了,忘记之前有什么脚本了,有没有佬推荐一些 2 个帖子 - 2 位参与者 阅读完整话题
greasyfork.org Linux.do Boost 增强 选中 boost 后增加 @ 按钮,一键打开回复框并 @ 该 boost 用户 14 个帖子 - 8 位参与者 阅读完整话题
如图,油猴眼睛有一个错号,这是什么情况 8 个帖子 - 7 位参与者 阅读完整话题
脚本代码: 油猴脚本.txt (25.8 KB) 效果图: 脚本添加了自动刷新时间的选择和手动刷新 5 个帖子 - 4 位参与者 阅读完整话题
L站帖子搜索的正确姿势是什么?为什么我觉得很难用 运营反馈 其实一直有这个困惑,当我需要搜某些教程的时候,直接搜出来的结果往往都是牛头不对马嘴的内容.我现在几乎都不使用自带的搜索功能了,而是直接在goole搜索内容加上"inurl:linux.do" 以下是结果对比: [image] [image] 而谷歌搜索搭配限定词搜索出来的结果可以说是又准又多: [image] 是我没有掌握L站自带搜索引擎的正确姿势吗 一直觉得 l 站搜索难用, 写了插件, 直接在搜索词后加上 site:linux.do/t, 得到结果之后按照 topicID 显示. 但是能不能搜到高级贴我就不知道了 插件: Linux.do_with_google_search.txt (25.0 KB) 7 个帖子 - 6 位参与者 阅读完整话题
放出来了,自由的感觉真好~~~谢谢管理大人 昨天晚上被禁言的。是前天晚上不小心参与了抽奖。也不是不小心,还是自己意识没有到位,被封了18个小时。在此也向L站各位管理说声抱歉,给你们造成麻烦了,教训就是劝大家对抽奖的事情不要掉以轻心啊,千万小心 写了个油猴脚本,只要进入的帖子里面包含特定的关键字,就给出边缘闪动警告,提醒一下自己这是抽奖贴,别瞎回 脚本不会产生任何请求,不会提交任何数据,不会对L站造成任何影响,只会判断话题页面里面是否包含某些关键字(关键字可以自己设置,光晕范围可以调) 最后的最后,佬们真的非必要不要抽奖啊,还想51天升3级呢(虽然还没有2级 ),白瞎了。。。 脚本在这 alertuser.txt (21.0 KB) 3 个帖子 - 3 位参与者 阅读完整话题
本帖使用社区开源推广,符合推广要求。我申明并遵循社区要求的以下内容: 我的帖子已经打上 开源推广 标签: 是 我的开源项目完整开源,无未开源部分: 是 我的开源项目已链接认可 LINUX DO 社区: 是 我帖子内的项目介绍,AI生成、润色内容部分已截图发出: 是 以上选择我承诺是永久有效的,接受社区和佬友监督: 是 以下为项目介绍正文内容,AI生成、润色内容已使用截图方式发出 锤子便签的笔记一直在云端里,想在本地留一份备份,但官方没有导出功能。 之前在网上找到了一个Chrome插件: https://github.com/reed-soul/smartisan-notes-saver 但只能全部导出,并且没办法导出图片。于是最近借助Claude Code小改了一下。 功能简介 全部导出 :所有便签按原分类打成一个 ZIP,文件名用 UTF-8,Windows / macOS / Linux 解压中文都不乱码。 自定义导出 :弹出面板按分类勾选你要的便签,支持搜索、文件夹整选、全选,可在「按分类 / 按修改时间」两种排序间切换;选好后打包 ZIP 或逐个下载 .md。 带上笔记里的图片 (可开关,默认开):ZIP 模式把图片存进 images/ 文件夹、正文用相对路径  引用;逐个导出则内嵌成 base64。 可选是否写入修改时间 / 创建时间;可自定义 ZIP 文件名;同分类下重名便签自动加 _2 、 _3 后缀,不会互相覆盖。 导出出来长什么样 smartisan-notes.zip ├── 工作/ │ ├── 周报模板.md │ └── 周报模板_2.md ← 同名自动加后缀 ├── 生活/ │ └── 灵感.md ├── 未分类/ │ └── ... └── images/ ← 开启「包含图片」时 └── Notes_1699xxxxxx.jpeg 每个 .md 里就是干净的纯文本: 修改时间:2024-08-15 14:32:11 便签正文…… 使用方法 先装 Tampermonkey 或 Violentmonkey (Chrome / Edge / Firefox / Safari 都有) 一键安装 https://greasyfork.org/zh-CN/scripts/576584- 锤子便签导出助手 登录 cloud.smartisan.com 或 yun.smartisan.com ,等便签同步完,点页面右下角的浮动按钮,选「全部导出」或「自定义导出」。 源码 / 反馈: GitHub - anyuxurl/smartisan-notes-export: 锤子便签导出助手 — Tampermonkey/Violentmonkey 油猴版,零依赖,支持 ZIP / 单 Markdown / 多文件三种导出模式 · GitHub 致谢原作者: GitHub - reed-soul/smartisan-notes-saver: 导出锤子便签的chrome插件 · GitHub 1 个帖子 - 1 位参与者 阅读完整话题