CC Switch Raw Extractor (linux.do)

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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