【油猴脚本】在sub2api中添加上游渠道时快速设置模型列表

起初是觉得sub的添加模型太不方便了,不能像newapi那样获取上游模型列表,默认填充的又一堆不能用的模型,手动一个个勾选太麻烦了。然后又不想二开sub(更新太频繁了,哪一天说不定要处理冲突,想想就麻烦)。于是想到了油猴脚本: 大概思路是,对于“添加账号”,就依赖页面上输入的apikey+apiur...
【油猴脚本】在sub2api中添加上游渠道时快速设置模型列表
【油猴脚本】在sub2api中添加上游渠道时快速设置模型列表

起初是觉得sub的添加模型太不方便了,不能像newapi那样获取上游模型列表,默认填充的又一堆不能用的模型,手动一个个勾选太麻烦了。然后又不想二开sub(更新太频繁了,哪一天说不定要处理冲突,想想就麻烦)。于是想到了油猴脚本
大概思路是,对于“添加账号”,就依赖页面上输入的apikey+apiurl调用/v1/models接口获取模型列表;对于“编辑账号”,就从请求返回的数据中获取apikey+apiurl调用/v1/models接口。

安全性:完全没问题,都是你浏览器直接向你的上游渠道发起的请求,不经过任何中间商。(把代码丢给ai分析下安全性风险即可)

效果如图:

image
image

然后是代码:

// ==UserScript==
  // @name         sub2api API Key 模型白名单自动填充
  // @namespace    https://github.com/Wei-Shaw/sub2api
  // @version      0.1.0
  // @description  在 sub2api 添加/编辑 API Key 账号时,从上游 /v1/models 获取模型并替换模型白名单
  // @match        *://*/admin/accounts*
  // @grant        GM_xmlhttpRequest
  // @connect      *
  // @run-at       document-idle
  // ==/UserScript==

  (function () {
    'use strict';

    const API_BASE = '/api/v1';
    const PANEL_ATTR = 'data-s2a-model-loader';
    const BUTTON_TEXT = '从 /v1/models 获取并替换白名单';
    const HELP_TEXT = '适用于 API Key 接入账号;编辑账号时会从账号详情接口读取已保存的 Key。';

    let lastEditId = null;
    let busy = false;
    let scanTimer = null;

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const qsa = (root, selector) => Array.from(root.querySelectorAll(selector));
    const textOf = (node) => (node?.textContent || '').replace(/\s+/g, ' ').trim();

    function isVisible(el) {
      if (!el || !(el instanceof Element)) return false;
      const rect = el.getBoundingClientRect();
      const style = window.getComputedStyle(el);
      return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
    }

    function isBefore(a, b) {
      return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
    }

    function activeDialog() {
      const dialogs = qsa(document, '[role="dialog"][aria-modal="true"], .modal-overlay');
      return dialogs.filter(isVisible).at(-1) || null;
    }

    function dialogTitle(dialog) {
      return textOf(dialog.querySelector('.modal-title, h3, [id^="modal-title"]'));
    }

    function isEditDialog(dialog) {
      return Boolean(dialog.querySelector('#edit-account-form')) || /编辑|Edit/i.test(dialogTitle(dialog));
    }

    function accountNameFromDialog(dialog) {
      const input = dialog.querySelector('input[data-tour="edit-account-form-name"], input[data-tour="account-form-name"]');
      return (input?.value || '').trim();
    }

    function findButtonByText(root, patterns) {
      return qsa(root, 'button').find((button) => {
        if (!isVisible(button)) return false;
        const text = textOf(button);
        return patterns.some((pattern) => pattern.test(text));
      }) || null;
    }

    function whitelistButton(dialog) {
      return findButtonByText(dialog, [/^模型白名单$/i, /^Model Whitelist$/i, /^Model whitelist$/i]);
    }

    function customModelInput(dialog) {
      const inputs = qsa(dialog, 'input').filter(isVisible);
      return inputs.find((input) => /输入自定义模型名称|Enter custom model name/i.test(input.placeholder || '')) || null;
    }

    function whitelistRoot(dialog) {
      const input = customModelInput(dialog);
      return input?.closest('.mb-3')?.parentElement || dialog;
    }

    function findApiKeyContext(dialog) {
      const whitelist = whitelistButton(dialog);
      if (!whitelist) return null;

      const inputsBeforeWhitelist = qsa(dialog, 'input')
        .filter((input) => isVisible(input) && isBefore(input, whitelist));

      const apiKeyInput = inputsBeforeWhitelist
        .filter((input) => (input.getAttribute('type') || '').toLowerCase() === 'password')
        .at(-1);
      if (!apiKeyInput) return null;

      const apiKeyIndex = inputsBeforeWhitelist.indexOf(apiKeyInput);
      const baseUrlInput = inputsBeforeWhitelist
        .slice(0, apiKeyIndex)
        .reverse()
        .find((input) => {
          const type = (input.getAttribute('type') || 'text').toLowerCase();
          const value = `${input.value || ''} ${input.placeholder || ''}`;
          return ['text', 'url', 'search', ''].includes(type) && /https?:\/\//i.test(value);
        });

      if (!baseUrlInput) return null;
      return { baseUrlInput, apiKeyInput, whitelist };
    }

    function statusEl(panel) {
      return panel.querySelector('[data-s2a-status]');
    }

    function setStatus(panel, message, tone = 'muted') {
      const el = statusEl(panel);
      if (!el) return;
      el.textContent = message;
      const colors = {
        muted: '#6b7280',
        info: '#2563eb',
        success: '#059669',
        error: '#dc2626'
      };
      el.style.color = colors[tone] || colors.muted;
    }

    function showToast(message, tone = 'info') {
      const old = document.querySelector('[data-s2a-toast]');
      old?.remove();

      const toast = document.createElement('div');
      toast.setAttribute('data-s2a-toast', '1');
      toast.textContent = message;
      const bg = tone === 'error' ? '#dc2626' : tone === 'success' ? '#059669' : '#2563eb';
      Object.assign(toast.style, {
        position: 'fixed',
        right: '18px',
        top: '18px',
        zIndex: '999999',
        maxWidth: '420px',
        padding: '10px 14px',
        borderRadius: '8px',
        background: bg,
        color: '#fff',
        fontSize: '14px',
        lineHeight: '1.45',
        boxShadow: '0 10px 30px rgba(0,0,0,.22)'
      });
      document.body.appendChild(toast);
      window.setTimeout(() => toast.remove(), 4500);
    }

    function injectPanel(dialog, ctx) {
      const existing = dialog.querySelector(`[${PANEL_ATTR}]`);
      if (existing) {
        if (existing.__s2aApiKeyInput === ctx.apiKeyInput) return;
        existing.remove();
      }

      const panel = document.createElement('div');
      panel.setAttribute(PANEL_ATTR, '1');
      panel.__s2aApiKeyInput = ctx.apiKeyInput;
      Object.assign(panel.style, {
        marginTop: '10px',
        padding: '10px',
        border: '1px solid rgba(37, 99, 235, .28)',
        borderRadius: '8px',
        background: 'rgba(37, 99, 235, .06)'
      });

      const row = document.createElement('div');
      Object.assign(row.style, { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' });

      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = BUTTON_TEXT;
      Object.assign(button.style, {
        border: '0',
        borderRadius: '8px',
        padding: '8px 12px',
        cursor: 'pointer',
        background: '#2563eb',
        color: '#fff',
        fontSize: '13px',
        fontWeight: '600'
      });

      const status = document.createElement('span');
      status.setAttribute('data-s2a-status', '1');
      status.textContent = HELP_TEXT;
      Object.assign(status.style, { color: '#6b7280', fontSize: '12px' });

      row.append(button, status);
      panel.append(row);

      button.addEventListener('click', () => runModelImport(dialog, panel));

      const apiKeyField = ctx.apiKeyInput.closest('div') || ctx.apiKeyInput.parentElement;
      apiKeyField?.insertAdjacentElement('afterend', panel);
    }

    function scheduleScan() {
      window.clearTimeout(scanTimer);
      scanTimer = window.setTimeout(scan, 80);
    }

    function scan() {
      if (!location.pathname.startsWith('/admin/accounts')) return;
      const dialog = activeDialog();
      if (!dialog) return;

      const ctx = findApiKeyContext(dialog);
      const panel = dialog.querySelector(`[${PANEL_ATTR}]`);
      if (!ctx) {
        panel?.remove();
        return;
      }
      injectPanel(dialog, ctx);
    }

    document.addEventListener('click', (event) => {
      const target = event.target instanceof Element ? event.target : null;
      const button = target?.closest('button');
      if (button && /编辑|Edit/i.test(textOf(button))) {
        const row = button.closest('tr[data-row-id]');
        const id = Number(row?.getAttribute('data-row-id'));
        lastEditId = Number.isFinite(id) && id > 0 ? id : null;
      }
      scheduleScan();
    }, true);

    new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
    window.setInterval(scan, 1200);
    scan();

    function normalizeModelsUrl(baseUrl) {
      const clean = String(baseUrl || '').trim().replace(/\/+$/, '');
      if (!clean) throw new Error('请先填写 Base URL。');
      if (/\/v1\/models$/i.test(clean)) return clean;
      if (/\/v1$/i.test(clean)) return `${clean}/models`;
      return `${clean}/v1/models`;
    }

    function unwrapApiEnvelope(payload) {
      if (payload && typeof payload === 'object' && 'code' in payload && 'data' in payload) {
        if (Number(payload.code) !== 0) {
          throw new Error(payload.message || '后台接口返回错误。');
        }
        return payload.data;
      }
      return payload;
    }

    async function adminGet(path) {
      const headers = { Accept: 'application/json' };
      const token = localStorage.getItem('auth_token');
      if (token) headers.Authorization = `Bearer ${token}`;

      const response = await fetch(path, { headers, credentials: 'include' });
      const bodyText = await response.text();
      let payload = null;
      try {
        payload = bodyText ? JSON.parse(bodyText) : null;
      } catch {
        payload = null;
      }

      if (!response.ok) {
        const message = payload?.message || payload?.detail || bodyText.slice(0, 180) || response.statusText;
        throw new Error(`后台接口请求失败(HTTP ${response.status}):${message}`);
      }
      return unwrapApiEnvelope(payload);
    }

    async function resolveEditAccount(dialog) {
      if (lastEditId) {
        try {
          const account = await adminGet(`${API_BASE}/admin/accounts/${lastEditId}`);
          if (account?.type === 'apikey') return account;
        } catch {
          // 继续走名称搜索兜底。
        }
      }

      const name = accountNameFromDialog(dialog);
      if (!name) {
        throw new Error('无法识别当前编辑账号,请从账号列表点击“编辑”打开弹窗后重试。');
      }

      const list = await adminGet(`${API_BASE}/admin/accounts?page=1&page_size=50&search=${encodeURIComponent(name)}`);
      const items = Array.isArray(list?.items) ? list.items : Array.isArray(list) ? list : [];
      const matches = items.filter((account) => {
        return account?.type === 'apikey' && String(account?.name || '').trim() === name;
      });

      if (matches.length !== 1) {
        throw new Error(`无法通过账号名唯一定位账号(匹配到 ${matches.length} 个),请从账号列表点击“编辑”后立即重试。`);
      }
      return await adminGet(`${API_BASE}/admin/accounts/${matches[0].id}`);
    }

    async function getApiKey(dialog, ctx) {
      const typed = (ctx.apiKeyInput.value || '').trim();
      if (typed) return typed;

      if (!isEditDialog(dialog)) {
        throw new Error('新增账号时请先填写 API Key。');
      }

      const account = await resolveEditAccount(dialog);
      if (account?.type !== 'apikey') {
        throw new Error('当前账号不是 API Key 接入账号。');
      }

      const saved = String(account?.credentials?.api_key || '').trim();
      if (!saved) {
        throw new Error('账号详情里没有可用的已保存 API Key,请在表单里重新填写 API Key。');
      }
      return saved;
    }

    function requestUpstreamModels(url, apiKey) {
      if (typeof GM_xmlhttpRequest !== 'function') {
        throw new Error('当前油猴环境不支持 GM_xmlhttpRequest,请确认脚本授权是否生效。');
      }

      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          timeout: 30000,
          headers: {
            Accept: 'application/json',
            Authorization: `Bearer ${apiKey}`,
            'x-api-key': apiKey,
            'x-goog-api-key': apiKey,
            'anthropic-version': '2023-06-01'
          },
          onload: (response) => {
            const text = response.responseText || '';
            if (response.status < 200 || response.status >= 300) {
              reject(new Error(`上游 /v1/models 请求失败(HTTP ${response.status}):${text.slice(0, 240) || response.statusText}`));
              return;
            }
            try {
              resolve(text ? JSON.parse(text) : null);
            } catch {
              reject(new Error('上游 /v1/models 返回的不是合法 JSON。'));
            }
          },
          onerror: () => reject(new Error('上游 /v1/models 网络请求失败。')),
          ontimeout: () => reject(new Error('上游 /v1/models 请求超时。'))
        });
      });
    }

    function normalizeModelId(value) {
      let model = String(value || '').trim();
      if (!model) return '';
      const marker = '/models/';
      const markerIndex = model.lastIndexOf(marker);
      if (markerIndex >= 0) model = model.slice(markerIndex + marker.length);
      model = model.replace(/^models\//i, '');
      if (/:[A-Za-z]+/.test(model)) model = model.split(':')[0];
      return model.trim();
    }

    function looksLikeModelKey(key) {
      const value = normalizeModelId(key);
      if (!value || /^(data|items|models|object|has_more|total)$/i.test(value)) return false;
      return /^[A-Za-z0-9][A-Za-z0-9._:@/+-]*$/.test(value);
    }

    function extractModelIds(payload) {
      const result = [];
      const seen = new Set();

      function add(value) {
        const model = normalizeModelId(value);
        if (!model || seen.has(model)) return;
        seen.add(model);
        result.push(model);
      }

      function visit(value, hint = '') {
        if (value == null) return;
        if (typeof value === 'string') {
          add(value);
          return;
        }
        if (Array.isArray(value)) {
          value.forEach((item) => visit(item, hint));
          return;
        }
        if (typeof value !== 'object') return;

        const direct = value.id || value.name || value.model || value.model_id || value.value || value.slug;
        if (typeof direct === 'string') add(direct);

        ['data', 'models', 'items'].forEach((key) => {
          if (value[key] !== undefined) visit(value[key], key);
        });

        if (/^(models|items)$/i.test(hint)) {
          Object.entries(value).forEach(([key, item]) => {
            const before = result.length;
            visit(item, key);
            if (result.length === before && looksLikeModelKey(key)) add(key);
          });
        }
      }

      visit(payload, 'root');
      return result;
    }

    async function waitFor(fn, timeoutMs = 3000) {
      const end = Date.now() + timeoutMs;
      while (Date.now() < end) {
        const value = fn();
        if (value) return value;
        await sleep(50);
      }
      return null;
    }

    function setNativeValue(input, value) {
      const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
      setter.call(input, value);
      input.dispatchEvent(new Event('input', { bubbles: true }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
    }

    function addModelButton(input, dialog) {
      const row = input.closest('.flex') || input.parentElement;
      const rowButton = row ? findButtonByText(row, [/^(填入|Add)$/i]) : null;
      if (rowButton) return rowButton;
      return findButtonByText(whitelistRoot(dialog), [/^(填入|Add)$/i]);
    }

    async function clearWhitelist(dialog) {
      const root = whitelistRoot(dialog);
      const clearButton = findButtonByText(root, [/清除所有模型/i, /清空模型/i, /^Clear all models$/i]);
      if (clearButton) {
        clearButton.click();
        await sleep(120);
        return;
      }

      const removeButtons = qsa(root, 'button').filter((button) => {
        return isVisible(button) && !textOf(button) && button.closest('span') && button.querySelector('svg');
      });
      removeButtons.forEach((button) => button.click());
      await sleep(120);
    }

    async function replaceWhitelist(dialog, ids, panel) {
      const whitelist = whitelistButton(dialog);
      if (!whitelist) throw new Error('没有找到“模型白名单”按钮。');

      whitelist.click();
      const input = await waitFor(() => customModelInput(dialog));
      if (!input) throw new Error('没有找到自定义模型输入框,请确认当前处于模型白名单模式。');

      await clearWhitelist(dialog);

      for (let i = 0; i < ids.length; i += 1) {
        const freshInput = customModelInput(dialog) || input;
        const addButton = addModelButton(freshInput, dialog);
        if (!addButton) throw new Error('没有找到模型“填入”按钮。');

        setNativeValue(freshInput, ids[i]);
        addButton.click();
        if ((i + 1) % 20 === 0 || i === ids.length - 1) {
          setStatus(panel, `正在填充模型:${i + 1}/${ids.length}`, 'info');
          await sleep(30);
        } else {
          await sleep(8);
        }
      }
    }

    async function runModelImport(dialog, panel) {
      if (busy) return;
      busy = true;

      const ctx = findApiKeyContext(dialog);
      const button = panel.querySelector('button');
      const oldText = button?.textContent || BUTTON_TEXT;
      if (button) {
        button.disabled = true;
        button.textContent = '正在获取模型...';
        button.style.opacity = '0.72';
        button.style.cursor = 'wait';
      }

      try {
        if (!ctx) throw new Error('当前弹窗不是 API Key 接入账号,或页面结构未找到 Base URL/API Key/模型白名单。');

        const baseUrl = (ctx.baseUrlInput.value || ctx.baseUrlInput.placeholder || '').trim();
        const modelsUrl = normalizeModelsUrl(baseUrl);
        const apiKey = await getApiKey(dialog, ctx);

        setStatus(panel, `正在请求:${modelsUrl}`, 'info');
        const payload = await requestUpstreamModels(modelsUrl, apiKey);
        const ids = extractModelIds(payload);
        if (ids.length === 0) {
          throw new Error('上游返回中没有识别到模型 ID。');
        }

        setStatus(panel, `已获取 ${ids.length} 个模型,正在替换白名单...`, 'info');
        await replaceWhitelist(dialog, ids, panel);

        const message = `已从 /v1/models 获取并替换 ${ids.length} 个模型。请确认后保存账号。`;
        setStatus(panel, message, 'success');
        showToast(message, 'success');
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        setStatus(panel, message, 'error');
        showToast(message, 'error');
      } finally {
        if (button) {
          button.disabled = false;
          button.textContent = oldText;
          button.style.opacity = '1';
          button.style.cursor = 'pointer';
        }
        busy = false;
      }
    }
  })();

1 个帖子 - 1 位参与者

阅读完整话题

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