sub2api测活+cpa->sub2api 油猴脚本分享

sub2api测活+cpa->sub2api 油猴脚本分享
sub2api测活+cpa->sub2api 油猴脚本分享

因为bugteam,论坛好多号可以蹬,碍于本人现在都用SUB2API,大多佬友分享的都是CPA。
故寻找脚本,基于佬友分享的脚本,补充了一下功能。

sub2api测活工具,个人自用分享一下 开发调优
Sub2API 账号模型巡检油猴脚本改版分享 说明:这个脚本是在参考站内一位大佬的油猴脚本基础上改出来的,主要是按我自己的使用场景做了一些调整。原思路来自前辈,感谢站内大佬的分享;如果有不合适的地方可以提醒我修改或删除。 主要修改 这版主要改了两个地方: 支持按分组收集账号并测活 脚本会尽量识别当前账号列表页面的分组。 拉取账号时只处理当前分组下的账号。 如果没识别到分组,会弹窗让…

image

  1. 测活,失败后直接删除。
  2. 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 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文