SubBoost Import

SubBoost Import Script

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         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('&', '&amp;')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll("'", '&#39;');
  }

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