2194 lines
70 KiB
JavaScript
2194 lines
70 KiB
JavaScript
const THEME_MODE_CACHE_KEY = "bestcf:theme-mode";
|
||
let themeMode = readCachedThemeMode();
|
||
|
||
function readCachedThemeMode() {
|
||
try {
|
||
const mode = window.localStorage.getItem(THEME_MODE_CACHE_KEY);
|
||
return mode === "light" || mode === "dark" ? mode : "auto";
|
||
} catch (error) {
|
||
return "auto";
|
||
}
|
||
}
|
||
|
||
function cacheThemeMode(mode) {
|
||
try {
|
||
window.localStorage.setItem(THEME_MODE_CACHE_KEY, mode);
|
||
} catch (error) {
|
||
// 手动选择在拒绝存储的环境下仅本次会话生效。
|
||
}
|
||
}
|
||
|
||
function systemPrefersDark() {
|
||
return Boolean(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||
}
|
||
|
||
function parentPrefersDark() {
|
||
try {
|
||
const parentDoc = window.parent && window.parent.document;
|
||
return Boolean(parentDoc && (
|
||
parentDoc.documentElement.classList.contains("dark-mode") ||
|
||
parentDoc.body.classList.contains("dark-mode")
|
||
));
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resolvedThemeIsDark() {
|
||
if (themeMode === "dark") return true;
|
||
if (themeMode === "light") return false;
|
||
const parentDark = parentPrefersDark();
|
||
if (parentDark !== null) return parentDark;
|
||
return systemPrefersDark();
|
||
}
|
||
|
||
function applyThemeMode() {
|
||
document.documentElement.classList.toggle("dark-mode", resolvedThemeIsDark());
|
||
updateThemeToggleButton();
|
||
}
|
||
|
||
function syncEmbeddedTheme() {
|
||
// 手动选择过配色时不再跟随父页面变化。
|
||
if (themeMode !== "auto") return;
|
||
applyThemeMode();
|
||
}
|
||
|
||
function updateThemeToggleButton() {
|
||
const button = document.getElementById("themeToggleBtn");
|
||
if (!button) return;
|
||
const isDark = resolvedThemeIsDark();
|
||
button.innerHTML = isDark ? svgSunIcon() : svgMoonIcon();
|
||
const label = isDark ? "切换到日间配色" : "切换到夜间配色";
|
||
button.title = label;
|
||
button.setAttribute("aria-label", label);
|
||
}
|
||
|
||
function toggleThemeMode() {
|
||
setThemeMode(resolvedThemeIsDark() ? "light" : "dark");
|
||
}
|
||
|
||
function setThemeMode(mode) {
|
||
themeMode = mode;
|
||
cacheThemeMode(mode);
|
||
applyThemeMode();
|
||
}
|
||
|
||
function svgSunIcon() {
|
||
return '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="M4.93 4.93l1.41 1.41"></path><path d="M17.66 17.66l1.41 1.41"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="M4.93 19.07l1.41-1.41"></path><path d="M17.66 6.34l1.41-1.41"></path></svg>';
|
||
}
|
||
|
||
function svgMoonIcon() {
|
||
return '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"></path></svg>';
|
||
}
|
||
|
||
applyThemeMode();
|
||
try {
|
||
const parentDoc = window.parent && window.parent.document;
|
||
if (parentDoc) {
|
||
new MutationObserver(syncEmbeddedTheme).observe(parentDoc.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||
new MutationObserver(syncEmbeddedTheme).observe(parentDoc.body, { attributes: true, attributeFilter: ["class"] });
|
||
}
|
||
} catch (error) {
|
||
// Standalone demo mode does not need parent theme sync.
|
||
}
|
||
if (window.matchMedia) {
|
||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||
if (themeMode === "auto") applyThemeMode();
|
||
});
|
||
}
|
||
const TLS_PORTS = [443, 2053, 2083, 2087, 2096, 8443];
|
||
// CIDR/IP区间随机采样的候选总量上限(与原“待测IP数”默认值一致)
|
||
const CANDIDATE_LIMIT = 512;
|
||
const BEST_HOSTS = [
|
||
"bestcf.cmliussss.hidns.vip",
|
||
"ns.psb.kdns.fr"
|
||
];
|
||
const DETECT_ENDPOINTS = {
|
||
// 基础地址即可,请求时会自动拼接 "/ip.json"
|
||
ipv4: [
|
||
"https://ipv4.fxxk.dedyn.io"
|
||
],
|
||
ipv6: [
|
||
"https://ipv6.fxxk.dedyn.io"
|
||
]
|
||
};
|
||
function expandWildcardEndpoints(endpoints) {
|
||
return endpoints.map(ep => ep.replace(/\*\*/g, () => Math.floor(Math.random() * 255 + 1).toString(16).padStart(2, '0')));
|
||
}
|
||
const BEST_HOST_CACHE_KEY = "bestcf:active-best-host";
|
||
let activeBestHost = "";
|
||
|
||
function readCachedBestHost() {
|
||
try {
|
||
return window.localStorage.getItem(BEST_HOST_CACHE_KEY) || "";
|
||
} catch (error) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function cacheBestHost(host) {
|
||
if (!host) return;
|
||
activeBestHost = host;
|
||
try {
|
||
window.localStorage.setItem(BEST_HOST_CACHE_KEY, host);
|
||
} catch (error) {
|
||
// Some embedded contexts can deny storage; in-memory restore still works.
|
||
}
|
||
}
|
||
|
||
function buildDetectEndpoints(family, host) {
|
||
return expandWildcardEndpoints(DETECT_ENDPOINTS[family].map(ep => ep.replace("{{host}}", host)));
|
||
}
|
||
|
||
async function resolveBestHost() {
|
||
if (!activeBestHost) activeBestHost = readCachedBestHost();
|
||
if (activeBestHost && BEST_HOSTS.includes(activeBestHost)) return activeBestHost;
|
||
|
||
for (const host of BEST_HOSTS) {
|
||
try {
|
||
const allEndpoints = [
|
||
...buildDetectEndpoints("ipv4", host),
|
||
...buildDetectEndpoints("ipv6", host)
|
||
];
|
||
await fetchJsonFromFastestEndpoint(allEndpoints, "ip.json", 6500);
|
||
cacheBestHost(host);
|
||
return host;
|
||
} catch (error) {
|
||
// This host failed; try the next one.
|
||
}
|
||
}
|
||
|
||
cacheBestHost(BEST_HOSTS[0]);
|
||
return BEST_HOSTS[0];
|
||
}
|
||
|
||
const CANDIDATE_INPUT_ERROR_MESSAGE = "待选列表为空或格式不符合要求,请输入有效域名(如 dash.cloudflare.com)。";
|
||
const RESULT_FILTER_KEYS = ["type", "country", "colo"];
|
||
const RESULT_FILTER_LABELS = {
|
||
type: "优选类型",
|
||
country: "国家/地区",
|
||
colo: "数据中心"
|
||
};
|
||
|
||
const state = {
|
||
ipv4: null,
|
||
ipv6: null,
|
||
network: { ipv4: null, ipv6: null },
|
||
locations: [],
|
||
locationsByIata: new Map(),
|
||
latencyRun: null,
|
||
networkChecking: false,
|
||
editorResizeObserver: null,
|
||
results: [],
|
||
selectedIds: new Set(),
|
||
nextResultId: 1,
|
||
sort: { key: "latency", dir: "asc" },
|
||
resultSelectionDrag: null,
|
||
suppressResultClick: false,
|
||
resultFilters: {},
|
||
openResultFilterKey: ""
|
||
};
|
||
|
||
const $ = (selector) => document.querySelector(selector);
|
||
const els = {
|
||
ipv4Value: $("#ipv4Value"),
|
||
ipv4Card: $("#ipv4Card"),
|
||
ipv4Tag: $("#ipv4Tag"),
|
||
ipv6Value: $("#ipv6Value"),
|
||
ipv6Card: $("#ipv6Card"),
|
||
ipv6Tag: $("#ipv6Tag"),
|
||
domainImportBtn: $("#domainImportBtn"),
|
||
ipInput: $("#ipInput"),
|
||
lineCount: $("#lineCount"),
|
||
lineGutter: $("#lineGutter"),
|
||
lineNumbers: $("#lineNumbers"),
|
||
lineShades: $("#lineShades"),
|
||
clearBtn: $("#clearBtn"),
|
||
timeoutInput: $("#timeoutInput"),
|
||
threadsInput: $("#threadsInput"),
|
||
portSelect: $("#portSelect"),
|
||
latencyBtn: $("#latencyBtn"),
|
||
themeToggleBtn: $("#themeToggleBtn"),
|
||
latencyStatus: $("#latencyStatus"),
|
||
latencyProgress: $("#latencyProgress"),
|
||
progressText: $("#progressText"),
|
||
resultBody: $("#resultBody"),
|
||
resultCount: $("#resultCount"),
|
||
selectAllHeader: $("#selectAllHeader"),
|
||
invertSelectBtn: $("#invertSelectBtn"),
|
||
clearSelectBtn: $("#clearSelectBtn"),
|
||
copyResultsBtn: $("#copyResultsBtn"),
|
||
exportTxtBtn: $("#exportTxtBtn"),
|
||
exportCsvBtn: $("#exportCsvBtn"),
|
||
selectionStatus: $("#selectionStatus"),
|
||
networkWarningOverlay: $("#networkWarningOverlay"),
|
||
networkWarningModal: $("#networkWarningModal"),
|
||
networkWarningHeading: $("#networkWarningHeading"),
|
||
networkWarningTitle: $("#networkWarningTitle"),
|
||
networkWarningMessage: $("#networkWarningMessage"),
|
||
networkWarningSummary: $("#networkWarningSummary"),
|
||
networkWarningDetails: $("#networkWarningDetails"),
|
||
networkWarningJson: $("#networkWarningJson"),
|
||
networkWarningLocalBtn: $("#networkWarningLocalBtn")
|
||
};
|
||
|
||
// ==================== 开屏加载动画 ====================
|
||
|
||
const BOOT_LOADING_MIN_VISIBLE = 800;
|
||
const bootLoading = {
|
||
active: true,
|
||
startedAt: performance.now(),
|
||
timers: []
|
||
};
|
||
|
||
function scheduleBootLoadingProgress() {
|
||
const bar = document.getElementById("bootLoadingBar");
|
||
if (!bar) return;
|
||
[
|
||
{ delay: 60, width: "30%" },
|
||
{ delay: 700, width: "60%" },
|
||
{ delay: 1600, width: "80%" },
|
||
{ delay: 2600, width: "90%" },
|
||
{ delay: 3000, width: "98%", crawl: true }
|
||
].forEach(({ delay, width, crawl }) => {
|
||
bootLoading.timers.push(window.setTimeout(() => {
|
||
if (crawl) bar.classList.add("is-crawling");
|
||
bar.style.width = width;
|
||
}, delay));
|
||
});
|
||
}
|
||
|
||
function finishBootLoading() {
|
||
// 冲刺 100%(先退出慢速蠕动)→ 分层淡出(卡片先行、背景随后)→ resolve 后由调用方弹警告/放行
|
||
const overlay = document.getElementById("bootLoading");
|
||
const bar = document.getElementById("bootLoadingBar");
|
||
bootLoading.timers.forEach((timer) => window.clearTimeout(timer));
|
||
bootLoading.timers = [];
|
||
const wait = Math.max(0, BOOT_LOADING_MIN_VISIBLE - (performance.now() - bootLoading.startedAt));
|
||
return new Promise((resolve) => {
|
||
window.setTimeout(() => {
|
||
if (bar) {
|
||
bar.classList.remove("is-crawling");
|
||
bar.style.width = "100%";
|
||
}
|
||
window.setTimeout(() => {
|
||
if (overlay) overlay.classList.add("is-hidden");
|
||
bootLoading.active = false;
|
||
window.setTimeout(resolve, 580);
|
||
}, 320);
|
||
}, wait);
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
bindEvents();
|
||
updateEditorMeta();
|
||
scheduleBootLoadingProgress();
|
||
detectNetwork();
|
||
if ("ResizeObserver" in window) {
|
||
state.editorResizeObserver = new ResizeObserver(updateEditorMeta);
|
||
state.editorResizeObserver.observe(els.ipInput);
|
||
}
|
||
});
|
||
|
||
function bindEvents() {
|
||
els.themeToggleBtn.addEventListener("click", toggleThemeMode);
|
||
els.domainImportBtn.addEventListener("click", importCfDomains);
|
||
els.ipInput.addEventListener("input", updateEditorMeta);
|
||
els.ipInput.addEventListener("scroll", syncEditorScroll);
|
||
els.clearBtn.addEventListener("click", () => {
|
||
els.ipInput.value = "";
|
||
updateEditorMeta();
|
||
});
|
||
[els.timeoutInput, els.threadsInput].forEach((input) => {
|
||
input.addEventListener("change", () => clampNumberInput(input));
|
||
});
|
||
els.latencyBtn.addEventListener("click", async () => {
|
||
if (state.latencyRun) {
|
||
stopLatencyRun();
|
||
return;
|
||
}
|
||
if (state.networkChecking) return;
|
||
if (!await ensureCnNetworkBeforeAction({
|
||
button: els.latencyBtn,
|
||
statusEl: els.latencyStatus,
|
||
idleHtml: `${svgPulseIcon()}优选延迟`
|
||
})) return;
|
||
startLatencyRun();
|
||
});
|
||
els.resultBody.addEventListener("click", (event) => {
|
||
if (state.suppressResultClick) {
|
||
state.suppressResultClick = false;
|
||
return;
|
||
}
|
||
|
||
const copyAddressBtn = event.target.closest("[data-copy-address]");
|
||
if (copyAddressBtn) {
|
||
event.stopPropagation();
|
||
copyResultAddress(copyAddressBtn.dataset.copyAddress || "", copyAddressBtn);
|
||
return;
|
||
}
|
||
|
||
const checkbox = event.target.closest("[data-select-id]");
|
||
if (checkbox) {
|
||
setResultSelected(checkbox.dataset.selectId, checkbox.checked);
|
||
return;
|
||
}
|
||
|
||
const row = event.target.closest("[data-result-id]");
|
||
if (!row) return;
|
||
toggleResultSelected(row.dataset.resultId);
|
||
});
|
||
els.resultBody.addEventListener("mousedown", startResultSelectionDrag);
|
||
els.resultBody.addEventListener("mouseover", continueResultSelectionDrag);
|
||
document.addEventListener("mouseup", finishResultSelectionDrag);
|
||
window.addEventListener("blur", finishResultSelectionDrag);
|
||
els.selectAllHeader.addEventListener("change", () => {
|
||
if (els.selectAllHeader.checked) {
|
||
selectAllResults();
|
||
} else {
|
||
clearSelectedResults();
|
||
}
|
||
});
|
||
els.invertSelectBtn.addEventListener("click", invertSelectedResults);
|
||
els.clearSelectBtn.addEventListener("click", resetResultSelection);
|
||
els.copyResultsBtn.addEventListener("click", copySelectedResultsToClipboard);
|
||
els.exportTxtBtn.addEventListener("click", exportSelectedResultsTxt);
|
||
els.exportCsvBtn.addEventListener("click", exportOptimizeResultsCsv);
|
||
els.networkWarningLocalBtn.addEventListener("click", openLocalOptimizeFromWarning);
|
||
document.querySelectorAll("[data-sort]").forEach((button) => {
|
||
button.addEventListener("click", () => setResultSort(button.dataset.sort));
|
||
});
|
||
document.querySelectorAll("[data-filter-key]").forEach((label) => {
|
||
label.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
toggleResultFilterMenu(label.dataset.filterKey, label);
|
||
});
|
||
label.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
toggleResultFilterMenu(label.dataset.filterKey, label);
|
||
});
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
const menu = event.target.closest(".result-filter-menu");
|
||
const label = event.target.closest("[data-filter-key]");
|
||
if (!menu && !label) closeResultFilterMenu();
|
||
});
|
||
window.addEventListener("resize", closeResultFilterMenu);
|
||
window.addEventListener("scroll", handleResultFilterScroll, true);
|
||
}
|
||
|
||
function handleResultFilterScroll(event) {
|
||
const menu = document.querySelector(".result-filter-menu");
|
||
if (menu && event.target instanceof Node && menu.contains(event.target)) return;
|
||
closeResultFilterMenu();
|
||
}
|
||
|
||
function svgStopIcon() {
|
||
return '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="1"></rect></svg>';
|
||
}
|
||
|
||
function svgPulseIcon() {
|
||
return '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h4l3-7 3 14 3-7h1"></path></svg>';
|
||
}
|
||
|
||
async function detectNetwork() {
|
||
return refreshCnNetwork({ resetResults: true, stopRuns: true });
|
||
}
|
||
|
||
async function ensureCnNetworkBeforeAction({ button, statusEl, idleHtml }) {
|
||
if (state.networkChecking) return false;
|
||
if (button) button.innerHTML = `${svgPulseIcon()}网络验证`;
|
||
if (statusEl) statusEl.textContent = "正在检测CN网络";
|
||
const allowed = await refreshCnNetwork({ resetResults: false, stopRuns: false });
|
||
if (!allowed) {
|
||
if (button && idleHtml) button.innerHTML = idleHtml;
|
||
if (statusEl) statusEl.textContent = "检测未通过,请关闭代理后重试";
|
||
return false;
|
||
}
|
||
if (statusEl) statusEl.textContent = "CN网络检测通过";
|
||
return true;
|
||
}
|
||
|
||
async function refreshCnNetwork({ resetResults = false, stopRuns = false } = {}) {
|
||
if (state.networkChecking) return availableFamilies().length > 0;
|
||
state.networkChecking = true;
|
||
|
||
if (stopRuns) {
|
||
if (state.latencyRun) stopLatencyRun();
|
||
}
|
||
|
||
state.ipv4 = null;
|
||
state.ipv6 = null;
|
||
state.network = { ipv4: null, ipv6: null };
|
||
state.locations = [];
|
||
state.locationsByIata = new Map();
|
||
if (resetResults) {
|
||
state.results = [];
|
||
state.selectedIds.clear();
|
||
state.nextResultId = 1;
|
||
resetResultFilters();
|
||
renderResults();
|
||
setProgress(0, 0);
|
||
}
|
||
|
||
renderNetworkCard("ipv4", { status: "loading" });
|
||
renderNetworkCard("ipv6", { status: "loading" });
|
||
updateActionAvailability();
|
||
|
||
try {
|
||
const bestHost = await resolveBestHost();
|
||
const checks = await Promise.allSettled([
|
||
detectStack("ipv4", bestHost),
|
||
detectStack("ipv6", bestHost)
|
||
]);
|
||
|
||
checks.forEach((result, index) => {
|
||
const family = index === 0 ? "ipv4" : "ipv6";
|
||
if (result.status === "fulfilled") {
|
||
state.network[family] = result.value;
|
||
if (result.value.allowed) {
|
||
if (result.value.ipType === "ipv4") state.ipv4 = result.value.ip;
|
||
if (result.value.ipType === "ipv6") state.ipv6 = result.value.ip;
|
||
}
|
||
} else {
|
||
state.network[family] = {
|
||
status: "failed",
|
||
error: result.reason && result.reason.message ? result.reason.message : "不具备优选条件"
|
||
};
|
||
}
|
||
});
|
||
|
||
renderNetworkCard("ipv4", state.network.ipv4);
|
||
renderNetworkCard("ipv6", state.network.ipv6);
|
||
await loadLocations();
|
||
updateServiceStatus();
|
||
|
||
const allowed = availableFamilies().length > 0;
|
||
if (bootLoading.active) {
|
||
await finishBootLoading();
|
||
}
|
||
if (!allowed) showNetworkWarning();
|
||
return allowed;
|
||
} catch (error) {
|
||
if (bootLoading.active) await finishBootLoading();
|
||
throw error;
|
||
} finally {
|
||
state.networkChecking = false;
|
||
updateActionAvailability();
|
||
}
|
||
}
|
||
|
||
function showNetworkWarning() {
|
||
renderNetworkWarning(buildNetworkWarningContext());
|
||
els.networkWarningOverlay.classList.add("is-visible");
|
||
els.networkWarningLocalBtn.focus();
|
||
}
|
||
|
||
function buildNetworkWarningContext() {
|
||
const entries = ["ipv4", "ipv6"].map((family) => ({
|
||
family,
|
||
info: state.network[family]
|
||
}));
|
||
const completed = entries.filter(({ info }) => info && info.status === "done");
|
||
const nonCn = completed.filter(({ info }) => !info.allowed);
|
||
|
||
if (nonCn.length) {
|
||
return {
|
||
kind: "non-cn",
|
||
heading: "红牌警告",
|
||
title: "检测到当前网络出口不是 CN 直连",
|
||
message: "请确认浏览器当前网络是否经过了 代理、VPN 或 境外网络,\n在线优选仅有 CN 直连网络环境才可以实现!\n当前网络环境无法进行在线优选,可尝试使用「本地优选」。",
|
||
rows: nonCn,
|
||
details: completed
|
||
};
|
||
}
|
||
|
||
return {
|
||
kind: "request-failed",
|
||
heading: "黄牌警告",
|
||
title: "优选域名请求失败",
|
||
message: "当前网络环境无法进行在线优选,但可尝试使用「本地优选」。\n请自行检查网络环境,确保优选域名可访问。",
|
||
rows: entries,
|
||
details: completed
|
||
};
|
||
}
|
||
|
||
function renderNetworkWarning(context) {
|
||
els.networkWarningModal.classList.toggle("is-request-failed", context.kind === "request-failed");
|
||
els.networkWarningHeading.textContent = context.heading;
|
||
els.networkWarningTitle.textContent = context.title;
|
||
els.networkWarningMessage.textContent = context.message;
|
||
els.networkWarningSummary.innerHTML = context.rows.map(({ family, info }) => {
|
||
if (context.kind === "non-cn") return renderNetworkWarningNonCnRow(family, info);
|
||
return renderNetworkWarningFailedRow(family, info);
|
||
}).join("");
|
||
|
||
const detailPayload = context.details
|
||
.filter(({ info }) => info && info.raw)
|
||
.map(({ family, info }) => ({
|
||
family,
|
||
endpoint: info.endpoint || "",
|
||
response: info.raw
|
||
}));
|
||
if (detailPayload.length) {
|
||
els.networkWarningJson.textContent = JSON.stringify(detailPayload, null, 2);
|
||
els.networkWarningDetails.hidden = false;
|
||
els.networkWarningDetails.open = false;
|
||
} else {
|
||
els.networkWarningJson.textContent = "";
|
||
els.networkWarningDetails.hidden = true;
|
||
els.networkWarningDetails.open = false;
|
||
}
|
||
}
|
||
|
||
function renderNetworkWarningNonCnRow(family, info) {
|
||
const ip = info && info.ip ? info.ip : "未获取";
|
||
const country = info && info.country ? String(info.country).toUpperCase() : "未知";
|
||
return `<div class="network-warning-row">
|
||
<span class="network-warning-family">${escapeHtml(family.toUpperCase())}</span>
|
||
<span class="network-warning-ip">${escapeHtml(ip)}</span>
|
||
<span class="network-warning-country">地区代码 ${escapeHtml(country)}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function renderNetworkWarningFailedRow(family, info) {
|
||
const error = info && info.error ? info.error : "请求失败";
|
||
return `<div class="network-warning-row">
|
||
<span class="network-warning-family">${escapeHtml(family.toUpperCase())}</span>
|
||
<span class="network-warning-ip">${escapeHtml(error)}</span>
|
||
<span class="network-warning-country">失败</span>
|
||
</div>`;
|
||
}
|
||
|
||
function openLocalOptimizeFromWarning() {
|
||
// 关闭当前警告弹窗
|
||
els.networkWarningOverlay.classList.remove("is-visible");
|
||
|
||
// 嵌入管理面板时优先交给父页面处理(关闭在线优选全屏并打开其本地优选目录)
|
||
if (window.parent && window.parent !== window) {
|
||
try {
|
||
if (typeof window.parent.switchToLocalOptimizeFromOnline === "function") {
|
||
window.parent.switchToLocalOptimizeFromOnline();
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
// 跨域嵌入时无法直接访问父页面函数,改用本页内置的本地优选目录。
|
||
}
|
||
}
|
||
|
||
// 独立运行(或父页面无本地优选能力)时,打开本页内置的本地优选工具目录
|
||
openLocalOptimizeModal();
|
||
}
|
||
|
||
// ==================== 本地优选 - CF 优选工具目录 ====================
|
||
|
||
const CF_TOOLS_RAW_URL = "https://raw.githubusercontent.com/cmliu/cmliu/refs/heads/main/json/best-cf-tools.json";
|
||
const RAW_GITHUB_PREFIX = "https://raw.githubusercontent.com";
|
||
const RAW_MIRROR_DOMAINS = [
|
||
"https://github.090227.xyz/raw.githubusercontent.com",
|
||
"https://github.cmliussss.com/raw.githubusercontent.com",
|
||
"https://github.cmliussss.net/raw.githubusercontent.com"
|
||
];
|
||
let localOptimizeProjects = [];
|
||
let localUiFilter = "all";
|
||
|
||
// GitHub Star 图标(octicon star)
|
||
const STAR_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true" focusable="false"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"></path></svg>';
|
||
|
||
// 打开本地优选工具目录弹窗(并发拉取工具清单,RAW 失败自动切换镜像)
|
||
async function openLocalOptimizeModal() {
|
||
const modal = document.getElementById("localOptimizeModal");
|
||
const list = document.getElementById("localOptimizeToolList");
|
||
if (!modal || !list) return;
|
||
|
||
modal.classList.add("show");
|
||
|
||
// 已加载过则直接显示,避免重复请求
|
||
if (list.dataset.loaded === "true") return;
|
||
|
||
list.innerHTML = '<div class="local-tools-loading">⏳ 正在拉取 CF 优选工具目录…</div>';
|
||
|
||
try {
|
||
const jsonText = await fetchWithAutoMirror(`${CF_TOOLS_RAW_URL}?_t=${Date.now()}`, "CF优选工具目录");
|
||
const data = JSON.parse(jsonText);
|
||
const projects = Array.isArray(data && data.projects) ? data.projects : [];
|
||
renderLocalOptimizeTools(list, projects);
|
||
list.dataset.loaded = "true";
|
||
} catch (error) {
|
||
console.error("[请求RAW] CF优选工具目录拉取失败:", error);
|
||
list.innerHTML = `
|
||
<div class="local-tools-error">
|
||
❌ 工具目录拉取失败,请检查网络后重试<br>
|
||
<button type="button" class="local-tools-retry" onclick="openLocalOptimizeModal()">重试</button>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// 渲染本地优选工具卡片
|
||
function renderLocalOptimizeTools(list, projects) {
|
||
localOptimizeProjects = projects;
|
||
|
||
// 同步类型筛选按钮高亮态,保证弹窗重开时高亮一致
|
||
const filterButtons = document.querySelectorAll("#localUiFilter .local-ui-filter-btn");
|
||
filterButtons.forEach((button) => {
|
||
button.classList.toggle("is-active", button.dataset.ui === localUiFilter);
|
||
});
|
||
|
||
if (!projects.length) {
|
||
list.innerHTML = '<div class="local-tools-error">😕 暂无可用的本地优选工具</div>';
|
||
return;
|
||
}
|
||
|
||
const uiLabels = {
|
||
webui: { emoji: "🌐", label: "网页界面" },
|
||
gui: { emoji: "🖥️", label: "图形界面" },
|
||
cli: { emoji: "⌨️", label: "命令行CLI" }
|
||
};
|
||
|
||
// 按 stars 数由高到低排序,携带原始索引(openLocalToolGitHub 依赖 localOptimizeProjects 索引)
|
||
const ranked = projects
|
||
.map((project, index) => ({ project, index }))
|
||
.sort((a, b) => (Number(b.project.stars) || 0) - (Number(a.project.stars) || 0));
|
||
|
||
// 按类型筛选
|
||
const filtered = ranked.filter(({ project }) => {
|
||
if (localUiFilter === "all") return true;
|
||
const uiValues = Array.isArray(project.ui)
|
||
? project.ui.map((value) => String(value).trim().toLowerCase())
|
||
: [];
|
||
return uiValues.includes(localUiFilter);
|
||
});
|
||
|
||
if (!filtered.length) {
|
||
const currentLabel = (uiLabels[localUiFilter] && uiLabels[localUiFilter].label) || "该";
|
||
list.innerHTML = `<div class="local-tools-error">😕 没有「${currentLabel}」类型的工具</div>`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = filtered.map(({ project, index }, i) => {
|
||
const name = escapeHtml(String(project.name || "未命名工具"));
|
||
const author = project.author ? escapeHtml(String(project.author)) : "";
|
||
const description = project.description ? escapeHtml(String(project.description)) : "";
|
||
const platforms = Array.isArray(project.platforms) ? project.platforms : [];
|
||
const platformBadges = platforms
|
||
.map((platform) => `<span class="local-tool-platform">${escapeHtml(String(platform))}</span>`)
|
||
.join("");
|
||
const uiList = Array.isArray(project.ui) ? project.ui : [];
|
||
const uiBadges = uiList
|
||
.filter((value) => value !== null && value !== undefined && String(value).trim() !== "")
|
||
.map((value) => {
|
||
const key = String(value).trim().toLowerCase();
|
||
const meta = uiLabels[key];
|
||
if (!meta) {
|
||
return `<span class="local-tool-ui">${escapeHtml(String(value))}</span>`;
|
||
}
|
||
return `<span class="local-tool-ui local-tool-ui-${key}">${meta.emoji} ${meta.label}</span>`;
|
||
})
|
||
.join("");
|
||
|
||
// GitHub Stars 徽章(无有效数据时不显示)
|
||
const starsNum = Number(project.stars);
|
||
const starsBadge = Number.isFinite(starsNum) && starsNum > 0
|
||
? `<span class="local-tool-stars" title="GitHub Stars">${STAR_SVG}${starsNum.toLocaleString("en-US")}</span>`
|
||
: "";
|
||
|
||
return `
|
||
<div class="local-tool-card" style="animation-delay: ${Math.min(i, 8) * 40}ms">
|
||
<div class="local-tool-head">
|
||
<span class="local-tool-name">${name}</span>
|
||
${author ? `<span class="local-tool-author">@${author}</span>` : ""}
|
||
${starsBadge}
|
||
</div>
|
||
<div class="local-tool-desc">${description}</div>
|
||
${(uiBadges || platformBadges) ? `<div class="local-tool-platforms">${uiBadges}${platformBadges}</div>` : ""}
|
||
<div class="local-tool-actions">
|
||
<button type="button" class="local-tool-github" onclick="openLocalToolGitHub(${index})">
|
||
🔗 前往 GitHub
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
// 设置本地优选工具类型筛选(白名单:all/webui/gui/cli)
|
||
function setLocalUiFilter(type) {
|
||
if (type !== "all" && type !== "webui" && type !== "gui" && type !== "cli") return;
|
||
localUiFilter = type;
|
||
|
||
const filterButtons = document.querySelectorAll("#localUiFilter .local-ui-filter-btn");
|
||
filterButtons.forEach((button) => {
|
||
button.classList.toggle("is-active", button.dataset.ui === localUiFilter);
|
||
});
|
||
|
||
const list = document.getElementById("localOptimizeToolList");
|
||
if (list) renderLocalOptimizeTools(list, localOptimizeProjects);
|
||
}
|
||
|
||
// 在新窗口打开所选工具的 GitHub 仓库
|
||
function openLocalToolGitHub(index) {
|
||
const project = localOptimizeProjects[index];
|
||
if (!project || !project.url) {
|
||
showOptimizeToast("未找到该工具的 GitHub 地址");
|
||
return;
|
||
}
|
||
window.open(project.url, "_blank", "noopener");
|
||
}
|
||
|
||
// 并发拉取 RAW 资源,原始链接与镜像谁先返回用谁(单个候选 10 秒超时)
|
||
async function fetchWithAutoMirror(originalUrl, description = "资源") {
|
||
const candidates = [{ url: originalUrl, label: "原始链接" }];
|
||
if (originalUrl.startsWith(RAW_GITHUB_PREFIX)) {
|
||
RAW_MIRROR_DOMAINS.forEach((mirrorDomain, index) => {
|
||
candidates.push({
|
||
url: originalUrl.replace(RAW_GITHUB_PREFIX, mirrorDomain),
|
||
label: `备用镜像 ${index + 1}`
|
||
});
|
||
});
|
||
}
|
||
|
||
const controllers = candidates.map(() => new AbortController());
|
||
let hasWinner = false;
|
||
|
||
const requests = candidates.map((candidate, index) => (async () => {
|
||
const controller = controllers[index];
|
||
const timer = window.setTimeout(() => controller.abort(), 10000);
|
||
try {
|
||
const response = await fetch(candidate.url, {
|
||
method: "GET",
|
||
cache: "no-store",
|
||
signal: controller.signal
|
||
});
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const text = await response.text();
|
||
return { ...candidate, index, text };
|
||
} catch (error) {
|
||
if (!(hasWinner && error.name === "AbortError")) {
|
||
console.warn(`[请求RAW] ❌ ${candidate.label}拉取失败: ${error.message}`);
|
||
}
|
||
throw error;
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
}
|
||
})());
|
||
|
||
try {
|
||
const winner = await Promise.any(requests);
|
||
hasWinner = true;
|
||
controllers.forEach((controller, index) => {
|
||
if (index !== winner.index) controller.abort();
|
||
});
|
||
return winner.text;
|
||
} catch (error) {
|
||
const mirrorCount = Math.max(candidates.length - 1, 0);
|
||
throw new Error(
|
||
mirrorCount > 0
|
||
? `${description}拉取失败,已尝试原始链接和全部${mirrorCount}个备用镜像`
|
||
: `${description}拉取失败,原始链接不可用`
|
||
);
|
||
}
|
||
}
|
||
|
||
async function detectStack(family, host) {
|
||
const { endpoint, data } = await fetchJsonFromFastestEndpoint(buildDetectEndpoints(family, host), "ip.json", 6500);
|
||
const allowed = data.country === "CN";
|
||
return {
|
||
status: "done",
|
||
allowed,
|
||
endpoint,
|
||
ip: data.ip || "",
|
||
ipType: data.ipType || family,
|
||
country: data.country || "未知",
|
||
cnIspCode: data.cnIspCode || "",
|
||
preferredType: preferredTypeFromCnIspCode(data.cnIspCode),
|
||
colo: data.colo || "",
|
||
asn: data.asn || "",
|
||
raw: data
|
||
};
|
||
}
|
||
|
||
async function loadLocations() {
|
||
const endpoints = [];
|
||
if (state.ipv4 && state.network.ipv4 && state.network.ipv4.endpoint) endpoints.push(state.network.ipv4.endpoint);
|
||
if (state.ipv6 && state.network.ipv6 && state.network.ipv6.endpoint) endpoints.push(state.network.ipv6.endpoint);
|
||
|
||
try {
|
||
const { data: locations } = await fetchJsonFromFastestEndpoint([...new Set(endpoints)], "locations", 8000);
|
||
if (!Array.isArray(locations)) throw new Error("locations 响应格式无效");
|
||
state.locations = locations;
|
||
state.locationsByIata = new Map(
|
||
locations
|
||
.filter((item) => item && item.iata)
|
||
.map((item) => [String(item.iata).toUpperCase(), item])
|
||
);
|
||
} catch (error) {
|
||
state.locations = [];
|
||
state.locationsByIata = new Map();
|
||
}
|
||
}
|
||
|
||
function renderNetworkCard(family, info) {
|
||
const card = family === "ipv4" ? els.ipv4Card : els.ipv6Card;
|
||
const value = family === "ipv4" ? els.ipv4Value : els.ipv6Value;
|
||
const tag = family === "ipv4" ? els.ipv4Tag : els.ipv6Tag;
|
||
const note = card.querySelector(".network-note");
|
||
card.classList.remove("is-loading", "is-ok", "is-failed", "is-unavailable");
|
||
|
||
if (!info || info.status === "loading") {
|
||
card.classList.remove("is-hidden");
|
||
card.classList.add("is-loading");
|
||
value.textContent = "检测中";
|
||
if (note) note.textContent = "识别线路";
|
||
tag.className = "tag idle";
|
||
tag.textContent = "等待";
|
||
return;
|
||
}
|
||
|
||
if (info.status === "failed") {
|
||
card.classList.remove("is-hidden");
|
||
card.classList.add("is-failed");
|
||
value.textContent = "不具备优选条件";
|
||
if (note) note.textContent = "线路不可用";
|
||
tag.className = "tag bad";
|
||
tag.textContent = "未就绪";
|
||
return;
|
||
}
|
||
|
||
if (!info.allowed) {
|
||
card.classList.add("is-hidden");
|
||
card.classList.add("is-unavailable");
|
||
value.textContent = "不可用";
|
||
if (note) note.textContent = "非CN网络";
|
||
tag.className = "tag bad";
|
||
tag.textContent = info.country || "非CN";
|
||
return;
|
||
}
|
||
|
||
card.classList.remove("is-hidden");
|
||
card.classList.add("is-ok");
|
||
value.textContent = maskIpForDisplay(info.ip, info.ipType || family);
|
||
if (note) note.textContent = info.preferredType || preferredTypeFromCnIspCode(info.cnIspCode);
|
||
tag.className = "tag ok";
|
||
tag.textContent = `${info.country || "未知"} · ${String(info.cnIspCode || "unknown").toUpperCase()}`;
|
||
}
|
||
|
||
function maskIpForDisplay(ip, family) {
|
||
if (!ip) return "未获取";
|
||
if (family === "ipv6") return maskIpv6ForDisplay(ip);
|
||
return maskIpv4ForDisplay(ip);
|
||
}
|
||
|
||
function maskIpv4ForDisplay(ip) {
|
||
const parts = ip.split(".");
|
||
if (parts.length !== 4) return "已脱敏";
|
||
return `${parts[0]}.***.**.${parts[3]}`;
|
||
}
|
||
|
||
function maskIpv6ForDisplay(ip) {
|
||
const normalized = normalizeIpv6(ip);
|
||
if (!normalized) return "已脱敏";
|
||
const groups = expandIpv6Groups(normalized);
|
||
if (!groups) return "已脱敏";
|
||
return `${groups[0]}:****:****:${groups[7]}`;
|
||
}
|
||
|
||
function updateServiceStatus() {
|
||
const available = availableFamilies();
|
||
if (available.length === 0) {
|
||
els.latencyStatus.textContent = "网络不可用";
|
||
return;
|
||
}
|
||
|
||
els.latencyStatus.textContent = "等待优选";
|
||
}
|
||
|
||
function availableFamilies() {
|
||
const list = [];
|
||
if (state.ipv4) list.push("ipv4");
|
||
if (state.ipv6) list.push("ipv6");
|
||
return list;
|
||
}
|
||
|
||
function updateActionAvailability() {
|
||
const hasService = availableFamilies().length > 0;
|
||
const latencyActive = Boolean(state.latencyRun);
|
||
els.latencyBtn.disabled = state.networkChecking || (!hasService && !latencyActive);
|
||
}
|
||
|
||
async function importCfDomains() {
|
||
els.domainImportBtn.disabled = true;
|
||
const original = els.domainImportBtn.innerHTML;
|
||
els.domainImportBtn.textContent = "导入中";
|
||
try {
|
||
const response = await fetchWithTimeout(`/cf_domains.txt?_t=${Date.now()}`, { method: "GET", timeout: 20000 });
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const text = await response.text();
|
||
const lines = text.split(/\r\n|\r|\n/).map(cleanInputLine).filter(Boolean);
|
||
if (!lines.length) throw new Error("cf_domains.txt 内容为空");
|
||
els.ipInput.value = lines.join("\n");
|
||
updateEditorMeta();
|
||
els.latencyStatus.textContent = `已导入 CF域名(${lines.length} 行)`;
|
||
} catch (error) {
|
||
els.latencyStatus.textContent = `导入失败:${friendlyError(error)}`;
|
||
} finally {
|
||
els.domainImportBtn.innerHTML = original;
|
||
els.domainImportBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function updateEditorMeta() {
|
||
const value = els.ipInput.value;
|
||
const lines = value.length ? value.split(/\r\n|\r|\n/).length : 0;
|
||
els.lineCount.textContent = `${lines} 行`;
|
||
const displayLines = Math.max(1, lines);
|
||
els.lineNumbers.textContent = Array.from({ length: displayLines }, (_, index) => index + 1).join("\n");
|
||
const visibleLines = Math.ceil((els.ipInput.clientHeight || 360) / 22) + 2;
|
||
const shadeLines = Math.max(displayLines, visibleLines);
|
||
els.lineShades.innerHTML = Array.from({ length: shadeLines }, () => '<div class="shade-line"></div>').join("");
|
||
syncEditorScroll();
|
||
}
|
||
|
||
function syncEditorScroll() {
|
||
const scrollTop = els.ipInput.scrollTop;
|
||
const offset = `translate3d(0, ${-scrollTop}px, 0)`;
|
||
els.lineNumbers.style.transform = offset;
|
||
els.lineShades.style.transform = offset;
|
||
}
|
||
|
||
async function startLatencyRun() {
|
||
const settings = readSettings();
|
||
let candidates;
|
||
try {
|
||
candidates = prepareCandidates(els.ipInput.value, settings.port);
|
||
} catch (error) {
|
||
const message = friendlyError(error);
|
||
els.latencyStatus.textContent = message;
|
||
els.latencyBtn.innerHTML = `${svgPulseIcon()}优选延迟`;
|
||
showOptimizeToast(message);
|
||
return;
|
||
}
|
||
|
||
els.ipInput.value = candidates.join("\n");
|
||
updateEditorMeta();
|
||
state.results = [];
|
||
state.selectedIds.clear();
|
||
state.nextResultId = 1;
|
||
state.sort = { key: "latency", dir: "asc" };
|
||
resetResultFilters();
|
||
renderResults();
|
||
|
||
const run = {
|
||
stopped: false,
|
||
controllers: new Set(),
|
||
completed: 0,
|
||
total: candidates.length
|
||
};
|
||
state.latencyRun = run;
|
||
els.latencyBtn.classList.add("danger");
|
||
els.latencyBtn.innerHTML = `${svgStopIcon()}停止优选`;
|
||
els.latencyStatus.textContent = `正在优选 ${candidates.length} 个地址`;
|
||
setProgress(0, candidates.length);
|
||
updateActionAvailability();
|
||
|
||
await runPool(candidates, settings.threads, async (address) => {
|
||
if (run.stopped) return;
|
||
try {
|
||
const result = await testLatency(address, settings.timeout, run);
|
||
if (result && !run.stopped) {
|
||
result.id = state.nextResultId++;
|
||
state.results.push(result);
|
||
state.results.sort((a, b) => a.latency - b.latency);
|
||
renderResults();
|
||
}
|
||
} catch (error) {
|
||
// Unavailable addresses are intentionally skipped from the result list.
|
||
} finally {
|
||
run.completed += 1;
|
||
setProgress(run.completed, run.total);
|
||
}
|
||
}, run);
|
||
|
||
if (run.stopped) {
|
||
els.latencyStatus.textContent = `已停止,保留 ${state.results.length} 个结果`;
|
||
} else {
|
||
els.latencyStatus.textContent = `优选完成,${state.results.length} 个可用`;
|
||
}
|
||
state.latencyRun = null;
|
||
els.latencyBtn.classList.remove("danger");
|
||
els.latencyBtn.innerHTML = `${svgPulseIcon()}优选延迟`;
|
||
updateActionAvailability();
|
||
renderResults();
|
||
}
|
||
|
||
function stopLatencyRun() {
|
||
const run = state.latencyRun;
|
||
if (!run) return;
|
||
run.stopped = true;
|
||
for (const controller of run.controllers) controller.abort();
|
||
els.latencyStatus.textContent = "正在停止优选";
|
||
}
|
||
|
||
// GET 取参数首次失败后最多重试 3 次(合计最多 4 次 GET);延迟测量依次发起 3 次 GET 取最小值。
|
||
const TRACE_RETRY_LIMIT = 3;
|
||
const LATENCY_MEASURE_COUNT = 3;
|
||
|
||
async function testLatency(address, timeout, run) {
|
||
const parsed = parseProbeTarget(address);
|
||
if (!parsed) throw new Error("地址格式无效");
|
||
const url = buildTraceUrl(parsed.host, parsed.port);
|
||
|
||
// 阶段一:GET 仅用于获取参数(DNS/建连耗时不计入延迟),失败可重试
|
||
let trace = null;
|
||
let lastError = null;
|
||
for (let attempt = 0; attempt <= TRACE_RETRY_LIMIT; attempt++) {
|
||
if (run && run.stopped) throw new Error("已停止");
|
||
try {
|
||
const response = await fetchWithTimeout(url, { method: "GET", timeout, run });
|
||
if (!response.ok) throw new Error("GET 不可用");
|
||
const text = await response.text();
|
||
const parsedTrace = parseCfTrace(text);
|
||
if (!isQualifiedTraceDomain(parsedTrace, parsed.host)) throw new Error("非合格优选域名");
|
||
trace = parsedTrace;
|
||
break;
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
if (!trace) throw lastError || new Error("参数获取失败");
|
||
|
||
// 阶段二:参数就绪后依次发起 3 次 GET 测速(串行执行,避免并发争抢带宽干扰样本),取最小值作为延迟
|
||
const samples = [];
|
||
for (let index = 0; index < LATENCY_MEASURE_COUNT; index++) {
|
||
if (run && run.stopped) throw new Error("已停止");
|
||
const started = performance.now();
|
||
try {
|
||
const response = await fetchWithTimeout(url, { method: "GET", timeout, run });
|
||
if (!response.ok) throw new Error("GET 不可用");
|
||
await response.text();
|
||
} catch (error) {
|
||
continue;
|
||
}
|
||
samples.push(Math.max(1, Math.round(performance.now() - started)));
|
||
}
|
||
if (!samples.length) throw new Error("GET 测速失败");
|
||
const latency = Math.min(...samples);
|
||
|
||
return {
|
||
address,
|
||
host: parsed.host,
|
||
port: parsed.port,
|
||
type: preferredTypeFromTraceIp(trace.ip),
|
||
country: countryFromColo(trace.colo),
|
||
latency,
|
||
ipType: traceFamilyFromIp(trace.ip),
|
||
responseIp: trace.ip || "",
|
||
colo: trace.colo || "",
|
||
raw: trace
|
||
};
|
||
}
|
||
|
||
function preferredTypeFromCnIspCode(cnIspCode) {
|
||
const normalizedCode = normalizeCnIspCode(cnIspCode);
|
||
const typeMap = {
|
||
ct: "电信优选",
|
||
cu: "联通优选",
|
||
cmcc: "移动优选"
|
||
};
|
||
return typeMap[normalizedCode] || "官方优选";
|
||
}
|
||
|
||
// 根据 trace 返回的 ip= 匹配对应协议栈(ipv4/ipv6)检测网络,取其 cnIspCode 映射优选类型
|
||
function preferredTypeFromTraceIp(traceIp) {
|
||
if (traceIp && traceIp === state.ipv4) {
|
||
return preferredTypeFromCnIspCode(state.network.ipv4 && state.network.ipv4.cnIspCode);
|
||
}
|
||
if (traceIp && traceIp === state.ipv6) {
|
||
return preferredTypeFromCnIspCode(state.network.ipv6 && state.network.ipv6.cnIspCode);
|
||
}
|
||
return "官方优选";
|
||
}
|
||
|
||
function normalizeCnIspCode(value) {
|
||
return String(value || "").trim().toLowerCase();
|
||
}
|
||
|
||
function countryFromColo(colo) {
|
||
if (!colo) return "未知";
|
||
const match = state.locationsByIata.get(String(colo).toUpperCase());
|
||
return match && match.cca2 ? match.cca2 : "未知";
|
||
}
|
||
|
||
function buildTraceUrl(host, port) {
|
||
const portSuffix = port && port !== 443 ? `:${port}` : "";
|
||
return `https://${host}${portSuffix}/cdn-cgi/trace?_t=${Date.now()}`;
|
||
}
|
||
|
||
function parseCfTrace(text) {
|
||
const trace = {};
|
||
String(text || "").split(/\r\n|\r|\n/).forEach((line) => {
|
||
const eq = line.indexOf("=");
|
||
if (eq <= 0) return;
|
||
trace[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||
});
|
||
return trace;
|
||
}
|
||
|
||
function isQualifiedTraceDomain(trace, requestedHost) {
|
||
if (String(trace.h || "").toLowerCase() !== String(requestedHost).toLowerCase()) return false;
|
||
if (trace.ip !== state.ipv4 && trace.ip !== state.ipv6) return false;
|
||
if (String(trace.loc || "").toUpperCase() !== "CN") return false;
|
||
return true;
|
||
}
|
||
|
||
function traceFamilyFromIp(ip) {
|
||
return String(ip || "").includes(":") ? "ipv6" : "ipv4";
|
||
}
|
||
|
||
function parseProbeTarget(address) {
|
||
const text = String(address || "").trim();
|
||
if (!text || /^\d{1,5}$/.test(text)) return null;
|
||
|
||
const bracketed = text.match(/^\[(.+)]:(\d{1,5})$/);
|
||
if (bracketed) {
|
||
const port = Number(bracketed[2]);
|
||
if (port < 1 || port > 65535) return null;
|
||
return { host: bracketed[1], port };
|
||
}
|
||
|
||
const lastColon = text.lastIndexOf(":");
|
||
if (lastColon > 0) {
|
||
const host = text.slice(0, lastColon);
|
||
const portText = text.slice(lastColon + 1);
|
||
if (/^\d{1,5}$/.test(portText) && !host.includes(":")) {
|
||
const port = Number(portText);
|
||
if (port >= 1 && port <= 65535) return { host, port };
|
||
}
|
||
}
|
||
|
||
return { host: text, port: 443 };
|
||
}
|
||
|
||
function renderResults() {
|
||
const rows = getVisibleSortedResults();
|
||
const hasActiveFilter = hasActiveResultFilters();
|
||
els.resultCount.textContent = hasActiveFilter
|
||
? `${rows.length} / ${state.results.length} 个可用`
|
||
: `${state.results.length} 个可用`;
|
||
renderSortMarks();
|
||
renderFilterHeaderState();
|
||
syncOpenResultFilterMenu();
|
||
syncSelectionWithResults();
|
||
if (!state.results.length || !rows.length) {
|
||
els.resultBody.innerHTML = `<tr><td class="empty" colspan="6">${state.results.length ? "筛选后暂无结果" : "暂无结果"}</td></tr>`;
|
||
updateSelectionState();
|
||
updateActionAvailability();
|
||
return;
|
||
}
|
||
|
||
els.resultBody.innerHTML = rows.map(({ item }) => {
|
||
const id = String(item.id);
|
||
const selected = state.selectedIds.has(id);
|
||
const safeAddress = escapeHtml(item.address);
|
||
return `<tr class="result-row${selected ? " is-selected" : ""}" data-result-id="${escapeHtml(id)}">
|
||
<td class="selection-cell"><input type="checkbox" data-select-id="${escapeHtml(id)}" aria-label="选择 ${safeAddress}" ${selected ? "checked" : ""}></td>
|
||
<td class="ip-cell"><button class="copy-address-btn" type="button" data-copy-address="${safeAddress}" title="点击复制" aria-label="复制 ${safeAddress}">${safeAddress}</button></td>
|
||
<td>${escapeHtml(item.type)}</td>
|
||
<td>${renderCountryCell(item.country)}</td>
|
||
<td>${escapeHtml(item.colo || "未知")}</td>
|
||
<td>${item.latency}ms</td>
|
||
</tr>`;
|
||
}).join("");
|
||
updateSelectionState();
|
||
updateActionAvailability();
|
||
}
|
||
|
||
function syncSelectionWithResults() {
|
||
const validIds = new Set();
|
||
state.results.forEach((item) => {
|
||
if (!item.id) item.id = state.nextResultId++;
|
||
validIds.add(String(item.id));
|
||
});
|
||
for (const id of [...state.selectedIds]) {
|
||
if (!validIds.has(id)) state.selectedIds.delete(id);
|
||
}
|
||
}
|
||
|
||
function updateSelectionState() {
|
||
const visibleIds = visibleResultIds();
|
||
const total = visibleIds.length;
|
||
const selected = state.selectedIds.size;
|
||
const visibleSelected = visibleIds.filter((id) => state.selectedIds.has(id)).length;
|
||
els.selectionStatus.textContent = `已选 ${selected} 个`;
|
||
els.copyResultsBtn.disabled = selected === 0;
|
||
els.exportTxtBtn.disabled = selected === 0;
|
||
els.exportCsvBtn.disabled = selected === 0;
|
||
els.invertSelectBtn.disabled = total === 0;
|
||
els.clearSelectBtn.disabled = selected === 0 && !hasActiveResultFilters();
|
||
els.selectAllHeader.disabled = total === 0;
|
||
els.selectAllHeader.checked = total > 0 && visibleSelected === total;
|
||
els.selectAllHeader.indeterminate = visibleSelected > 0 && visibleSelected < total;
|
||
}
|
||
|
||
function startResultSelectionDrag(event) {
|
||
if (event.button !== 0) return;
|
||
const row = event.target.closest("[data-result-id]");
|
||
if (!row || !els.resultBody.contains(row)) return;
|
||
if (isInteractiveResultTarget(event.target)) return;
|
||
|
||
event.preventDefault();
|
||
state.resultSelectionDrag = {
|
||
active: true,
|
||
toggledIds: new Set(),
|
||
lastRow: null
|
||
};
|
||
document.body.classList.add("is-result-dragging");
|
||
toggleResultRowDuringDrag(row);
|
||
}
|
||
|
||
function continueResultSelectionDrag(event) {
|
||
const drag = state.resultSelectionDrag;
|
||
if (!drag || !drag.active) return;
|
||
if ((event.buttons & 1) !== 1) {
|
||
finishResultSelectionDrag();
|
||
return;
|
||
}
|
||
|
||
const row = event.target.closest("[data-result-id]");
|
||
if (!row || !els.resultBody.contains(row)) return;
|
||
toggleResultRowDuringDrag(row);
|
||
}
|
||
|
||
function finishResultSelectionDrag() {
|
||
const drag = state.resultSelectionDrag;
|
||
if (!drag || !drag.active) return;
|
||
drag.active = false;
|
||
document.body.classList.remove("is-result-dragging");
|
||
state.suppressResultClick = true;
|
||
window.setTimeout(() => {
|
||
state.suppressResultClick = false;
|
||
}, 0);
|
||
}
|
||
|
||
function toggleResultRowDuringDrag(row) {
|
||
const drag = state.resultSelectionDrag;
|
||
if (!drag || !drag.active) return;
|
||
const rows = Array.from(els.resultBody.querySelectorAll("[data-result-id]"));
|
||
const currentIndex = rows.indexOf(row);
|
||
const lastIndex = drag.lastRow ? rows.indexOf(drag.lastRow) : -1;
|
||
|
||
if (currentIndex !== -1 && lastIndex !== -1) {
|
||
const start = Math.min(currentIndex, lastIndex);
|
||
const end = Math.max(currentIndex, lastIndex);
|
||
for (let index = start; index <= end; index += 1) {
|
||
toggleSingleResultRowDuringDrag(rows[index]);
|
||
}
|
||
} else {
|
||
toggleSingleResultRowDuringDrag(row);
|
||
}
|
||
drag.lastRow = row;
|
||
}
|
||
|
||
function toggleSingleResultRowDuringDrag(row) {
|
||
const drag = state.resultSelectionDrag;
|
||
if (!drag || !drag.active || !row) return;
|
||
const id = row.dataset.resultId;
|
||
if (!id || drag.toggledIds.has(id)) return;
|
||
|
||
const selected = !state.selectedIds.has(String(id));
|
||
if (selected) {
|
||
state.selectedIds.add(String(id));
|
||
} else {
|
||
state.selectedIds.delete(String(id));
|
||
}
|
||
drag.toggledIds.add(id);
|
||
applyResultRowSelection(row, selected);
|
||
updateSelectionState();
|
||
}
|
||
|
||
function applyResultRowSelection(row, selected) {
|
||
row.classList.toggle("is-selected", selected);
|
||
const checkbox = row.querySelector("[data-select-id]");
|
||
if (checkbox) checkbox.checked = selected;
|
||
}
|
||
|
||
function isInteractiveResultTarget(target) {
|
||
return Boolean(target.closest("button, input, select, textarea, a, label"));
|
||
}
|
||
|
||
function setResultSelected(id, selected) {
|
||
if (!id) return;
|
||
if (selected) {
|
||
state.selectedIds.add(String(id));
|
||
} else {
|
||
state.selectedIds.delete(String(id));
|
||
}
|
||
renderResults();
|
||
}
|
||
|
||
function toggleResultSelected(id) {
|
||
if (!id) return;
|
||
setResultSelected(id, !state.selectedIds.has(String(id)));
|
||
}
|
||
|
||
function selectAllResults() {
|
||
getVisibleSortedResults().forEach(({ item }) => state.selectedIds.add(String(item.id)));
|
||
renderResults();
|
||
}
|
||
|
||
function invertSelectedResults() {
|
||
getVisibleSortedResults().forEach(({ item }) => {
|
||
const id = String(item.id);
|
||
if (state.selectedIds.has(id)) {
|
||
state.selectedIds.delete(id);
|
||
} else {
|
||
state.selectedIds.add(id);
|
||
}
|
||
});
|
||
renderResults();
|
||
}
|
||
|
||
function clearSelectedResults() {
|
||
state.selectedIds.clear();
|
||
renderResults();
|
||
}
|
||
|
||
function resetResultSelection() {
|
||
state.selectedIds.clear();
|
||
resetResultFilters();
|
||
renderResults();
|
||
}
|
||
|
||
function selectedResults() {
|
||
return state.results.filter((item) => state.selectedIds.has(String(item.id)));
|
||
}
|
||
|
||
function formatResultLine(item) {
|
||
const host = String(item.address || "").replace(/:\d+$/, "");
|
||
return `${item.address}#${item.country} ${item.type}[${host} ${item.latency}ms]`;
|
||
}
|
||
|
||
function selectedResultLines() {
|
||
const lines = selectedResults().map(formatResultLine).filter(Boolean);
|
||
if (!lines.length) {
|
||
els.selectionStatus.textContent = "请先勾选结果";
|
||
return null;
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
async function copySelectedResultsToClipboard() {
|
||
const lines = selectedResultLines();
|
||
if (!lines) return;
|
||
try {
|
||
await writeClipboardText(lines.join("\n"));
|
||
els.selectionStatus.textContent = `已复制 ${lines.length} 个`;
|
||
} catch (error) {
|
||
els.selectionStatus.textContent = "复制失败";
|
||
}
|
||
}
|
||
|
||
function exportSelectedResultsTxt() {
|
||
const lines = selectedResultLines();
|
||
if (!lines) return false;
|
||
downloadTextFile(`bestcf-domains-${timestampForFilename()}.txt`, lines.join("\n"), "text/plain;charset=utf-8");
|
||
els.selectionStatus.textContent = `已导出 ${lines.length} 个`;
|
||
return true;
|
||
}
|
||
|
||
function exportOptimizeResultsCsv() {
|
||
const rows = getSortedResults().map(({ item }) => item);
|
||
if (!rows.length) {
|
||
els.selectionStatus.textContent = "暂无可导出的结果";
|
||
return false;
|
||
}
|
||
|
||
const header = ["优选域名", "IP类型", "优选类型", "国家/地区", "数据中心", "延迟(ms)"];
|
||
const csvRows = rows.map((item) => [
|
||
item.address || "",
|
||
item.ipType || "未知",
|
||
item.type || "",
|
||
item.country || "未知",
|
||
item.colo || "未知",
|
||
Number.isFinite(item.latency) ? item.latency : ""
|
||
]);
|
||
const csv = [header, ...csvRows].map(formatCsvRow).join("\r\n");
|
||
downloadTextFile(`bestcf-results-${timestampForFilename()}.csv`, `\uFEFF${csv}`, "text/csv;charset=utf-8");
|
||
els.selectionStatus.textContent = `已导出 ${rows.length} 个`;
|
||
return true;
|
||
}
|
||
|
||
function formatCsvRow(row) {
|
||
return row.map(formatCsvCell).join(",");
|
||
}
|
||
|
||
function formatCsvCell(value) {
|
||
const text = String(value === null || value === undefined ? "" : value);
|
||
const safeText = /^[=+\-@]/.test(text) ? `'${text}` : text;
|
||
return /[",\r\n]/.test(safeText) ? `"${safeText.replace(/"/g, '""')}"` : safeText;
|
||
}
|
||
|
||
function timestampForFilename() {
|
||
const date = new Date();
|
||
const pad = (value) => String(value).padStart(2, "0");
|
||
return [
|
||
date.getFullYear(),
|
||
pad(date.getMonth() + 1),
|
||
pad(date.getDate())
|
||
].join("") + "-" + [
|
||
pad(date.getHours()),
|
||
pad(date.getMinutes()),
|
||
pad(date.getSeconds())
|
||
].join("");
|
||
}
|
||
|
||
function downloadTextFile(filename, content, mimeType) {
|
||
const blob = new Blob([content], { type: mimeType });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = filename;
|
||
link.style.display = "none";
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
}
|
||
|
||
async function copyResultAddress(address, anchor) {
|
||
if (!address) return;
|
||
try {
|
||
await writeClipboardText(address);
|
||
showCopyAddressTip(anchor, "已复制到粘贴板");
|
||
} catch (error) {
|
||
showCopyAddressTip(anchor, "复制失败");
|
||
}
|
||
}
|
||
|
||
async function writeClipboardText(text) {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(text);
|
||
return;
|
||
}
|
||
|
||
const textarea = document.createElement("textarea");
|
||
textarea.value = text;
|
||
textarea.setAttribute("readonly", "");
|
||
textarea.style.position = "fixed";
|
||
textarea.style.left = "-9999px";
|
||
textarea.style.top = "0";
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
const copied = document.execCommand("copy");
|
||
textarea.remove();
|
||
if (!copied) throw new Error("copy failed");
|
||
}
|
||
|
||
function showCopyAddressTip(anchor, message) {
|
||
if (!anchor) return;
|
||
document.querySelectorAll(".copy-address-tip").forEach((tip) => tip.remove());
|
||
const rect = anchor.getBoundingClientRect();
|
||
const tip = document.createElement("div");
|
||
tip.className = "copy-address-tip";
|
||
tip.textContent = message;
|
||
tip.style.left = `${rect.left + rect.width / 2}px`;
|
||
tip.style.top = `${rect.top}px`;
|
||
document.body.appendChild(tip);
|
||
window.setTimeout(() => tip.remove(), 1500);
|
||
}
|
||
|
||
let optimizeToastTimer = 0;
|
||
function showOptimizeToast(message) {
|
||
let toast = document.querySelector(".optimize-toast");
|
||
if (!toast) {
|
||
toast = document.createElement("div");
|
||
toast.className = "optimize-toast";
|
||
toast.setAttribute("role", "status");
|
||
toast.setAttribute("aria-live", "polite");
|
||
document.body.appendChild(toast);
|
||
}
|
||
|
||
toast.textContent = message;
|
||
window.clearTimeout(optimizeToastTimer);
|
||
requestAnimationFrame(() => toast.classList.add("is-visible"));
|
||
optimizeToastTimer = window.setTimeout(() => {
|
||
toast.classList.remove("is-visible");
|
||
}, 3600);
|
||
}
|
||
|
||
function setResultSort(key) {
|
||
if (state.sort.key === key) {
|
||
state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
|
||
} else {
|
||
state.sort = { key, dir: "asc" };
|
||
}
|
||
renderResults();
|
||
}
|
||
|
||
function renderSortMarks() {
|
||
document.querySelectorAll("[data-sort-mark]").forEach((mark) => {
|
||
mark.textContent = mark.dataset.sortMark === state.sort.key
|
||
? (state.sort.dir === "asc" ? "▲" : "▼")
|
||
: "";
|
||
});
|
||
}
|
||
|
||
function renderFilterHeaderState() {
|
||
document.querySelectorAll("[data-filter-key]").forEach((label) => {
|
||
const key = label.dataset.filterKey;
|
||
label.classList.toggle("is-filtered", isResultFilterActive(key));
|
||
label.classList.toggle("is-active", state.openResultFilterKey === key);
|
||
label.title = isResultFilterActive(key)
|
||
? `${RESULT_FILTER_LABELS[key] || "字段"} 已筛选`
|
||
: `筛选 ${RESULT_FILTER_LABELS[key] || "字段"}`;
|
||
});
|
||
}
|
||
|
||
function toggleResultFilterMenu(key, anchor) {
|
||
if (!RESULT_FILTER_KEYS.includes(key)) return;
|
||
const currentMenu = document.querySelector(".result-filter-menu");
|
||
if (state.openResultFilterKey === key && currentMenu) {
|
||
closeResultFilterMenu();
|
||
return;
|
||
}
|
||
openResultFilterMenu(key, anchor);
|
||
}
|
||
|
||
function openResultFilterMenu(key, anchor) {
|
||
closeResultFilterMenu({ keepKey: true });
|
||
state.openResultFilterKey = key;
|
||
const options = resultFilterOptions(key);
|
||
const values = options.map((option) => option.value);
|
||
const selectedValues = selectedResultFilterValues(key, values);
|
||
const menu = document.createElement("div");
|
||
menu.className = "result-filter-menu";
|
||
menu.dataset.filterMenuKey = key;
|
||
menu.innerHTML = `
|
||
<div class="result-filter-actions">
|
||
<button class="result-filter-action" type="button" data-filter-action="all">全选</button>
|
||
<button class="result-filter-action" type="button" data-filter-action="invert">反选</button>
|
||
</div>
|
||
<div class="result-filter-options">
|
||
${options.map((option) => {
|
||
const safeValue = escapeHtml(option.value);
|
||
return `<label class="result-filter-option" title="${safeValue}">
|
||
<input type="checkbox" data-filter-value="${safeValue}" ${selectedValues.has(option.value) ? "checked" : ""}>
|
||
${renderResultFilterOptionText(key, option.value)}
|
||
<span class="result-filter-option-count">(${option.count})</span>
|
||
</label>`;
|
||
}).join("") || '<div class="empty">暂无可筛选内容</div>'}
|
||
</div>`;
|
||
document.body.appendChild(menu);
|
||
positionResultFilterMenu(menu, anchor);
|
||
|
||
menu.addEventListener("click", (event) => event.stopPropagation());
|
||
menu.querySelectorAll("[data-filter-action]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const checkboxes = Array.from(menu.querySelectorAll("[data-filter-value]"));
|
||
if (button.dataset.filterAction === "all") {
|
||
checkboxes.forEach((checkbox) => { checkbox.checked = true; });
|
||
} else {
|
||
checkboxes.forEach((checkbox) => { checkbox.checked = !checkbox.checked; });
|
||
}
|
||
applyResultFilterMenuSelection(key, menu);
|
||
});
|
||
});
|
||
menu.querySelectorAll("[data-filter-value]").forEach((checkbox) => {
|
||
checkbox.addEventListener("change", () => applyResultFilterMenuSelection(key, menu));
|
||
});
|
||
renderFilterHeaderState();
|
||
}
|
||
|
||
function closeResultFilterMenu(options = {}) {
|
||
document.querySelectorAll(".result-filter-menu").forEach((menu) => menu.remove());
|
||
if (!options.keepKey) state.openResultFilterKey = "";
|
||
renderFilterHeaderState();
|
||
}
|
||
|
||
function resetResultFilters() {
|
||
state.resultFilters = {};
|
||
closeResultFilterMenu();
|
||
}
|
||
|
||
function syncOpenResultFilterMenu() {
|
||
if (!state.openResultFilterKey) return;
|
||
const anchor = document.querySelector(`[data-filter-key="${cssEscape(state.openResultFilterKey)}"]`);
|
||
if (!anchor) {
|
||
closeResultFilterMenu();
|
||
return;
|
||
}
|
||
openResultFilterMenu(state.openResultFilterKey, anchor);
|
||
}
|
||
|
||
function positionResultFilterMenu(menu, anchor) {
|
||
const rect = anchor.getBoundingClientRect();
|
||
const padding = 12;
|
||
const width = menu.offsetWidth || 260;
|
||
const height = menu.offsetHeight || 260;
|
||
let left = rect.left;
|
||
let top = rect.bottom + 6;
|
||
if (left + width > window.innerWidth - padding) left = window.innerWidth - width - padding;
|
||
if (top + height > window.innerHeight - padding) top = Math.max(padding, rect.top - height - 6);
|
||
menu.style.left = `${Math.max(padding, left)}px`;
|
||
menu.style.top = `${Math.max(padding, top)}px`;
|
||
}
|
||
|
||
function applyResultFilterMenuSelection(key, menu) {
|
||
const values = resultFilterValues(key);
|
||
const checkedValues = Array.from(menu.querySelectorAll("[data-filter-value]:checked"))
|
||
.map((checkbox) => checkbox.dataset.filterValue);
|
||
if (!checkedValues.length) {
|
||
state.resultFilters[key] = new Set();
|
||
} else if (checkedValues.length === values.length) {
|
||
delete state.resultFilters[key];
|
||
} else {
|
||
state.resultFilters[key] = new Set(checkedValues);
|
||
}
|
||
renderResults();
|
||
}
|
||
|
||
function resultFilterValues(key) {
|
||
return resultFilterOptions(key).map((option) => option.value);
|
||
}
|
||
|
||
function resultFilterOptions(key) {
|
||
const countMap = new Map();
|
||
state.results.forEach((item) => {
|
||
const value = resultFilterValue(item, key);
|
||
countMap.set(value, (countMap.get(value) || 0) + 1);
|
||
});
|
||
return Array.from(countMap.keys())
|
||
.sort((a, b) => compareResultValue(a, b))
|
||
.map((value) => ({ value, count: countMap.get(value) || 0 }));
|
||
}
|
||
|
||
function renderResultFilterOptionText(key, value) {
|
||
if (key === "country") return renderCountryCell(value);
|
||
return `<span class="result-filter-option-text">${escapeHtml(value)}</span>`;
|
||
}
|
||
|
||
function selectedResultFilterValues(key, values) {
|
||
const filter = state.resultFilters[key];
|
||
if (!filter) return new Set(values);
|
||
return new Set(values.filter((value) => filter.has(value)));
|
||
}
|
||
|
||
function isResultFilterActive(key) {
|
||
const values = resultFilterValues(key);
|
||
const filter = state.resultFilters[key];
|
||
return Boolean(filter && filter.size !== values.length);
|
||
}
|
||
|
||
function hasActiveResultFilters() {
|
||
return RESULT_FILTER_KEYS.some(isResultFilterActive);
|
||
}
|
||
|
||
function resultMatchesFilters(item) {
|
||
return RESULT_FILTER_KEYS.every((key) => {
|
||
const filter = state.resultFilters[key];
|
||
if (!filter) return true;
|
||
return filter.has(resultFilterValue(item, key));
|
||
});
|
||
}
|
||
|
||
function resultFilterValue(item, key) {
|
||
if (key === "type") return String(item.type || "未知");
|
||
if (key === "country") return String(item.country || "未知");
|
||
if (key === "colo") return String(item.colo || "未知");
|
||
return String(item[key] || "未知");
|
||
}
|
||
|
||
function getVisibleSortedResults() {
|
||
return getSortedResults().filter(({ item }) => resultMatchesFilters(item));
|
||
}
|
||
|
||
function visibleResultIds() {
|
||
return getVisibleSortedResults().map(({ item }) => String(item.id));
|
||
}
|
||
|
||
function cssEscape(value) {
|
||
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
|
||
return String(value).replace(/["\\]/g, "\\$&");
|
||
}
|
||
|
||
function getSortedResults() {
|
||
const direction = state.sort.dir === "asc" ? 1 : -1;
|
||
return state.results
|
||
.map((item, index) => ({ item, index }))
|
||
.sort((a, b) => {
|
||
const left = sortValue(a.item, state.sort.key);
|
||
const right = sortValue(b.item, state.sort.key);
|
||
const emptyCompared = compareEmptyValue(left, right);
|
||
if (emptyCompared !== 0) return emptyCompared;
|
||
const compared = compareResultValue(left, right);
|
||
if (compared !== 0) return compared * direction;
|
||
return a.index - b.index;
|
||
});
|
||
}
|
||
|
||
function sortValue(item, key) {
|
||
return item[key];
|
||
}
|
||
|
||
function compareResultValue(left, right) {
|
||
if (typeof left === "number" && typeof right === "number") return left - right;
|
||
return String(left).localeCompare(String(right), "zh-Hans-CN", { numeric: true, sensitivity: "base" });
|
||
}
|
||
|
||
function compareEmptyValue(left, right) {
|
||
const leftEmpty = left === null || left === undefined || left === "";
|
||
const rightEmpty = right === null || right === undefined || right === "";
|
||
if (leftEmpty && rightEmpty) return 0;
|
||
if (leftEmpty) return 1;
|
||
if (rightEmpty) return -1;
|
||
return 0;
|
||
}
|
||
|
||
function renderCountryCell(country) {
|
||
const text = String(country || "未知").trim() || "未知";
|
||
const countryCode = /^[a-zA-Z]{2}$/.test(text) ? text.toLowerCase() : "";
|
||
const flag = countryCode
|
||
? `<img class="country-flag" src="https://ipdata.co/flags/${countryCode}.png" alt="" loading="lazy" onerror="this.remove()">`
|
||
: "";
|
||
return `<span class="country-cell">${flag}<span class="country-code">${escapeHtml(text)}</span></span>`;
|
||
}
|
||
|
||
function prepareCandidates(rawText, preferredPort) {
|
||
const lines = rawText
|
||
.split(/\r\n|\r|\n/)
|
||
.map(cleanInputLine)
|
||
.filter(Boolean);
|
||
|
||
if (!lines.length) throw new Error(CANDIDATE_INPUT_ERROR_MESSAGE);
|
||
|
||
const fixed = [];
|
||
const cidrs = [];
|
||
const ranges = [];
|
||
for (const line of lines) {
|
||
if (line.includes("/")) {
|
||
const cidr = parseCidr(line);
|
||
if (cidr) cidrs.push(cidr);
|
||
continue;
|
||
}
|
||
const range = parseIpRange(line);
|
||
if (range) {
|
||
ranges.push(range);
|
||
continue;
|
||
}
|
||
const normalized = normalizeDomainCandidate(line, preferredPort) || normalizeAddress(line, preferredPort);
|
||
if (normalized) fixed.push(normalized);
|
||
}
|
||
|
||
// 随机打乱固定IP、CIDR和范围列表,以确保每次选取的候选IP是随机且不同的
|
||
for (let i = fixed.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[fixed[i], fixed[j]] = [fixed[j], fixed[i]];
|
||
}
|
||
for (let i = cidrs.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[cidrs[i], cidrs[j]] = [cidrs[j], cidrs[i]];
|
||
}
|
||
for (let i = ranges.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[ranges[i], ranges[j]] = [ranges[j], ranges[i]];
|
||
}
|
||
|
||
const candidates = [];
|
||
const seen = new Set();
|
||
const addCandidate = (address) => {
|
||
if (!address || seen.has(address) || candidates.length >= CANDIDATE_LIMIT) return false;
|
||
seen.add(address);
|
||
candidates.push(address);
|
||
return true;
|
||
};
|
||
|
||
fixed.forEach(addCandidate);
|
||
|
||
if (cidrs.length && candidates.length < CANDIDATE_LIMIT) {
|
||
let attempts = 0;
|
||
const maxAttempts = Math.max(CANDIDATE_LIMIT * 12, cidrs.length * 8);
|
||
while (candidates.length < CANDIDATE_LIMIT && attempts < maxAttempts) {
|
||
const cidr = cidrs[attempts % cidrs.length];
|
||
addCandidate(randomAddressFromCidr(cidr, preferredPort));
|
||
attempts += 1;
|
||
}
|
||
}
|
||
|
||
if (ranges.length && candidates.length < CANDIDATE_LIMIT) {
|
||
let attempts = 0;
|
||
const maxAttempts = Math.max(CANDIDATE_LIMIT * 12, ranges.length * 8);
|
||
while (candidates.length < CANDIDATE_LIMIT && attempts < maxAttempts) {
|
||
const range = ranges[attempts % ranges.length];
|
||
addCandidate(randomAddressFromRange(range, preferredPort));
|
||
attempts += 1;
|
||
}
|
||
}
|
||
|
||
if (!candidates.length) throw new Error(CANDIDATE_INPUT_ERROR_MESSAGE);
|
||
return candidates;
|
||
}
|
||
|
||
function cleanInputLine(line) {
|
||
return line
|
||
.replace(/:/g, ":")
|
||
.replace(/#.*/, "")
|
||
.replace(/[ ]/g, "")
|
||
.trim();
|
||
}
|
||
|
||
const DOMAIN_HOST_PATTERN = /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
|
||
|
||
function normalizeDomainCandidate(line, preferredPort) {
|
||
const lastColon = line.lastIndexOf(":");
|
||
let host = line;
|
||
let portText = "";
|
||
if (lastColon > 0) {
|
||
const maybeHost = line.slice(0, lastColon);
|
||
const maybePort = line.slice(lastColon + 1);
|
||
if (/^\d{1,5}$/.test(maybePort)) {
|
||
host = maybeHost;
|
||
portText = maybePort;
|
||
}
|
||
}
|
||
if (!DOMAIN_HOST_PATTERN.test(host)) return null;
|
||
const port = validPortOrDefault(portText, choosePort(preferredPort));
|
||
return `${host}:${port}`;
|
||
}
|
||
|
||
function normalizeAddress(line, preferredPort) {
|
||
const fallbackPort = choosePort(preferredPort);
|
||
const bracketed = line.match(/^\[([0-9a-fA-F:.]+)](?::?(\d{1,5}))?$/);
|
||
if (bracketed) {
|
||
const ip = normalizeIpv6(bracketed[1]);
|
||
if (!ip) return null;
|
||
const port = validPortOrDefault(bracketed[2], fallbackPort);
|
||
return `[${ip}]:${port}`;
|
||
}
|
||
|
||
const ipv4 = line.match(/^(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?$/);
|
||
if (ipv4 && isValidIpv4(ipv4[1])) {
|
||
const port = validPortOrDefault(ipv4[2], fallbackPort);
|
||
return `${ipv4[1]}:${port}`;
|
||
}
|
||
|
||
const ipv6Direct = normalizeIpv6(line);
|
||
if (ipv6Direct) return `[${ipv6Direct}]:${fallbackPort}`;
|
||
|
||
const lastColon = line.lastIndexOf(":");
|
||
if (lastColon > 0) {
|
||
const maybeIp = line.slice(0, lastColon);
|
||
const maybePort = line.slice(lastColon + 1);
|
||
const ip = normalizeIpv6(maybeIp);
|
||
if (ip && /^\d{1,5}$/.test(maybePort)) {
|
||
return `[${ip}]:${validPortOrDefault(maybePort, fallbackPort)}`;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function parseIpRange(line) {
|
||
const separator = line.indexOf("-");
|
||
if (separator <= 0 || separator !== line.lastIndexOf("-")) return null;
|
||
|
||
const startText = unwrapRangeIp(line.slice(0, separator));
|
||
const endText = unwrapRangeIp(line.slice(separator + 1));
|
||
|
||
const startIpv4 = ipv4ToBigInt(startText);
|
||
const endIpv4 = ipv4ToBigInt(endText);
|
||
if (startIpv4 !== null && endIpv4 !== null) {
|
||
if (startIpv4 > endIpv4) return null;
|
||
return { family: "ipv4", start: startIpv4, size: endIpv4 - startIpv4 + 1n };
|
||
}
|
||
|
||
const startIpv6 = ipv6ToBigInt(startText);
|
||
const endIpv6 = ipv6ToBigInt(endText);
|
||
if (startIpv6 !== null && endIpv6 !== null) {
|
||
if (startIpv6 > endIpv6) return null;
|
||
return { family: "ipv6", start: startIpv6, size: endIpv6 - startIpv6 + 1n };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function unwrapRangeIp(value) {
|
||
const bracketed = value.match(/^\[([0-9a-fA-F:.]+)]$/);
|
||
return bracketed ? bracketed[1] : value;
|
||
}
|
||
|
||
function parseCidr(line) {
|
||
const parts = line.split("/");
|
||
if (parts.length !== 2) return null;
|
||
const prefix = Number(parts[1]);
|
||
if (!Number.isInteger(prefix)) return null;
|
||
|
||
if (parts[0].includes(".")) {
|
||
const value = ipv4ToBigInt(parts[0]);
|
||
if (value === null || prefix < 0 || prefix > 32) return null;
|
||
const hostBits = 32 - prefix;
|
||
const network = value & prefixMask(32, prefix);
|
||
return { family: "ipv4", bits: 32, prefix, hostBits, network, size: 1n << BigInt(hostBits) };
|
||
}
|
||
|
||
const value = ipv6ToBigInt(parts[0]);
|
||
if (value === null || prefix < 0 || prefix > 128) return null;
|
||
const hostBits = 128 - prefix;
|
||
const network = value & prefixMask(128, prefix);
|
||
return { family: "ipv6", bits: 128, prefix, hostBits, network, size: 1n << BigInt(hostBits) };
|
||
}
|
||
|
||
function randomAddressFromCidr(cidr, preferredPort) {
|
||
const value = cidr.network + randomBigIntBelow(cidr.size);
|
||
const port = choosePort(preferredPort);
|
||
if (cidr.family === "ipv4") return `${bigIntToIpv4(value)}:${port}`;
|
||
return `[${bigIntToIpv6(value)}]:${port}`;
|
||
}
|
||
|
||
function randomAddressFromRange(range, preferredPort) {
|
||
const value = range.start + randomBigIntBelow(range.size);
|
||
const port = choosePort(preferredPort);
|
||
if (range.family === "ipv4") return `${bigIntToIpv4(value)}:${port}`;
|
||
return `[${bigIntToIpv6(value)}]:${port}`;
|
||
}
|
||
|
||
function isValidIpv4(ip) {
|
||
const parts = ip.split(".");
|
||
if (parts.length !== 4) return false;
|
||
return parts.every((part) => {
|
||
if (!/^\d{1,3}$/.test(part)) return false;
|
||
const value = Number(part);
|
||
return value >= 0 && value <= 255;
|
||
});
|
||
}
|
||
|
||
function ipv4ToBigInt(ip) {
|
||
if (!isValidIpv4(ip)) return null;
|
||
return ip.split(".").reduce((acc, part) => (acc << 8n) + BigInt(Number(part)), 0n);
|
||
}
|
||
|
||
function bigIntToIpv4(value) {
|
||
return [24n, 16n, 8n, 0n]
|
||
.map((shift) => Number((value >> shift) & 255n))
|
||
.join(".");
|
||
}
|
||
|
||
function normalizeIpv6(ip) {
|
||
const value = ipv6ToBigInt(ip);
|
||
return value === null ? null : bigIntToIpv6(value);
|
||
}
|
||
|
||
function expandIpv6Groups(ip) {
|
||
const value = ipv6ToBigInt(ip);
|
||
if (value === null) return null;
|
||
const groups = [];
|
||
for (let index = 7; index >= 0; index -= 1) {
|
||
groups.push(Number((value >> BigInt(index * 16)) & 0xffffn).toString(16));
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
function ipv6ToBigInt(ip) {
|
||
if (!ip || /[^0-9a-fA-F:.]/.test(ip)) return null;
|
||
if ((ip.match(/::/g) || []).length > 1) return null;
|
||
|
||
const sides = ip.split("::");
|
||
let left = sides[0] ? sides[0].split(":") : [];
|
||
let right = sides.length === 2 && sides[1] ? sides[1].split(":") : [];
|
||
if (sides.length === 1 && left.length !== 8) return null;
|
||
if (sides.length === 2) {
|
||
const fill = 8 - left.length - right.length;
|
||
if (fill < 1) return null;
|
||
left = [...left, ...Array(fill).fill("0"), ...right];
|
||
}
|
||
if (left.length !== 8) return null;
|
||
|
||
let result = 0n;
|
||
for (const group of left) {
|
||
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
|
||
result = (result << 16n) + BigInt(parseInt(group, 16));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function bigIntToIpv6(value) {
|
||
const groups = [];
|
||
for (let index = 7; index >= 0; index -= 1) {
|
||
groups.push(Number((value >> BigInt(index * 16)) & 0xffffn).toString(16));
|
||
}
|
||
|
||
let bestStart = -1;
|
||
let bestLength = 0;
|
||
for (let index = 0; index < groups.length;) {
|
||
if (groups[index] !== "0") {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
let end = index;
|
||
while (end < groups.length && groups[end] === "0") end += 1;
|
||
const length = end - index;
|
||
if (length > bestLength && length > 1) {
|
||
bestStart = index;
|
||
bestLength = length;
|
||
}
|
||
index = end;
|
||
}
|
||
|
||
if (bestStart === -1) return groups.join(":");
|
||
const before = groups.slice(0, bestStart).join(":");
|
||
const after = groups.slice(bestStart + bestLength).join(":");
|
||
if (!before && !after) return "::";
|
||
if (!before) return `::${after}`;
|
||
if (!after) return `${before}::`;
|
||
return `${before}::${after}`;
|
||
}
|
||
|
||
function prefixMask(bits, prefix) {
|
||
if (prefix === 0) return 0n;
|
||
const all = (1n << BigInt(bits)) - 1n;
|
||
const host = (1n << BigInt(bits - prefix)) - 1n;
|
||
return all ^ host;
|
||
}
|
||
|
||
function randomBigIntBelow(max) {
|
||
if (max <= 1n) return 0n;
|
||
const bits = max.toString(2).length;
|
||
const bytes = Math.ceil(bits / 8);
|
||
const mask = (1n << BigInt(bits)) - 1n;
|
||
const buffer = new Uint8Array(bytes);
|
||
while (true) {
|
||
crypto.getRandomValues(buffer);
|
||
let value = 0n;
|
||
for (const byte of buffer) value = (value << 8n) + BigInt(byte);
|
||
value &= mask;
|
||
if (value < max) return value;
|
||
}
|
||
}
|
||
|
||
function choosePort(preferredPort) {
|
||
if (preferredPort > 0) return preferredPort;
|
||
return TLS_PORTS[Math.floor(Math.random() * TLS_PORTS.length)];
|
||
}
|
||
|
||
function validPortOrDefault(value, fallback) {
|
||
if (!value) return fallback;
|
||
const port = Number(value);
|
||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallback;
|
||
}
|
||
|
||
function readSettings() {
|
||
[els.timeoutInput, els.threadsInput].forEach((input) => clampNumberInput(input));
|
||
return {
|
||
timeout: Number(els.timeoutInput.value),
|
||
threads: Number(els.threadsInput.value),
|
||
port: Number(els.portSelect.value)
|
||
};
|
||
}
|
||
|
||
function clampNumberInput(input) {
|
||
const min = Number(input.min);
|
||
const max = Number(input.max);
|
||
const fallback = Number(input.defaultValue || min);
|
||
let value = Number(input.value);
|
||
if (!Number.isFinite(value)) value = fallback;
|
||
value = Math.min(max, Math.max(min, Math.round(value)));
|
||
input.value = String(value);
|
||
}
|
||
|
||
async function runPool(items, concurrency, worker, run) {
|
||
let cursor = 0;
|
||
const size = Math.min(Math.max(1, concurrency), items.length);
|
||
const workers = Array.from({ length: size }, async () => {
|
||
while (!run.stopped && cursor < items.length) {
|
||
const index = cursor;
|
||
cursor += 1;
|
||
await worker(items[index], index);
|
||
}
|
||
});
|
||
await Promise.all(workers);
|
||
}
|
||
|
||
async function fetchJson(url, timeout) {
|
||
const response = await fetchWithTimeout(url, { method: "GET", timeout });
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
return response.json();
|
||
}
|
||
|
||
async function fetchJsonFromFastestEndpoint(endpoints, path, timeout) {
|
||
const bases = Array.from(new Set((Array.isArray(endpoints) ? endpoints : [endpoints]).filter(Boolean)));
|
||
if (!bases.length) throw new Error("无可用检测地址");
|
||
|
||
const run = { stopped: false, controllers: new Set() };
|
||
let pending = bases.length;
|
||
let lastError = null;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
bases.forEach((endpoint) => {
|
||
fetchJsonFromEndpoint(endpoint, path, timeout, run)
|
||
.then((result) => {
|
||
if (run.stopped) return;
|
||
run.stopped = true;
|
||
for (const controller of run.controllers) controller.abort();
|
||
resolve(result);
|
||
})
|
||
.catch((error) => {
|
||
if (run.stopped) return;
|
||
lastError = error;
|
||
pending -= 1;
|
||
if (pending === 0) reject(lastError || new Error("检测失败"));
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
async function fetchJsonFromEndpoint(endpoint, path, timeout, run) {
|
||
const normalizedPath = String(path || "").replace(/^\/+/, "");
|
||
const response = await fetchWithTimeout(`${endpoint}/${normalizedPath}?_t=${Date.now()}`, {
|
||
method: "GET",
|
||
timeout,
|
||
run
|
||
});
|
||
if (response.status !== 200) throw new Error(`${endpoint} HTTP ${response.status}`);
|
||
const data = await response.json();
|
||
return { endpoint, data };
|
||
}
|
||
|
||
function fetchWithTimeout(url, { method = "GET", timeout = 8000, run = null } = {}) {
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => controller.abort(), timeout);
|
||
if (run) {
|
||
run.controllers.add(controller);
|
||
if (run.stopped) controller.abort();
|
||
}
|
||
return fetch(url, {
|
||
method,
|
||
cache: "no-store",
|
||
signal: controller.signal
|
||
}).finally(() => {
|
||
window.clearTimeout(timer);
|
||
if (run) run.controllers.delete(controller);
|
||
});
|
||
}
|
||
|
||
function setProgress(done, total) {
|
||
const percent = total ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
||
els.latencyProgress.style.width = `${percent}%`;
|
||
els.progressText.textContent = `${done} / ${total}`;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function friendlyError(error) {
|
||
if (!error) return "操作失败";
|
||
if (error.name === "AbortError") return "请求超时或已停止";
|
||
return error.message || "操作失败";
|
||
}
|