SubBoost Import Script
// ==UserScript==
// @name SubBoost Import
// @namespace https://subboost.org/
// @description SubBoost Import Script
// @author RyanCross6673
// @version 1.1.0
// @run-at document-end
// @match https://subboost.org/?editSubscriptionId=*
// @icon https://www.google.com/s2/favicons?sz=64&domain=subboost.org
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'subboost-config';
const BTN_ID = 'sb-batch-rule-btn';
const PANEL_ID = 'sb-batch-rule-panel';
const RULE_TYPES = [
{ value: 'DOMAIN', label: '域名 (DOMAIN)' },
{ value: 'DOMAIN-SUFFIX', label: '域名后缀 (DOMAIN-SUFFIX)' },
{ value: 'DOMAIN-KEYWORD', label: '域名关键词 (DOMAIN-KEYWORD)' },
{ value: 'IP-CIDR', label: 'IP 段 (IP-CIDR)' },
{ value: 'IP-CIDR6', label: 'IPv6 段 (IP-CIDR6)' },
{ value: 'GEOIP', label: 'GeoIP (GEOIP)' },
{ value: 'GEOSITE', label: 'GeoSite (GEOSITE)' },
{ value: 'PROCESS-NAME', label: '进程名 (PROCESS-NAME)' },
{ value: 'DST-PORT', label: '目标端口 (DST-PORT)' },
{ value: 'SRC-PORT', label: '源端口 (SRC-PORT)' }
];
const TARGETS = [
'DIRECT',
'REJECT',
'🚀 节点选择',
'⚡ 自动选择',
'🛑 广告拦截',
'🏠 私有网络',
'🔒 国内服务',
'🌍 非中国',
'🐟 漏网之鱼'
];
function loadConfig() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { state: { customRules: [] } };
let data;
try {
data = JSON.parse(raw);
} catch {
throw new Error('subboost-config JSON 解析失败');
}
if (!data.state) data.state = {};
if (!Array.isArray(data.state.customRules)) data.state.customRules = [];
return data;
}
function saveConfig(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
function makeId() {
return `custom-rule-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function parseLines(text) {
return String(text)
.split('\n')
.map(s => s.trim())
.filter(Boolean);
}
function toHostnameLike(value) {
let s = String(value).trim();
if (!s) return '';
try {
if (/^https?:\/\//i.test(s)) {
const u = new URL(s);
s = u.hostname || '';
}
} catch {}
s = s.replace(/^[a-z]+:\/\//i, '');
s = s.replace(/[/?#].*$/, '');
s = s.replace(/:\d+$/, '');
s = s.replace(/\.$/, '');
s = s.toLowerCase();
return s;
}
function extractRootDomain(hostname) {
const host = String(hostname || '').trim().toLowerCase();
if (!host) return '';
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
if (host.includes(':')) return host;
const parts = host.split('.').filter(Boolean);
if (parts.length <= 2) return host;
const secondLevelSet = new Set([
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
'co.uk', 'org.uk', 'gov.uk', 'ac.uk',
'com.hk', 'com.tw', 'com.au', 'net.au', 'org.au',
'co.jp', 'com.sg', 'com.my'
]);
const last2 = parts.slice(-2).join('.');
const last3 = parts.slice(-3).join('.');
if (secondLevelSet.has(last2) && parts.length >= 3) {
return last3;
}
return last2;
}
function normalizeValues(values, type, extractRoot) {
let result = values.map(v => {
let x = String(v).trim();
if (!x) return '';
if (type === 'DOMAIN' || type === 'DOMAIN-SUFFIX' || type === 'DOMAIN-KEYWORD') {
x = toHostnameLike(x);
if (!x) return '';
if (extractRoot) {
x = extractRootDomain(x);
} else if (type === 'DOMAIN-SUFFIX') {
x = x.replace(/^www\./i, '');
}
}
return x;
}).filter(Boolean);
result = [...new Set(result)];
result.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
return result;
}
function escapeHtml(str) {
return String(str)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function extractAllApiHubSiteUrls(jsonText) {
let data;
try {
data = JSON.parse(jsonText);
} catch {
throw new Error('All API Hub JSON 解析失败');
}
const list = data?.accounts?.accounts;
if (!Array.isArray(list)) {
throw new Error('未找到 accounts.accounts 数组');
}
const urls = list
.map(item => item?.site_url)
.filter(Boolean)
.map(x => String(x).trim())
.filter(Boolean);
return [...new Set(urls)];
}
function appendLinesToTextarea(textarea, lines) {
const oldLines = parseLines(textarea.value);
const merged = [...oldLines, ...lines];
textarea.value = merged.join('\n');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file, 'utf-8');
});
}
function createButton() {
if (document.getElementById(BTN_ID)) return;
const btn = document.createElement('button');
btn.id = BTN_ID;
btn.textContent = '批量导入规则';
btn.style.cssText = `
position: fixed;
right: 20px;
bottom: 20px;
z-index: 999998;
height: 42px;
padding: 0 16px;
border: none;
border-radius: 12px;
background: #4f46e5;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0,0,0,.35);
`;
btn.onclick = () => {
createPanel();
document.getElementById(PANEL_ID).style.display = 'flex';
};
document.body.appendChild(btn);
}
function createPanel() {
if (document.getElementById(PANEL_ID)) return;
const mask = document.createElement('div');
mask.id = PANEL_ID;
mask.style.cssText = `
position: fixed;
inset: 0;
z-index: 999999;
background: rgba(0,0,0,.55);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
`;
const card = document.createElement('div');
card.style.cssText = `
width: min(800px, 96vw);
max-height: 92vh;
overflow: auto;
background: #111214;
color: #fff;
border: 1px solid rgba(255,255,255,.12);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,.45);
padding: 18px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
`;
card.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<div style="font-size:18px;font-weight:700;">批量导入自定义规则</div>
<button id="sb-close-panel" style="border:none;background:transparent;color:#bbb;cursor:pointer;font-size:20px;line-height:1;">×</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="grid-column:1 / span 2;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:12px;flex-wrap:wrap;">
<label style="display:block;font-size:13px;color:#bbb;">多行规则值(一行一条)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button id="sb-import-all-api-hub" style="
height:32px;
padding:0 12px;
border:none;
border-radius:10px;
background:#24304d;
color:#dbe7ff;
cursor:pointer;
font-size:12px;
font-weight:600;
">粘贴 All API Hub JSON</button>
<button id="sb-import-all-api-hub-file" style="
height:32px;
padding:0 12px;
border:none;
border-radius:10px;
background:#234236;
color:#dcffee;
cursor:pointer;
font-size:12px;
font-weight:600;
">选择 All API Hub JSON 文件</button>
</div>
</div>
<textarea id="sb-rule-values" placeholder="例如:
google.com
https://quota.wpgzs.top/
https://www.google.com/search?q=1" style="
width:100%;
min-height:220px;
resize:vertical;
border:1px solid rgba(255,255,255,.12);
border-radius:12px;
background:#1a1b1f;
color:#fff;
padding:12px;
box-sizing:border-box;
outline:none;
"></textarea>
<input id="sb-hidden-file-input" type="file" accept=".json,application/json" style="display:none;" />
</div>
<div>
<label style="display:block;font-size:13px;color:#bbb;margin-bottom:6px;">匹配规则</label>
<select id="sb-rule-type" style="
width:100%;height:40px;border-radius:10px;
background:#1a1b1f;color:#fff;border:1px solid rgba(255,255,255,.12);padding:0 10px;">
${RULE_TYPES.map(x => `<option value="${x.value}" ${x.value === 'DOMAIN-SUFFIX' ? 'selected' : ''}>${x.label}</option>`).join('')}
</select>
</div>
<div>
<label style="display:block;font-size:13px;color:#bbb;margin-bottom:6px;">连接方式</label>
<select id="sb-rule-target" style="
width:100%;height:40px;border-radius:10px;
background:#1a1b1f;color:#fff;border:1px solid rgba(255,255,255,.12);padding:0 10px;">
${TARGETS.map(x => `<option value="${escapeHtml(x)}" ${x === 'DIRECT' ? 'selected' : ''}>${escapeHtml(x)}</option>`).join('')}
</select>
</div>
<div>
<label style="display:block;font-size:13px;color:#bbb;margin-bottom:6px;">导入模式</label>
<select id="sb-import-mode" style="
width:100%;height:40px;border-radius:10px;
background:#1a1b1f;color:#fff;border:1px solid rgba(255,255,255,.12);padding:0 10px;">
<option value="append" selected>追加</option>
<option value="replace">覆盖</option>
</select>
</div>
<div style="display:flex;align-items:end;gap:18px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#ddd;cursor:pointer;height:40px;">
<input id="sb-no-resolve" type="checkbox" />
启用 no-resolve
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#ddd;cursor:pointer;height:40px;">
<input id="sb-extract-root" type="checkbox" />
提取主域名
</label>
</div>
</div>
<div style="margin-top:14px;padding:12px;border:1px solid rgba(255,255,255,.10);border-radius:12px;background:#17181c;">
<div style="font-size:13px;color:#bbb;margin-bottom:8px;">预览</div>
<div style="
max-height:280px;
overflow:auto;
border:1px solid rgba(255,255,255,.06);
border-radius:10px;
background:#111318;
padding:10px 12px;
">
<div id="sb-preview" style="
font-size:13px;
color:#ddd;
line-height:1.6;
white-space:pre-wrap;
word-break:break-all;
">尚未填写</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
<button id="sb-cancel" style="
height:40px;padding:0 16px;border-radius:10px;border:1px solid rgba(255,255,255,.12);
background:#1a1b1f;color:#fff;cursor:pointer;">取消</button>
<button id="sb-run" style="
height:40px;padding:0 16px;border-radius:10px;border:none;
background:#4f46e5;color:#fff;cursor:pointer;font-weight:600;">确定执行</button>
</div>
`;
mask.appendChild(card);
document.body.appendChild(mask);
const textarea = card.querySelector('#sb-rule-values');
const typeEl = card.querySelector('#sb-rule-type');
const targetEl = card.querySelector('#sb-rule-target');
const noResolveEl = card.querySelector('#sb-no-resolve');
const extractRootEl = card.querySelector('#sb-extract-root');
const modeEl = card.querySelector('#sb-import-mode');
const previewEl = card.querySelector('#sb-preview');
const importJsonBtn = card.querySelector('#sb-import-all-api-hub');
const importJsonFileBtn = card.querySelector('#sb-import-all-api-hub-file');
const hiddenFileInput = card.querySelector('#sb-hidden-file-input');
function updatePreview() {
const values = normalizeValues(
parseLines(textarea.value),
typeEl.value,
extractRootEl.checked
);
const type = typeEl.value;
const target = targetEl.value;
const noResolve = noResolveEl.checked;
const extractRoot = extractRootEl.checked;
const mode = modeEl.value === 'replace' ? '覆盖' : '追加';
if (!values.length) {
previewEl.textContent = '尚未填写';
return;
}
const sample = values.map(v =>
`${type},${v},${target}${noResolve ? ',no-resolve' : ''}`
).join('\n');
previewEl.innerHTML = `
共 <b>${values.length}</b> 条
模式:<b>${mode}</b>
类型:<b>${type}</b>
连接方式:<b>${escapeHtml(target)}</b>
no-resolve:<b>${noResolve ? '启用' : '关闭'}</b>
提取主域名:<b>${extractRoot ? '启用' : '关闭'}</b>
<pre style="
white-space:pre-wrap;
word-break:break-all;
margin:0;
color:#cfd3ff;
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;
">${escapeHtml(sample)}</pre>
`;
}
importJsonBtn.onclick = () => {
const jsonText = prompt('请粘贴 All API Hub 的 JSON 数据');
if (!jsonText) return;
try {
const urls = extractAllApiHubSiteUrls(jsonText);
if (!urls.length) {
alert('未提取到任何 site_url');
return;
}
appendLinesToTextarea(textarea, urls);
alert(`已提取 ${urls.length} 条 site_url 到输入框`);
} catch (err) {
console.error(err);
alert('导入失败:' + err.message);
}
};
importJsonFileBtn.onclick = () => {
hiddenFileInput.value = '';
hiddenFileInput.click();
};
hiddenFileInput.addEventListener('change', async () => {
const file = hiddenFileInput.files && hiddenFileInput.files[0];
if (!file) return;
try {
const text = await readFileAsText(file);
const urls = extractAllApiHubSiteUrls(text);
if (!urls.length) {
alert('未提取到任何 site_url');
return;
}
appendLinesToTextarea(textarea, urls);
alert(`已从文件提取 ${urls.length} 条 site_url 到输入框`);
} catch (err) {
console.error(err);
alert('文件导入失败:' + err.message);
}
});
textarea.addEventListener('input', updatePreview);
typeEl.addEventListener('change', updatePreview);
targetEl.addEventListener('change', updatePreview);
noResolveEl.addEventListener('change', updatePreview);
extractRootEl.addEventListener('change', updatePreview);
modeEl.addEventListener('change', updatePreview);
card.querySelector('#sb-close-panel').onclick = () => mask.style.display = 'none';
card.querySelector('#sb-cancel').onclick = () => mask.style.display = 'none';
mask.addEventListener('click', e => {
if (e.target === mask) mask.style.display = 'none';
});
card.querySelector('#sb-run').onclick = () => {
try {
const type = typeEl.value;
const target = targetEl.value;
const noResolve = noResolveEl.checked;
const extractRoot = extractRootEl.checked;
const mode = modeEl.value;
const previewValues = normalizeValues(
parseLines(textarea.value),
type,
extractRoot
);
if (!previewValues.length) {
throw new Error('请先输入规则值,一行一条');
}
const ok = confirm(
`确认执行?\n\n` +
`数量:${previewValues.length} 条\n` +
`规则类型:${type}\n` +
`连接方式:${target}\n` +
`no-resolve:${noResolve ? '启用' : '关闭'}\n` +
`提取主域名:${extractRoot ? '启用' : '关闭'}\n` +
`导入模式:${mode === 'replace' ? '覆盖' : '追加'}`
);
if (!ok) return;
const result = runImport({
values: textarea.value,
type,
target,
noResolve,
extractRoot,
mode
});
updatePreview();
alert(`执行完成\n\n新增:${result.added} 条\n跳过重复:${result.skipped} 条`);
location.reload();
} catch (err) {
console.error(err);
alert('执行失败:' + err.message);
}
};
updatePreview();
}
function runImport({ values, type, target, noResolve, extractRoot, mode }) {
const list = normalizeValues(parseLines(values), type, extractRoot);
if (!list.length) {
throw new Error('请先输入规则值,一行一条');
}
const data = loadConfig();
if (mode === 'replace') {
data.state.customRules = [];
}
const rules = data.state.customRules;
const exists = new Set(
rules.map(r => [
r?.type || '',
String(r?.value || '').trim().toLowerCase(),
r?.target || '',
Boolean(r?.noResolve)
].join('||'))
);
let added = 0;
let skipped = 0;
for (const value of list) {
const key = [type, value.toLowerCase(), target, Boolean(noResolve)].join('||');
if (exists.has(key)) {
skipped++;
continue;
}
rules.push({
id: makeId(),
type,
value,
target,
noResolve: Boolean(noResolve)
});
exists.add(key);
added++;
}
saveConfig(data);
return { added, skipped };
}
setInterval(() => {
if (document.body) createButton();
}, 1000);
})();