CC Switch Raw Extractor (linux.do)

Fetch raw topic, log URL/key, and inject chooser by post number

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         CC Switch Raw Extractor (linux.do)
// @namespace    https://linux.do/
// @version      0.15
// @description  Fetch raw topic, log URL/key, and inject chooser by post number
// @author       irisWirisW (https://github.com/irisWirisW)
// @license      AGPL-3.0-or-later
// @homepageURL  https://greasyfork.org/zh-CN/scripts/565604-cc-switch-raw-extractor-linux-do
// @supportURL   https://greasyfork.org/zh-CN/scripts/565604-cc-switch-raw-extractor-linux-do/feedback
// @icon         https://linux.do/uploads/default/optimized/4X/c/c/d/ccd8c210609d498cbeb3d5201d4c259348447562_2_32x32.png
// @match        https://linux.do/*
// @run-at       document-end
// ==/UserScript==

(() => {
  "use strict";

  const DEFAULT_APP = "codex";
  const CODEX_MODEL = "gpt-5.2-codex";
  const BUTTON_CLASS = "ccs-import-bar";
  const DONE_ATTR = "data-ccs-imported";

  const URL_REGEX_GLOBAL = /https?:\/\/[^\s"'<>`]+/g;
  const URL_REGEX_SINGLE = /https?:\/\/[^\s"'<>`]+/i;
  const KEY_VALUE_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,})/i;
  const BASE_LABEL_REGEX = /base[_\s-]?url|baseurl|endpoint/i;
  const KEY_LABEL_REGEX = /api[_\s-]?key|key|token/i;

  const log = (...args) => console.log("[CCS]", ...args);

  let rawProviders = null;
  let rawFetchPromise = null;
  let pickerEl = null;
  let pickerEndpoint = null;
  let pickerKey = null;
  let pickerApp = null;
  let pickerName = null;
  let pickerNameHint = null;
  let pickerMessage = null;
  let pickerConfirm = null;
  let pickerCancel = null;
  let currentTopicKey = null;
  let fetchToken = 0;

  const style = document.createElement("style");
  style.textContent = `
    .${BUTTON_CLASS} {
      margin-top: 8px;
      display: flex;
      gap: 8px;
      align-items: center;
      flex-wrap: wrap;
    }
    .${BUTTON_CLASS} .ccs-btn {
      padding: 6px 12px;
      border-radius: 999px;
      border: 1px solid rgba(0, 0, 0, 0.2);
      background: #f5f5f5;
      color: #111;
      cursor: pointer;
      font-size: 12px;
      font-weight: 600;
    }
    .ccs-picker {
      --ccs-overlay: rgba(0, 0, 0, 0.52);
      --ccs-panel-bg: #ffffff;
      --ccs-panel-border: rgba(15, 23, 42, 0.16);
      --ccs-text: #0f172a;
      --ccs-muted: #475569;
      --ccs-input-bg: #ffffff;
      --ccs-input-border: #cbd5e1;
      --ccs-input-text: #0f172a;
      --ccs-input-placeholder: #64748b;
      --ccs-btn-bg: #f8fafc;
      --ccs-btn-border: #cbd5e1;
      --ccs-btn-text: #0f172a;
      --ccs-btn-primary-bg: #1d4ed8;
      --ccs-btn-primary-border: #1e40af;
      --ccs-btn-primary-text: #ffffff;
      position: fixed;
      inset: 0;
      background: var(--ccs-overlay);
      display: none;
      align-items: center;
      justify-content: center;
      z-index: 9999;
    }
    .ccs-picker.open {
      display: flex;
    }
    .ccs-picker__panel {
      background: var(--ccs-panel-bg);
      border: 1px solid var(--ccs-panel-border);
      border-radius: 10px;
      padding: 16px;
      width: min(520px, 92vw);
      box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
      color: var(--ccs-text);
      font-size: 13px;
    }
    .ccs-picker__title {
      font-size: 14px;
      font-weight: 700;
      margin-bottom: 10px;
    }
    .ccs-picker__message {
      font-size: 12px;
      color: var(--ccs-muted);
      margin-bottom: 10px;
    }
    .ccs-picker__hint {
      margin-top: 6px;
      font-size: 12px;
      color: #b45309;
      line-height: 1.4;
      display: none;
    }
    .ccs-picker label {
      display: block;
      font-weight: 600;
      margin: 8px 0 4px;
    }
    .ccs-picker input {
      width: 100%;
      max-width: none !important;
      min-width: 0;
      display: block;
      box-sizing: border-box;
      padding: 6px 8px;
      border-radius: 6px;
      border: 1px solid var(--ccs-input-border);
      background: var(--ccs-input-bg);
      color: var(--ccs-input-text) !important;
      -webkit-text-fill-color: var(--ccs-input-text);
      font-size: 12px;
    }
    .ccs-picker input::placeholder {
      color: var(--ccs-input-placeholder);
      opacity: 1;
    }
    .ccs-picker select {
      width: 100%;
      max-width: none !important;
      min-width: 0;
      display: block;
      box-sizing: border-box;
      padding: 6px 8px;
      border-radius: 6px;
      border: 1px solid var(--ccs-input-border);
      background: var(--ccs-input-bg);
      color: var(--ccs-input-text) !important;
      -webkit-text-fill-color: var(--ccs-input-text);
      font-size: 12px;
    }
    .ccs-picker select option {
      color: var(--ccs-input-text);
      background: var(--ccs-input-bg);
    }
    .ccs-picker__actions {
      display: flex;
      gap: 8px;
      justify-content: flex-end;
      margin-top: 12px;
    }
    .ccs-picker__actions button {
      padding: 6px 12px;
      border-radius: 999px;
      border: 1px solid var(--ccs-btn-border);
      background: var(--ccs-btn-bg);
      color: var(--ccs-btn-text);
      font-size: 12px;
      cursor: pointer;
      font-weight: 600;
    }
    .ccs-picker__confirm {
      background: var(--ccs-btn-primary-bg) !important;
      border-color: var(--ccs-btn-primary-border) !important;
      color: var(--ccs-btn-primary-text) !important;
    }
    .ccs-picker__confirm:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }
    .ccs-picker__actions button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    html.dark .ccs-picker,
    body.dark .ccs-picker,
    :root[data-theme="dark"] .ccs-picker,
    :root[data-color-scheme="dark"] .ccs-picker {
      --ccs-overlay: rgba(2, 6, 23, 0.74);
      --ccs-panel-bg: #0f172a;
      --ccs-panel-border: rgba(148, 163, 184, 0.34);
      --ccs-text: #e2e8f0;
      --ccs-muted: #94a3b8;
      --ccs-input-bg: #0b1220;
      --ccs-input-border: #334155;
      --ccs-input-text: #f8fafc;
      --ccs-input-placeholder: #94a3b8;
      --ccs-btn-bg: #111827;
      --ccs-btn-border: #334155;
      --ccs-btn-text: #e2e8f0;
      --ccs-btn-primary-bg: #2563eb;
      --ccs-btn-primary-border: #1d4ed8;
      --ccs-btn-primary-text: #eff6ff;
    }
    html.dark .ccs-picker__hint,
    body.dark .ccs-picker__hint,
    :root[data-theme="dark"] .ccs-picker__hint,
    :root[data-color-scheme="dark"] .ccs-picker__hint {
      color: #fbbf24;
    }
    @media (prefers-color-scheme: dark) {
      .ccs-picker {
        --ccs-overlay: rgba(2, 6, 23, 0.74);
        --ccs-panel-bg: #0f172a;
        --ccs-panel-border: rgba(148, 163, 184, 0.34);
        --ccs-text: #e2e8f0;
        --ccs-muted: #94a3b8;
        --ccs-input-bg: #0b1220;
        --ccs-input-border: #334155;
        --ccs-input-text: #f8fafc;
        --ccs-input-placeholder: #94a3b8;
        --ccs-btn-bg: #111827;
        --ccs-btn-border: #334155;
        --ccs-btn-text: #e2e8f0;
        --ccs-btn-primary-bg: #2563eb;
        --ccs-btn-primary-border: #1d4ed8;
        --ccs-btn-primary-text: #eff6ff;
      }
      .ccs-picker__hint {
        color: #fbbf24;
      }
    }
  `;
  document.head.appendChild(style);

  function getTopicIdFromUrl(url) {
    const match =
      url.match(/\/t\/topic\/(\d+)/) ||
      url.match(/\/t\/[^/]+\/(\d+)/);
    return match ? match[1] : null;
  }

  function getTopicId() {
    const canonical = document.querySelector('link[rel="canonical"]');
    const url = canonical?.href || window.location.href;
    return getTopicIdFromUrl(url);
  }

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

  function isTopicPage() {
    return window.location.pathname.startsWith("/t/");
  }

  function getCurrentTopicUrl() {
    const canonical = document.querySelector('link[rel="canonical"]');
    if (canonical && canonical.href) return canonical.href;
    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 (!pickerNameHint) return;
    if (app !== "codex") {
      pickerNameHint.style.display = "none";
      pickerNameHint.textContent = "";
      return;
    }

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

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

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

  function findCookedElement(postNumber) {
    if (postNumber == null) return null;
    return (
      document.querySelector(`[data-post-number="${postNumber}"] .cooked`) ||
      document.querySelector(`#post_${postNumber} .cooked`) ||
      document.querySelector(`article[id="post_${postNumber}"] .cooked`) ||
      document.querySelector(`article[data-post-number="${postNumber}"] .cooked`)
    );
  }

  function extractEndpoint(blockText) {
    const labelMatch = blockText.match(
      /(?:base[_\s-]?url|baseurl|endpoint)\s*[:=:]?\s*(https?:\/\/[^\s"'<>]+)/i,
    );
    if (labelMatch) return labelMatch[1];
    const urlMatch = blockText.match(URL_REGEX_SINGLE);
    return urlMatch ? urlMatch[0] : null;
  }

  function extractApiKey(blockText) {
    const labelMatch = blockText.match(
      /(?:api[_\s-]?key|key|token)\s*[:=:]?\s*(sk-[A-Za-z0-9_-]{16,}|cr_[A-Za-z0-9_-]{16,}|rk-[A-Za-z0-9_-]{16,}|pk-[A-Za-z0-9_-]{16,})/i,
    );
    if (labelMatch) return labelMatch[1];
    const keyMatch = blockText.match(KEY_VALUE_REGEX);
    return keyMatch ? keyMatch[1] || keyMatch[0] : null;
  }

  function parseProvidersFromRaw(rawText) {
    const blocks = rawText.split(/\n-{5,}\n/g);
    const providers = [];
    const byPost = new Map();
    const allEndpoints = [];
    const allKeys = [];

    const pushUnique = (list, value) => {
      if (value && !list.includes(value)) list.push(value);
    };

    for (const block of blocks) {
      const trimmed = block.trim();
      if (!trimmed) continue;
      const lines = trimmed.split("\n");
      const header = lines[0] || "";
      const postMatch = header.match(/#(\d+)/);
      const postNumber = postMatch ? Number(postMatch[1]) : null;
      const content = lines.slice(1).join("\n");

      const urls = extractUrls(content);
      const keys = Array.from(
        new Set(
          (content.match(new RegExp(KEY_VALUE_REGEX.source, "ig")) || []).map(
            (k) => k,
          ),
        ),
      );

      urls.forEach((u) => pushUnique(allEndpoints, u));
      keys.forEach((k) => pushUnique(allKeys, k));

      if (urls.length || keys.length) {
        const info = { postNumber, endpoints: urls, keys };
        providers.push(info);
        byPost.set(postNumber, info);
        log("raw block", { postNumber, urls, keys });
      } else {
        log("raw block no match", { postNumber });
      }
    }

    return { providers, byPost, allEndpoints, allKeys };
  }

  function normalizeUrlCandidate(value) {
    if (!value) return "";
    return value
      .trim()
      .replace(/[`"'<>]+$/g, "")
      .replace(/[,。;!?、)\]}>》」】]+$/g, "");
  }

  function extractUrls(text) {
    const matches = text.match(URL_REGEX_GLOBAL) || [];
    const urls = [];

    matches.forEach((match) => {
      const candidates = match.split(/(?=https?:\/\/)/g);
      candidates.forEach((candidate) => {
        const normalized = normalizeUrlCandidate(candidate);
        if (/^https?:\/\//i.test(normalized)) {
          urls.push(normalized);
        }
      });
    });

    return Array.from(new Set(urls));
  }

  async function fetchRawProviders() {
    if (rawProviders) return rawProviders;
    if (rawFetchPromise) return rawFetchPromise;

    const topicId = getTopicId();
    if (!topicId) {
      log("topic id not found");
      return [];
    }

    const requestKey = currentTopicKey;
    const token = fetchToken;
    const rawUrl = `/raw/${topicId}`;
    log("fetch raw", rawUrl);
    rawFetchPromise = fetch(rawUrl, { credentials: "same-origin" })
      .then((resp) => {
        if (!resp.ok) {
          log("raw fetch failed", resp.status);
          return "";
        }
        return resp.text();
      })
      .then((text) => {
        if (requestKey !== currentTopicKey || token !== fetchToken) {
          return null;
        }
        rawProviders = parseProvidersFromRaw(text);
        log("raw urls", rawProviders.allEndpoints);
        log("raw keys", rawProviders.allKeys);
        return rawProviders;
      })
      .catch((err) => {
        log("raw fetch error", err);
        return [];
      })
      .finally(() => {
        rawFetchPromise = null;
      });

    return rawFetchPromise;
  }

  function injectButton(target, info) {
    if (!target || target.getAttribute(DONE_ATTR) === "1") return;
    target.setAttribute(DONE_ATTR, "1");

    const bar = document.createElement("div");
    bar.className = BUTTON_CLASS;

    const btn = document.createElement("button");
    btn.className = "ccs-btn";
    btn.textContent = "导入到 CC Switch";
    btn.addEventListener("click", () => openPicker(info));

    bar.appendChild(btn);
    target.appendChild(bar);
  }

  function injectProviders(providers) {
    if (!providers || providers.length === 0) return;
    providers.forEach((info) => {
      const target = findCookedElement(info.postNumber);
      if (target) injectButton(target, info);
    });
  }

  function ensurePicker() {
    if (pickerEl) return;
    pickerEl = document.createElement("div");
    pickerEl.className = "ccs-picker";
    pickerEl.innerHTML = `
      <div class="ccs-picker__panel" role="dialog" aria-modal="true">
        <div class="ccs-picker__title">选择导入信息</div>
        <div class="ccs-picker__message"></div>
        <label>导入应用</label>
        <select class="ccs-picker__app">
          <option value="codex">Codex</option>
          <option value="claude">Claude Code</option>
        </select>
        <label>名称</label>
        <input class="ccs-picker__name" />
        <div class="ccs-picker__hint"></div>
        <label>Endpoint URL</label>
        <select class="ccs-picker__endpoint"></select>
        <label>API Key</label>
        <select class="ccs-picker__key"></select>
        <div class="ccs-picker__actions">
          <button class="ccs-picker__confirm">导入到 CC Switch</button>
          <button class="ccs-picker__cancel">取消</button>
        </div>
      </div>
    `;
    document.body.appendChild(pickerEl);
    pickerEndpoint = pickerEl.querySelector(".ccs-picker__endpoint");
    pickerKey = pickerEl.querySelector(".ccs-picker__key");
    pickerApp = pickerEl.querySelector(".ccs-picker__app");
    pickerName = pickerEl.querySelector(".ccs-picker__name");
    pickerNameHint = pickerEl.querySelector(".ccs-picker__hint");
    pickerMessage = pickerEl.querySelector(".ccs-picker__message");
    pickerConfirm = pickerEl.querySelector(".ccs-picker__confirm");
    pickerCancel = pickerEl.querySelector(".ccs-picker__cancel");

    pickerCancel.addEventListener("click", () => closePicker());
    pickerEl.addEventListener("click", (event) => {
      if (event.target === pickerEl) closePicker();
    });

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

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

    bindAutoSelect(pickerName);
  }

  function closePicker() {
    if (!pickerEl) return;
    pickerEl.classList.remove("open");
  }

  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 fillSelect(select, values, preferred) {
    select.innerHTML = "";
    values.forEach((value) => {
      const option = document.createElement("option");
      option.value = value;
      option.textContent = value;
      select.appendChild(option);
    });
    if (preferred && values.includes(preferred)) {
      select.value = preferred;
    }
  }

  function openPicker(info) {
    if (!rawProviders) return;
    ensurePicker();
    const endpoints =
      info.endpoints && info.endpoints.length
        ? info.endpoints
        : rawProviders.allEndpoints;
    const keys =
      info.keys && info.keys.length ? info.keys : rawProviders.allKeys;

    const defaultEndpoint = endpoints[0] || "";
    const defaultKey = keys[keys.length - 1] || keys[0] || "";
    const defaultName = getDefaultNameByApp(DEFAULT_APP, defaultEndpoint);

    fillSelect(pickerEndpoint, endpoints, defaultEndpoint);
    fillSelect(pickerKey, keys, defaultKey);
    if (pickerApp) {
      pickerApp.value = DEFAULT_APP;
    }

    if (pickerName) {
      pickerName.value = defaultName || "custom";
    }

    updateNameHint((pickerApp && pickerApp.value) || DEFAULT_APP, pickerName.value);

    pickerMessage.textContent = `#${info.postNumber} 提取到 ${endpoints.length} 个URL,${keys.length} 个Key`;
    pickerConfirm.disabled = endpoints.length === 0 || keys.length === 0;

    pickerConfirm.onclick = () => {
      const app = (pickerApp && pickerApp.value) || DEFAULT_APP;
      const endpoint = pickerEndpoint.value;
      const apiKey = pickerKey.value;
      const name =
        (pickerName && pickerName.value && pickerName.value.trim()) ||
        getDefaultNameByApp(app, endpoint);
      if (!endpoint || !apiKey) return;

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

      const link = buildDeepLink({ app, endpoint, apiKey, name });
      log("selected", { app, endpoint, apiKey, name });
      window.location.href = link;
      closePicker();
    };

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

  function cleanupInjected() {
    document.querySelectorAll(`.${BUTTON_CLASS}`).forEach((el) => el.remove());
    document
      .querySelectorAll(`[${DONE_ATTR}]`)
      .forEach((el) => el.removeAttribute(DONE_ATTR));
  }

  function handleTopicChange() {
    const nextKey = isTopicPage() ? getTopicKey() : null;
    if (nextKey === currentTopicKey) return;
    currentTopicKey = nextKey;
    fetchToken += 1;
    rawProviders = null;
    rawFetchPromise = null;
    closePicker();
    cleanupInjected();

    if (!nextKey) return;

    fetchRawProviders().then((result) => {
      if (!result || !result.providers) return;
      injectProviders(result.providers);
    });
  }

  function start() {
    handleTopicChange();

    const observer = new MutationObserver(() => {
      if (rawProviders && rawProviders.providers) {
        injectProviders(rawProviders.providers);
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    setInterval(handleTopicChange, 600);
  }

  start();
})();