Fetch raw topic, log URL/key, and inject chooser by post number
// ==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();
})();