NewAPI Token Import to CC Switch

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
})();