NewAPI Token Import to CC Switch

Add import-to-CCSwitch action for token rows on NewAPI-style consoles

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         NewAPI Token Import to CC Switch
// @namespace    https://newapi.style/
// @version      0.14
// @description  Add import-to-CCSwitch action for token rows on NewAPI-style consoles
// @author       irisWirisW (https://github.com/irisWirisW)
// @license      AGPL-3.0-or-later
// @homepageURL  https://greasyfork.org/zh-CN/scripts/565602-newapi-token-import-to-cc-switch
// @supportURL   https://greasyfork.org/zh-CN/scripts/565602-newapi-token-import-to-cc-switch/feedback
// @match        *://*/console/*
// @run-at       document-idle
// @grant        GM_setClipboard
// @grant        GM_notification
// ==/UserScript==

(() => {
  "use strict";

  const DEFAULT_APP = "codex";
  const CODEX_MODEL = "gpt-5.2-codex";
  const ROW_SELECTOR = "tr.semi-table-row[data-row-key]";
  const ACTION_SELECTOR = 'td[aria-colindex="11"] .semi-space';
  const BOUND_ATTR = "data-ccs-bound";
  const TOKEN_PATH_REGEX = /^\/console\/token(?:\/|$)/;
  const KEY_REGEX =
    /^(sk-[A-Za-z0-9_-]{16,}|cr_[A-Za-z0-9_-]{16,}|rk-[A-Za-z0-9_-]{16,}|pk-[A-Za-z0-9_-]{16,})$/;

  let modal = null;
  let appField = null;
  let nameField = null;
  let endpointField = null;
  let keyField = null;
  let nameHintField = null;
  let messageField = null;
  let importBtn = null;
  let copyBtn = null;
  let actionMenu = null;
  let actionMenuAnchor = null;

  const style = document.createElement("style");
  style.textContent = `
    .ccs-newapi-btn {
      margin-left: 4px;
      border-color: rgba(59, 130, 246, 0.35) !important;
      color: #60a5fa !important;
    }
    .ccs-newapi-modal {
      --ccs-overlay: rgba(2, 6, 23, 0.72);
      --ccs-panel: #0f172a;
      --ccs-panel-border: rgba(148, 163, 184, 0.32);
      --ccs-text: #e2e8f0;
      --ccs-muted: #94a3b8;
      --ccs-input-bg: #0b1220;
      --ccs-input-border: #334155;
      --ccs-input-text: #f8fafc;
      --ccs-primary: #2563eb;
      position: fixed;
      inset: 0;
      background: var(--ccs-overlay);
      display: none;
      align-items: center;
      justify-content: center;
      z-index: 99999;
    }
    .ccs-newapi-modal.open {
      display: flex;
    }
    .ccs-newapi-panel {
      width: min(560px, 92vw);
      border-radius: 12px;
      background: var(--ccs-panel);
      border: 1px solid var(--ccs-panel-border);
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
      color: var(--ccs-text);
      padding: 16px;
      font-size: 13px;
    }
    .ccs-newapi-title {
      font-size: 16px;
      font-weight: 700;
      margin-bottom: 8px;
    }
    .ccs-newapi-msg {
      color: var(--ccs-muted);
      font-size: 12px;
      margin-bottom: 10px;
    }
    .ccs-newapi-name-hint {
      margin-top: 6px;
      font-size: 12px;
      color: #fbbf24;
      line-height: 1.4;
      display: none;
    }
    .ccs-newapi-panel label {
      display: block;
      font-weight: 600;
      margin: 8px 0 4px;
    }
    .ccs-newapi-panel input,
    .ccs-newapi-panel select {
      width: 100%;
      display: block;
      box-sizing: border-box;
      border-radius: 8px;
      border: 1px solid var(--ccs-input-border);
      background: var(--ccs-input-bg);
      color: var(--ccs-input-text);
      padding: 7px 10px;
      font-size: 13px;
    }
    .ccs-newapi-actions {
      margin-top: 12px;
      display: flex;
      justify-content: flex-end;
      gap: 8px;
      flex-wrap: wrap;
    }
    .ccs-newapi-actions button {
      border-radius: 999px;
      border: 1px solid #334155;
      background: #111827;
      color: #e2e8f0;
      font-size: 12px;
      font-weight: 700;
      padding: 6px 12px;
      cursor: pointer;
    }
    .ccs-newapi-actions .ccs-newapi-import {
      background: var(--ccs-primary);
      border-color: #1d4ed8;
      color: #eff6ff;
    }
    .ccs-newapi-hidden-action {
      display: none !important;
    }
    .ccs-newapi-split .ccs-newapi-btn {
      margin-left: 0;
    }
    .ccs-newapi-more-menu {
      position: fixed;
      z-index: 100001;
      min-width: 124px;
      border-radius: 8px;
      border: 1px solid #334155;
      background: #111827;
      box-shadow: 0 18px 44px rgba(0, 0, 0, 0.35);
      padding: 4px;
      display: none;
    }
    .ccs-newapi-more-menu.open {
      display: block;
    }
    .ccs-newapi-more-menu__item {
      width: 100%;
      border: 0;
      border-radius: 6px;
      padding: 6px 10px;
      background: transparent;
      color: #e2e8f0;
      text-align: left;
      font-size: 12px;
      font-weight: 600;
      cursor: pointer;
    }
    .ccs-newapi-more-menu__item:hover {
      background: rgba(148, 163, 184, 0.16);
    }
    .ccs-newapi-more-menu__item.danger {
      color: #f87171;
    }
    thead th[aria-colindex="6"],
    tr.semi-table-row td[aria-colindex="6"] {
      width: 96px !important;
      max-width: 96px !important;
    }
    tr.semi-table-row td[aria-colindex="6"] .semi-input-wrapper {
      width: auto !important;
      min-width: 0 !important;
      max-width: none !important;
      display: inline-flex !important;
      align-items: center;
      padding-left: 0 !important;
      padding-right: 4px !important;
      background: transparent !important;
      border-color: transparent !important;
      box-shadow: none !important;
    }
    tr.semi-table-row td[aria-colindex="6"] input.semi-input {
      width: 0 !important;
      min-width: 0 !important;
      max-width: 0 !important;
      padding: 0 !important;
      margin: 0 !important;
      border: 0 !important;
      color: transparent !important;
      -webkit-text-fill-color: transparent !important;
      text-shadow: none !important;
      opacity: 0 !important;
      pointer-events: none;
      user-select: none;
      flex: 0 0 0 !important;
    }
    tr.semi-table-row td[aria-colindex="6"] .semi-input-suffix {
      margin-left: 0 !important;
      display: inline-flex !important;
      gap: 0 !important;
    }
    tr.semi-table-row td[aria-colindex="6"] .semi-input-suffix button:not(:last-child) {
      display: none !important;
    }
  `;
  document.head.appendChild(style);

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  function pageUrl() {
    return window.location.href.split("#")[0];
  }

  function inferName(endpoint) {
    try {
      return new URL(endpoint).host;
    } catch {
      return "custom";
    }
  }

  function getDefaultNameByApp(app, endpoint) {
    if ((app || DEFAULT_APP) === "codex") {
      return "custom";
    }
    return inferName(endpoint);
  }

  function updateNameHint(app, name) {
    if (!nameHintField) return;
    if ((app || DEFAULT_APP) !== "codex") {
      nameHintField.style.display = "none";
      nameHintField.textContent = "";
      return;
    }

    const normalizedName = (name || "").trim();
    const statusText =
      normalizedName === "custom"
        ? "当前已设置为 custom。"
        : "当前不是 custom,可能导致 thread 出错。";

    nameHintField.style.display = "block";
    nameHintField.textContent = `提示:Codex 导入名称建议使用 custom,不设置成 custom 可能导致 thread 出错。${statusText}`;
  }

  function buildDeepLink({ app, name, endpoint, apiKey }) {
    const normalizedApp = app || DEFAULT_APP;
    const params = new URLSearchParams({
      resource: "provider",
      app: normalizedApp,
      name:
        name && name.trim()
          ? name.trim()
          : getDefaultNameByApp(normalizedApp, endpoint),
      endpoint,
      apiKey,
      homepage: pageUrl(),
    });
    if ((app || DEFAULT_APP) === "codex" && CODEX_MODEL) {
      params.set("model", CODEX_MODEL);
    }
    return `ccswitch://v1/import?${params.toString()}`;
  }

  function looksMasked(key) {
    return key.includes("*");
  }

  function isLikelyKey(key) {
    return KEY_REGEX.test((key || "").trim());
  }

  function getSiteName() {
    const navTitle =
      document.querySelector("header h4") ||
      document.querySelector(".semi-typography-h4");
    const navText = navTitle ? (navTitle.textContent || "").trim() : "";
    if (navText) return navText;

    const titleText = (document.title || "").split("|")[0].trim();
    if (titleText) return titleText;

    try {
      return new URL(window.location.href).host;
    } catch {
      return "NewAPI Console";
    }
  }

  function isTokenPage() {
    return TOKEN_PATH_REGEX.test(window.location.pathname);
  }

  function cleanupInjected() {
    closeActionMenu();
    document.querySelectorAll('.ccs-newapi-split').forEach((split) => split.remove());
    document
      .querySelectorAll('.ccs-newapi-hidden-action')
      .forEach((el) => el.classList.remove('ccs-newapi-hidden-action'));
    document
      .querySelectorAll('.ccs-newapi-btn')
      .forEach((button) => {
        if (!button.closest('.ccs-newapi-split')) button.remove();
      });
    document
      .querySelectorAll(`tr.semi-table-row[${BOUND_ATTR}]`)
      .forEach((row) => row.removeAttribute(BOUND_ATTR));
    closeModal();
  }

  function getTokenInput(row) {
    return row.querySelector('td[aria-colindex="6"] input.semi-input');
  }

  async function resolveApiKey(row) {
    const input = getTokenInput(row);
    let key = input ? (input.value || "").trim() : "";
    if (key && !looksMasked(key) && isLikelyKey(key)) return key;

    const eyeBtn = row.querySelector(
      'td[aria-colindex="6"] button[aria-label*="toggle token visibility"]',
    );
    if (eyeBtn) {
      eyeBtn.click();
      await sleep(120);
      key = input ? (input.value || "").trim() : key;
      if (key && !looksMasked(key) && isLikelyKey(key)) return key;
    }

    const copyBtn = row.querySelector(
      'td[aria-colindex="6"] button[aria-label*="copy token key"]',
    );
    if (copyBtn && navigator.clipboard && navigator.clipboard.readText) {
      try {
        copyBtn.click();
        await sleep(160);
        const copied = ((await navigator.clipboard.readText()) || "").trim();
        if (copied && isLikelyKey(copied)) return copied;
      } catch {
        // Ignore and fallback to prompt.
      }
    }

    return key;
  }

  function ensureModal() {
    if (modal) return;

    modal = document.createElement("div");
    modal.className = "ccs-newapi-modal";
    modal.innerHTML = `
      <div class="ccs-newapi-panel" role="dialog" aria-modal="true">
        <div class="ccs-newapi-title">导入到 CC Switch</div>
        <div class="ccs-newapi-msg"></div>
        <label>导入应用</label>
        <select class="ccs-newapi-app">
          <option value="codex">Codex</option>
          <option value="claude">Claude Code</option>
        </select>
        <label>名称</label>
        <input class="ccs-newapi-name" />
        <div class="ccs-newapi-name-hint"></div>
        <label>Endpoint URL</label>
        <input class="ccs-newapi-endpoint" />
        <label>API Key</label>
        <input class="ccs-newapi-key" />
        <div class="ccs-newapi-actions">
          <button type="button" class="ccs-newapi-copy">复制导入链接</button>
          <button type="button" class="ccs-newapi-import">导入到 CC Switch</button>
          <button type="button" class="ccs-newapi-cancel">取消</button>
        </div>
      </div>
    `;

    document.body.appendChild(modal);
    appField = modal.querySelector(".ccs-newapi-app");
    nameField = modal.querySelector(".ccs-newapi-name");
    endpointField = modal.querySelector(".ccs-newapi-endpoint");
    keyField = modal.querySelector(".ccs-newapi-key");
    nameHintField = modal.querySelector(".ccs-newapi-name-hint");
    messageField = modal.querySelector(".ccs-newapi-msg");
    importBtn = modal.querySelector(".ccs-newapi-import");
    copyBtn = modal.querySelector(".ccs-newapi-copy");

    const cancelBtn = modal.querySelector(".ccs-newapi-cancel");
    cancelBtn.addEventListener("click", closeModal);
    modal.addEventListener("click", (event) => {
      if (event.target === modal) closeModal();
    });

    appField.addEventListener("change", () => {
      if (!nameField) return;
      const app = appField.value || DEFAULT_APP;
      if (app === "codex" && !(nameField.value || "").trim()) {
        nameField.value = "custom";
      }
      updateNameHint(app, nameField.value);
    });

    nameField.addEventListener("input", () => {
      const app = (appField && appField.value) || DEFAULT_APP;
      updateNameHint(app, nameField.value);
    });

    bindAutoSelect(nameField);
    bindAutoSelect(endpointField);
    bindAutoSelect(keyField);
  }

  function bindAutoSelect(input) {
    if (!input) return;
    const selectAll = () => {
      input.focus();
      input.select();
    };
    input.addEventListener("focus", () => setTimeout(selectAll, 0));
    input.addEventListener("click", selectAll);
    input.addEventListener("mouseup", (event) => event.preventDefault());
  }

  function closeModal() {
    if (!modal) return;
    modal.classList.remove("open");
  }

  function ensureActionMenu() {
    if (actionMenu) return;

    actionMenu = document.createElement("div");
    actionMenu.className = "ccs-newapi-more-menu";
    document.body.appendChild(actionMenu);

    document.addEventListener(
      "mousedown",
      (event) => {
        if (!actionMenu || !actionMenu.classList.contains("open")) return;
        const target = event.target;
        if (actionMenu.contains(target)) return;
        if (actionMenuAnchor && actionMenuAnchor.contains(target)) return;
        closeActionMenu();
      },
      true,
    );

    window.addEventListener("resize", closeActionMenu);
    window.addEventListener("scroll", closeActionMenu, true);
  }

  function closeActionMenu() {
    if (!actionMenu) return;
    actionMenu.classList.remove("open");
    actionMenu.innerHTML = "";
    actionMenuAnchor = null;
  }

  function openActionMenu(anchor, buttons) {
    const actions = buttons.filter((button) => button && button.isConnected);
    if (!actions.length) {
      closeActionMenu();
      return;
    }

    ensureActionMenu();
    actionMenu.innerHTML = "";

    actions.forEach((button) => {
      const label = (button.textContent || "").trim();
      if (!label) return;

      const item = document.createElement("button");
      item.type = "button";
      item.className = "ccs-newapi-more-menu__item";
      if (button.classList.contains("semi-button-danger")) {
        item.classList.add("danger");
      }
      item.textContent = label;
      item.addEventListener("click", (event) => {
        event.preventDefault();
        event.stopPropagation();
        closeActionMenu();
        button.click();
      });
      actionMenu.appendChild(item);
    });

    if (!actionMenu.children.length) {
      closeActionMenu();
      return;
    }

    actionMenu.classList.add("open");
    actionMenuAnchor = anchor;

    const rect = anchor.getBoundingClientRect();
    const menuRect = actionMenu.getBoundingClientRect();
    const left = Math.max(
      8,
      Math.min(rect.right - menuRect.width, window.innerWidth - menuRect.width - 8),
    );
    let top = rect.bottom + 6;
    if (top + menuRect.height > window.innerHeight - 8) {
      top = Math.max(8, rect.top - menuRect.height - 6);
    }

    actionMenu.style.left = `${left}px`;
    actionMenu.style.top = `${top}px`;
  }

  function writeClipboard(text) {
    if (typeof GM_setClipboard === "function") {
      GM_setClipboard(text);
      if (typeof GM_notification === "function") {
        GM_notification({ text: "已复制导入链接", timeout: 1200 });
      }
      return;
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).catch(() => {});
    }
  }

  function showModal({ name, endpoint, apiKey, message }) {
    ensureModal();

    appField.value = DEFAULT_APP;
    nameField.value = name || getDefaultNameByApp(DEFAULT_APP, endpoint || `${window.location.origin}/v1`);
    endpointField.value = endpoint || `${window.location.origin}/v1`;
    keyField.value = apiKey || "";
    messageField.textContent = message || "请确认参数后导入。";
    updateNameHint(appField.value, nameField.value);

    const buildCurrentLink = () =>
      buildDeepLink({
        app: appField.value,
        name: nameField.value,
        endpoint: endpointField.value,
        apiKey: keyField.value,
      });

    importBtn.onclick = () => {
      const endpointValue = (endpointField.value || "").trim();
      const apiKeyValue = (keyField.value || "").trim();
      if (!endpointValue || !apiKeyValue) {
        messageField.textContent = "Endpoint 和 API Key 不能为空。";
        return;
      }
      if (!isLikelyKey(apiKeyValue)) {
        messageField.textContent =
          "API Key 看起来不完整,请先手动确认或粘贴完整 key。";
        return;
      }

      const appValue = (appField.value || DEFAULT_APP).trim();
      const nameValue = (nameField.value || "").trim();
      if (appValue === "codex" && nameValue !== "custom") {
        const shouldContinue = window.confirm(
          "提示:Codex 导入名称建议使用 custom,不设置成 custom 可能导致 thread 出错。是否继续导入?",
        );
        if (!shouldContinue) {
          updateNameHint(appValue, nameValue);
          return;
        }
      }

      window.location.href = buildCurrentLink();
      closeModal();
    };

    copyBtn.onclick = () => {
      const endpointValue = (endpointField.value || "").trim();
      const apiKeyValue = (keyField.value || "").trim();
      if (!endpointValue || !apiKeyValue) {
        messageField.textContent = "Endpoint 和 API Key 不能为空。";
        return;
      }
      writeClipboard(buildCurrentLink());
    };

    modal.classList.add("open");
  }

  async function onImportClick(row) {
    const name = getDefaultNameByApp(DEFAULT_APP, `${window.location.origin}/v1`);
    const endpoint = `${window.location.origin}/v1`;
    const key = await resolveApiKey(row);
    const keyReady = key && isLikelyKey(key) && !looksMasked(key);
    const message = keyReady
      ? "已自动读取 key,可直接导入。"
      : "未能自动读取完整 key,请手动补全后导入。";

    showModal({
      name,
      endpoint,
      apiKey: key || "",
      message,
    });
  }

  function injectRowButton(row) {
    if (!(row instanceof Element)) return;

    const actionWrap = row.querySelector(ACTION_SELECTOR);
    if (!actionWrap) return;

    if (row.querySelector('.ccs-newapi-split')) {
      row.setAttribute(BOUND_ATTR, "1");
      return;
    }

    actionWrap
      .querySelectorAll(':scope > .ccs-newapi-btn')
      .forEach((button) => button.remove());

    const originalSplit = actionWrap.querySelector(':scope > .semi-button-split');
    if (!originalSplit) return;

    const originalMoreBtn = originalSplit.querySelector('button:last-child');
    const extraActionButtons = Array.from(
      actionWrap.querySelectorAll(':scope > button:not(.ccs-newapi-btn)'),
    );

    const split = document.createElement("div");
    split.className = "semi-button-split overflow-hidden ccs-newapi-split";
    split.setAttribute("role", "group");
    split.setAttribute("aria-label", "CC Switch 操作按钮组");

    const importButton = document.createElement("button");
    importButton.className =
      "semi-button semi-button-tertiary semi-button-size-small semi-button-light semi-button-first ccs-newapi-btn";
    importButton.type = "button";
    importButton.innerHTML = '<span class="semi-button-content">导入CCSwitch</span>';
    importButton.addEventListener("click", async (event) => {
      event.preventDefault();
      event.stopPropagation();
      await onImportClick(row);
    });

    let moreButton = null;
    if (originalMoreBtn) {
      moreButton = originalMoreBtn.cloneNode(true);
      moreButton.className =
        "semi-button semi-button-tertiary semi-button-size-small semi-button-light semi-button-with-icon semi-button-with-icon-only semi-button-last ccs-newapi-more-btn";
      moreButton.removeAttribute("aria-describedby");
      moreButton.removeAttribute("data-popupid");
    } else {
      moreButton = document.createElement("button");
      moreButton.type = "button";
      moreButton.className =
        "semi-button semi-button-tertiary semi-button-size-small semi-button-light semi-button-with-icon semi-button-with-icon-only semi-button-last ccs-newapi-more-btn";
      moreButton.innerHTML =
        '<span class="semi-button-content"><span aria-hidden="true">▼</span></span>';
    }

    moreButton.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      openActionMenu(
        moreButton,
        extraActionButtons.filter((button) => button.classList.contains('ccs-newapi-hidden-action')),
      );
    });

    split.appendChild(importButton);
    split.appendChild(moreButton);

    originalSplit.classList.add('ccs-newapi-hidden-action');
    extraActionButtons.forEach((button) => button.classList.add('ccs-newapi-hidden-action'));

    actionWrap.insertBefore(split, originalSplit);
    row.setAttribute(BOUND_ATTR, "1");
  }

  function scanAndInject() {
    if (!isTokenPage()) return;
    document.querySelectorAll(ROW_SELECTOR).forEach((row) => injectRowButton(row));
  }

  let scanTimer = null;
  let domObserver = null;

  function startDomObserver() {
    if (domObserver || !document.body) return;
    domObserver = new MutationObserver(() => {
      if (isTokenPage()) scheduleScan();
    });
    domObserver.observe(document.body, { childList: true, subtree: true });
  }

  function stopDomObserver() {
    if (!domObserver) return;
    domObserver.disconnect();
    domObserver = null;
  }

  const scheduleScan = () => {
    if (!isTokenPage()) {
      cleanupInjected();
      stopDomObserver();
      return;
    }

    startDomObserver();
    if (scanTimer) return;
    scanTimer = setTimeout(() => {
      scanTimer = null;
      scanAndInject();
    }, 100);
  };

  function getRouteKey() {
    return `${window.location.pathname}${window.location.search}${window.location.hash}`;
  }

  let lastRouteKey = getRouteKey();

  function onRouteChange(force = false) {
    const routeKey = getRouteKey();
    if (!force && routeKey === lastRouteKey) return;
    lastRouteKey = routeKey;
    scheduleScan();
  }

  function wrapHistoryMethod(methodName) {
    const original = history[methodName];
    if (typeof original !== "function" || original.__ccsWrapped) return;

    function wrappedHistoryMethod(...args) {
      const result = original.apply(this, args);
      setTimeout(onRouteChange, 0);
      return result;
    }

    wrappedHistoryMethod.__ccsWrapped = true;
    history[methodName] = wrappedHistoryMethod;
  }

  function bindRouteWatcher() {
    wrapHistoryMethod("pushState");
    wrapHistoryMethod("replaceState");
    window.addEventListener("popstate", onRouteChange);
    window.addEventListener("hashchange", onRouteChange);
    setInterval(onRouteChange, 400);
  }

  bindRouteWatcher();
  onRouteChange(true);
})();