Greasy Fork Filter

Native Greasy Fork install filter sync + live keyword blocking

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Greasy Fork Filter
// @namespace    https://github.com/quantavil/userscript
// @version      1.4
// @description  Native Greasy Fork install filter sync + live keyword blocking
// @match        https://greatest.deepsurf.us/*/scripts*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  // ─── Config ───────────────────────────────────────────────────────────────
  const FIELDS = {
    daily: { label: 'Daily installs', param: 'daily_installs', data: 'scriptDailyInstalls' },
    total: { label: 'Total installs', param: 'total_installs', data: 'scriptTotalInstalls' },
  };

  const DEFAULTS = { keywords: [] };
  for (const key of Object.keys(FIELDS)) {
    DEFAULTS[key] = 0;
    DEFAULTS[`${key}Op`] = 'gt';
  }

  // ─── Helpers ──────────────────────────────────────────────────────────────
  const validOp = v => v === 'gt' || v === 'lt';
  const toNum = v => Math.max(0, parseInt(v, 10) || 0);
  const cleanKeywords = list => [...new Set(
    (Array.isArray(list) ? list : [])
      .map(v => String(v).trim().toLowerCase())
      .filter(Boolean)
  )];

  const debounce = (fn, ms = 120) => {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), ms);
    };
  };

  const el = (tag, attrs = {}, ...kids) => {
    const node = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (k === 'dataset' && v) Object.assign(node.dataset, v);
      else if (k === 'style' && typeof v === 'string') node.style.cssText = v;
      else if (k in node) node[k] = v;
      else node.setAttribute(k, v);
    }
    kids.flat().forEach(k => k != null && node.append(k));
    return node;
  };

  const toast = msg => {
    const t = el('div', { className: 'gf-toast', textContent: msg });
    document.body.append(t);
    setTimeout(() => t.remove(), 2000);
  };

  // ─── State ────────────────────────────────────────────────────────────────
  const S = { open: false };
  for (const [k, v] of Object.entries(DEFAULTS)) S[k] = GM_getValue(k, v);

  S.keywords = cleanKeywords(S.keywords);
  for (const key of Object.keys(FIELDS)) {
    S[key] = toNum(S[key]);
    S[`${key}Op`] = validOp(S[`${key}Op`]) ? S[`${key}Op`] : 'gt';
  }

  const save = (k, v) => {
    S[k] = v;
    GM_setValue(k, v);
  };

  const resetAll = () => {
    for (const [k, v] of Object.entries(DEFAULTS)) {
      save(k, Array.isArray(v) ? [] : v);
    }
  };

  const exportState = () =>
    Object.fromEntries(Object.keys(DEFAULTS).map(k => [k, S[k]]));

  // If current URL has native GF filter params, reflect them into local state.
  (() => {
    const p = new URL(location.href).searchParams;
    for (const [key, field] of Object.entries(FIELDS)) {
      const value = p.get(field.param);
      const op = p.get(`${field.param}_operator`);
      if (value !== null && value !== '') save(key, toNum(value));
      if (validOp(op)) save(`${key}Op`, op);
    }
  })();

  // ─── CSS ──────────────────────────────────────────────────────────────────
  document.head.append(el('style', { textContent: `
    #gf-fab {
      position: fixed; bottom: 28px; right: 28px; z-index: 99999;
      width: 40px; height: 40px; border-radius: 10px;
      background: #1a1a1a; border: 1px solid rgba(255,255,255,0.08);
      cursor: pointer; display: grid; place-items: center;
      box-shadow: 0 2px 12px rgba(0,0,0,0.3);
      transition: transform .18s, background .18s;
    }
    #gf-fab:hover { transform: scale(1.06); background: #2a2a2a; }
    #gf-fab.active { background: #111; }
    #gf-fab svg { color: #e0e0e0; display: block; pointer-events: none; }

    #gf-panel {
      position: fixed; bottom: 78px; right: 28px; z-index: 99999;
      width: 296px; background: #fff;
      border: 1px solid rgba(0,0,0,0.09); border-radius: 14px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.13), 0 2px 8px rgba(0,0,0,0.05);
      font: 13px/1.45 -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
      color: #1a1a1a; transform-origin: bottom right;
      transition: transform .22s cubic-bezier(.34,1.56,.64,1), opacity .16s;
    }
    #gf-panel.closed {
      transform: scale(0.86) translateY(10px);
      opacity: 0; pointer-events: none;
    }

    .gf-section { padding: 13px 15px; border-bottom: 1px solid rgba(0,0,0,0.06); }
    .gf-section:last-child { border-bottom: none; }

    .gf-label {
      font-size: 10.5px; font-weight: 600;
      letter-spacing: .06em; text-transform: uppercase;
      color: #999; margin-bottom: 10px;
    }

    .gf-row {
      display: flex; align-items: center;
      justify-content: space-between; gap: 10px; margin-bottom: 8px;
    }
    .gf-row:last-child { margin-bottom: 0; }
    .gf-row-text { font-size: 13px; color: #3a3a3a; flex: 1; }

    .gf-control { display: flex; align-items: center; gap: 6px; }

    .gf-op-wrap { position: relative; }
    .gf-op-wrap::after {
      content: '▾';
      position: absolute; right: 9px; top: 50%;
      transform: translateY(-50%);
      pointer-events: none; color: #777; font-size: 10px;
    }

    .gf-op,
    .gf-num {
      height: 28px; border: 1px solid rgba(0,0,0,0.13);
      border-radius: 7px; background: #f5f5f7; color: #1a1a1a;
      font-size: 13px; outline: none;
      transition: border-color .15s, box-shadow .15s, background .15s;
      box-sizing: border-box;
    }

    .gf-op {
      width: 58px; padding: 0 25px 0 9px;
      appearance: none; -webkit-appearance: none; -moz-appearance: none;
      cursor: pointer;
    }

    .gf-num {
      width: 78px; padding: 0 9px; text-align: right;
      -moz-appearance: textfield;
    }
    .gf-num::-webkit-inner-spin-button { -webkit-appearance: none; }

    .gf-op:focus,
    .gf-num:focus,
    .gf-kw-input:focus {
      border-color: #888; box-shadow: 0 0 0 3px rgba(0,0,0,0.07);
    }

    .gf-note {
      margin-top: 10px; font-size: 11.5px; line-height: 1.45;
      color: #8a8a8a;
    }

    .gf-kw-row { display: flex; gap: 6px; margin-bottom: 9px; }
    .gf-kw-input {
      flex: 1; height: 28px; padding: 0 9px;
      border: 1px solid rgba(0,0,0,0.13); border-radius: 7px;
      background: #f5f5f7; color: #1a1a1a;
      font-size: 13px; outline: none;
      transition: border-color .15s, box-shadow .15s;
      box-sizing: border-box;
    }

    .gf-actions { display: flex; gap: 6px; flex-wrap: wrap; }

    .gf-btn {
      height: 28px; padding: 0 11px;
      border: 1px solid rgba(0,0,0,0.13); border-radius: 7px;
      background: #f5f5f7; color: #3a3a3a;
      font-size: 12px; font-weight: 500; cursor: pointer;
      transition: background .15s, border-color .15s, opacity .15s;
      white-space: nowrap;
    }
    .gf-btn:hover { background: #e5e5e7; border-color: rgba(0,0,0,0.2); }

    .gf-btn.primary {
      background: #1a1a1a; color: #f0f0f0;
      border-color: rgba(0,0,0,0.15);
    }
    .gf-btn.primary:hover { background: #333; }

    .gf-btn:disabled {
      background: #e5e5e7; color: #bbb;
      border-color: transparent; cursor: default;
    }

    .gf-tags { display: flex; flex-wrap: wrap; gap: 5px; min-height: 4px; }
    .gf-tag {
      display: inline-flex; align-items: center; gap: 4px;
      padding: 3px 6px 3px 9px; background: #ebebed; color: #2a2a2a;
      border-radius: 20px; font-size: 11.5px; font-weight: 500;
      animation: gf-pop .14s ease;
    }
    @keyframes gf-pop {
      from { transform: scale(0.72); opacity: 0; }
      to   { transform: scale(1); opacity: 1; }
    }

    .gf-tag-x {
      display: grid; place-items: center;
      width: 15px; height: 15px; border-radius: 50%;
      background: rgba(0,0,0,0.1); border: none;
      cursor: pointer; color: inherit;
      font-size: 11px; line-height: 1; padding: 0;
      transition: background .12s;
    }
    .gf-tag-x:hover { background: #e63946; color: #fff; }

    .gf-footer {
      padding: 9px 15px; display: flex;
      align-items: center; justify-content: space-between;
      gap: 12px;
    }
    .gf-stat { font-size: 12px; color: #999; }
    .gf-stat b { color: #1a1a1a; font-weight: 600; }

    .gf-clear {
      font-size: 12px; color: #e63946; background: none;
      border: none; cursor: pointer; padding: 0;
      opacity: .75; transition: opacity .12s;
      white-space: nowrap;
    }
    .gf-clear:hover { opacity: 1; }

    .gf-toast {
      position: fixed; bottom: 78px; right: 28px; z-index: 100000;
      padding: 8px 14px; border-radius: 8px;
      background: #1a1a1a; color: #f0f0f0;
      font: 12px/1 -apple-system, system-ui, sans-serif;
      pointer-events: none;
      animation: gf-toast-in .2s ease, gf-toast-out .25s ease 1.6s forwards;
    }
    @keyframes gf-toast-in  { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
    @keyframes gf-toast-out { to   { opacity:0; transform:translateY(4px); } }

    @media (prefers-color-scheme: dark) {
      #gf-panel {
        background: #1c1c1e; border-color: rgba(255,255,255,0.09);
        color: #f0f0f2; box-shadow: 0 12px 40px rgba(0,0,0,0.5);
      }
      .gf-section      { border-bottom-color: rgba(255,255,255,0.06); }
      .gf-label        { color: #555; }
      .gf-row-text     { color: #bbb; }
      .gf-op-wrap::after { color: #777; }
      .gf-op,
      .gf-num,
      .gf-kw-input     { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #f0f0f2; }
      .gf-op:focus,
      .gf-num:focus,
      .gf-kw-input:focus { border-color: #666; box-shadow: 0 0 0 3px rgba(255,255,255,0.06); }
      .gf-note         { color: #757575; }
      .gf-btn          { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #bbb; }
      .gf-btn:hover    { background: #3a3a3c; }
      .gf-btn.primary  { background: #3a3a3c; border-color: rgba(255,255,255,0.08); color: #e0e0e0; }
      .gf-btn.primary:hover { background: #4a4a4c; }
      .gf-btn:disabled { background: #2c2c2e; color: #555; }
      .gf-tag          { background: #3a3a3c; color: #ddd; }
      .gf-tag-x        { background: rgba(255,255,255,0.1); }
      .gf-stat         { color: #555; }
      .gf-stat b       { color: #ccc; }
    }
  ` }));

  // ─── URL Sync ─────────────────────────────────────────────────────────────
  const buildUrl = () => {
    const url = new URL(location.href);
    const p = url.searchParams;

    p.delete('page');

    for (const [key, field] of Object.entries(FIELDS)) {
      const opKey = `${field.param}_operator`;
      if (S[key] > 0) {
        p.set(field.param, String(S[key]));
        p.set(opKey, S[`${key}Op`]);
      } else {
        p.delete(field.param);
        p.delete(opKey);
      }
    }

    return url.toString();
  };

  const isUrlSynced = () => {
    const p = new URL(location.href).searchParams;
    return Object.entries(FIELDS).every(([key, field]) => {
      const active = S[key] > 0;
      return (p.get(field.param) || '') === (active ? String(S[key]) : '') &&
             (p.get(`${field.param}_operator`) || '') === (active ? S[`${key}Op`] : '');
    });
  };

  const updateSyncBtn = () => {
    const synced = isUrlSynced();
    syncBtn.disabled = synced;
    syncBtn.textContent = synced ? 'URL synced' : 'Sync to URL';
  };

  // ─── Filtering ────────────────────────────────────────────────────────────
  let shown = 0, hidden = 0;

  const passes = (value, key) =>
    !S[key] || (S[`${key}Op`] === 'gt' ? value > S[key] : value < S[key]);

  const updateStat = () => {
    statEl.innerHTML = `<b>${shown}</b> shown &nbsp;·&nbsp; <b>${hidden}</b> hidden`;
  };

  const applyFilter = debounce(() => {
    shown = 0;
    hidden = 0;

    document.querySelectorAll('#browse-script-list > li[data-script-id]').forEach(li => {
      const text = `${li.dataset.scriptName || ''} ${li.querySelector('.script-description')?.textContent || ''}`.toLowerCase();
      const blocked = S.keywords.some(k => text.includes(k));
      const badNums = Object.entries(FIELDS).some(([key, field]) =>
        !passes(+li.dataset[field.data] || 0, key)
      );

      li.hidden = blocked || badNums;
      li.hidden ? hidden++ : shown++;
    });

    updateStat();
    updateSyncBtn();
  }, 120);

  // ─── UI Builders ──────────────────────────────────────────────────────────
  const makeFieldRow = ([key, field]) => {
    const op = el('select', { className: 'gf-op', dataset: { key: `${key}Op` } },
      el('option', { value: 'gt', textContent: '>' }),
      el('option', { value: 'lt', textContent: '<' }),
    );
    op.value = S[`${key}Op`];

    const input = el('input', {
      className: 'gf-num',
      type: 'number',
      min: 0,
      value: S[key],
      dataset: { key },
    });

    return el('div', { className: 'gf-row' },
      el('span', { className: 'gf-row-text', textContent: field.label }),
      el('div', { className: 'gf-control' },
        el('div', { className: 'gf-op-wrap' }, op),
        input,
      ),
    );
  };

  const renderTags = () => {
    tagsEl.textContent = '';
    S.keywords.forEach(kw => {
      tagsEl.append(
        el('span', { className: 'gf-tag' },
          kw,
          el('button', { className: 'gf-tag-x', textContent: '×', dataset: { kw } }),
        )
      );
    });
  };

  const syncUI = () => {
    panel.querySelectorAll('[data-key]').forEach(node => {
      node.value = S[node.dataset.key];
    });
    kwInput.value = '';
    addBtn.disabled = true;
    renderTags();
    updateSyncBtn();
  };

  // ─── Import / Export ──────────────────────────────────────────────────────
  const exportSettings = () => {
    const json = JSON.stringify(exportState(), null, 2);
    const a = el('a', {
      href: 'data:application/json;charset=utf-8,' + encodeURIComponent(json),
      download: 'gf-filter-settings.json',
    });
    document.body.append(a);
    a.click();
    a.remove();
    toast('Settings exported');
  };

  const importSettings = () => {
    const fi = el('input', { type: 'file', accept: '.json', hidden: true });
    fi.addEventListener('change', () => {
      const file = fi.files?.[0];
      fi.remove();
      if (!file) return;

      const reader = new FileReader();
      reader.onload = () => {
        try {
          const data = JSON.parse(String(reader.result || '{}'));

          for (const key of Object.keys(FIELDS)) {
            if (key in data) save(key, toNum(data[key]));
            if (validOp(data[`${key}Op`])) save(`${key}Op`, data[`${key}Op`]);
          }
          if ('keywords' in data) save('keywords', cleanKeywords(data.keywords));

          syncUI();
          applyFilter();
          toast('Settings imported');
        } catch {
          toast('Invalid JSON file');
        }
      };
      reader.readAsText(file);
    });
    document.body.append(fi);
    fi.click();
  };

  // ─── Build UI ─────────────────────────────────────────────────────────────
  const statEl = el('span', { className: 'gf-stat' });
  const syncBtn = el('button', { className: 'gf-btn primary', textContent: 'Sync to URL' });
  const exportBtn = el('button', { className: 'gf-btn', textContent: 'Export JSON' });
  const importBtn = el('button', { className: 'gf-btn', textContent: 'Import JSON' });
  const clearBtn = el('button', { className: 'gf-clear', textContent: 'Reset all' });

  const kwInput = el('input', {
    className: 'gf-kw-input',
    type: 'text',
    placeholder: 'e.g. "mod menu", cheat…',
  });
  const addBtn = el('button', {
    className: 'gf-btn primary',
    textContent: 'Block',
    disabled: true,
  });
  const tagsEl = el('div', { className: 'gf-tags' });

  const panel = el('div', { id: 'gf-panel', className: 'closed' },
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Install threshold' }),
      ...Object.entries(FIELDS).map(makeFieldRow),
      el('div', {
        className: 'gf-note',
        textContent: 'Sync to URL reloads the page and uses Greasy Fork’s native install filters. Keyword blocking stays live and local.',
      }),
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Block keywords' }),
      el('div', { className: 'gf-kw-row' }, kwInput, addBtn),
      tagsEl,
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Settings' }),
      el('div', { className: 'gf-actions' }, syncBtn, exportBtn, importBtn),
    ),
    el('div', { className: 'gf-footer' }, statEl, clearBtn),
  );

  const fab = el('button', {
    id: 'gf-fab',
    title: 'Filter scripts',
    innerHTML: `
      <svg width="16" height="16" viewBox="0 0 16 16" fill="none"
           stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
        <path d="M2 4h12"/><path d="M4.5 8h7"/><path d="M7 12h2"/>
      </svg>
    `,
  });

  document.body.append(fab, panel);

  // ─── Events ───────────────────────────────────────────────────────────────
  const addKeyword = () => {
    const kw = kwInput.value.trim().toLowerCase();
    if (!kw) return;
    if (!S.keywords.includes(kw)) save('keywords', [...S.keywords, kw]);
    kwInput.value = '';
    addBtn.disabled = true;
    renderTags();
    applyFilter();
  };

  kwInput.addEventListener('input', () => {
    addBtn.disabled = !kwInput.value.trim();
  });

  kwInput.addEventListener('keydown', e => {
    if (e.key === 'Enter') addKeyword();
  });

  addBtn.addEventListener('click', addKeyword);

  tagsEl.addEventListener('click', e => {
    const btn = e.target.closest('button[data-kw]');
    if (!btn) return;
    save('keywords', S.keywords.filter(k => k !== btn.dataset.kw));
    renderTags();
    applyFilter();
  });

  panel.addEventListener('input', e => {
    const key = e.target.dataset.key;
    if (!key) return;
    save(key, key.endsWith('Op') ? (validOp(e.target.value) ? e.target.value : 'gt') : toNum(e.target.value));
    applyFilter();
  });

  panel.addEventListener('change', e => {
    const key = e.target.dataset.key;
    if (!key) return;
    save(key, key.endsWith('Op') ? (validOp(e.target.value) ? e.target.value : 'gt') : toNum(e.target.value));
    applyFilter();
  });

  exportBtn.addEventListener('click', exportSettings);
  importBtn.addEventListener('click', importSettings);

  syncBtn.addEventListener('click', () => {
    if (isUrlSynced()) return toast('Already synced');
    location.assign(buildUrl());
  });

  clearBtn.addEventListener('click', () => {
    resetAll();
    syncUI();
    applyFilter();
  });

  const toggle = force => {
    S.open = force ?? !S.open;
    panel.classList.toggle('closed', !S.open);
    fab.classList.toggle('active', S.open);
  };

  fab.addEventListener('click', e => {
    e.stopPropagation();
    toggle();
  });

  document.addEventListener('click', e => {
    if (S.open && !panel.contains(e.target) && !fab.contains(e.target)) toggle(false);
  });

  document.addEventListener('keydown', e => {
    if (e.key === 'Escape') toggle(false);
  });

  // ─── Init ─────────────────────────────────────────────────────────────────
  syncUI();
  applyFilter();

  const list = document.getElementById('browse-script-list');
  if (list) new MutationObserver(applyFilter).observe(list, { childList: true });
})();