Greasy Fork Filter

Filter scripts by installs and keywords — with import/export

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Greasy Fork Filter
// @namespace    gf-filter
// @version      1.1
// @description  Filter scripts by installs and keywords — with import/export
// @match        https://greasyfork.org/*/scripts*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ─── State ────────────────────────────────────────────────────────────────
  const DEFAULTS = { daily: 0, total: 0, keywords: [] };

  const S = {
    daily:    GM_getValue('daily',    DEFAULTS.daily),
    total:    GM_getValue('total',    DEFAULTS.total),
    keywords: GM_getValue('keywords', DEFAULTS.keywords),
    open:     false,
  };

  const save = (k, v) => { S[k] = v; GM_setValue(k, v); };
  const resetAll = () => Object.entries(DEFAULTS).forEach(([k, v]) => save(k, v));

  // ─── Helpers ──────────────────────────────────────────────────────────────
  const debounce = (fn, ms) => {
    let t;
    return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
  };

  const el = (tag, attrs = {}, ...kids) => {
    const n = Object.assign(document.createElement(tag), attrs);
    kids.forEach(c => n.append(c));
    return n;
  };

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

  // ─── 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: 276px; 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: 7px;
    }
    .gf-row:last-child { margin-bottom: 0; }
    .gf-row-text { font-size: 13px; color: #3a3a3a; flex: 1; }

    .gf-num {
      width: 70px; height: 28px; padding: 0 9px;
      border: 1px solid rgba(0,0,0,0.13); border-radius: 7px;
      background: #f5f5f7; color: #1a1a1a;
      font-size: 13px; text-align: right; outline: none;
      transition: border-color .15s, box-shadow .15s;
      -moz-appearance: textfield;
    }
    .gf-num::-webkit-inner-spin-button { -webkit-appearance: none; }
    .gf-num:focus { border-color: #888; box-shadow: 0 0 0 3px rgba(0,0,0,0.07); }

    .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;
    }
    .gf-kw-input:focus { border-color: #888; box-shadow: 0 0 0 3px rgba(0,0,0,0.07); }

    .gf-add-btn {
      height: 28px; padding: 0 11px;
      border: 1px solid rgba(0,0,0,0.15); border-radius: 7px;
      background: #1a1a1a; color: #f0f0f0;
      font-size: 12px; font-weight: 500; cursor: pointer;
      transition: background .15s; white-space: nowrap;
    }
    .gf-add-btn:hover { background: #333; }
    .gf-add-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-io-row { display: flex; gap: 6px; margin-top: 11px; }
    .gf-io-btn {
      flex: 1; height: 28px; border-radius: 7px;
      border: 1px solid rgba(0,0,0,0.13);
      background: #f5f5f7; color: #3a3a3a;
      font-size: 12px; font-weight: 500; cursor: pointer;
      transition: background .15s, border-color .15s;
    }
    .gf-io-btn:hover { background: #e5e5e7; border-color: rgba(0,0,0,0.2); }

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

    .gf-footer {
      padding: 9px 15px; display: flex;
      align-items: center; justify-content: space-between;
    }
    .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;
    }
    .gf-clear:hover { opacity: 1; }

    @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-num,
      .gf-kw-input   { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #f0f0f2; }
      .gf-num:focus,
      .gf-kw-input:focus { border-color: #666; box-shadow: 0 0 0 3px rgba(255,255,255,0.06); }
      .gf-add-btn    { background: #3a3a3c; border-color: rgba(255,255,255,0.08); color: #e0e0e0; }
      .gf-add-btn:hover    { background: #4a4a4c; }
      .gf-add-btn:disabled { background: #2c2c2e; color: #555; }
      .gf-tag        { background: #3a3a3c; color: #ddd; }
      .gf-tag-x      { background: rgba(255,255,255,0.1); }
      .gf-io-btn     { background: #2c2c2e; border-color: rgba(255,255,255,0.1); color: #bbb; }
      .gf-io-btn:hover { background: #3a3a3c; }
      .gf-stat       { color: #555; }
      .gf-stat b     { color: #ccc; }
    }
  ` }));

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

  const applyFilter = debounce(() => {
    shown = 0; hidden = 0;
    const kws = S.keywords.map(k => k.toLowerCase());

    document.querySelectorAll('#browse-script-list > li[data-script-id]').forEach(li => {
      const daily    = +li.dataset.scriptDailyInstalls || 0;
      const total    = +li.dataset.scriptTotalInstalls || 0;
      const name     = (li.dataset.scriptName || '').toLowerCase();
      const desc     = (li.querySelector('.script-description')?.textContent || '').toLowerCase();
      const haystack = name + ' ' + desc;

      const filtered = daily < S.daily
                    || total < S.total
                    || kws.some(k => haystack.includes(k));

      li.style.display = filtered ? 'none' : '';
      filtered ? hidden++ : shown++;
    });

    updateStat();
  }, 120);

  // ─── Tag‑list builder ────────────────────────────────────────────────────
  const makeTagSection = (listKey, placeholder) => {
    const tagsEl = el('div', { className: 'gf-tags' });
    const input  = el('input', { className: 'gf-kw-input', type: 'text', placeholder });
    const addBtn = el('button', { className: 'gf-add-btn', textContent: 'Block', disabled: true });

    const render = () => {
      tagsEl.innerHTML = '';
      S[listKey].forEach(val => {
        const x = el('button', { className: 'gf-tag-x', textContent: '×' });
        x.addEventListener('click', e => {
          e.stopPropagation();              // prevent panel‑close handler
          save(listKey, S[listKey].filter(v => v !== val));
          render();
          applyFilter();
        });
        tagsEl.append(el('span', { className: 'gf-tag' }, val, x));
      });
    };

    const add = () => {
      const phrase = input.value.trim().toLowerCase();
      if (!phrase || S[listKey].includes(phrase)) {
        input.value = ''; addBtn.disabled = true; return;
      }
      save(listKey, [...S[listKey], phrase]);
      input.value = ''; addBtn.disabled = true;
      render();
      applyFilter();
    };

    input.addEventListener('input',   () => { addBtn.disabled = !input.value.trim(); });
    input.addEventListener('keydown', e => e.key === 'Enter' && add());
    addBtn.addEventListener('click',  add);

    return {
      tagsEl, input, addBtn, render,
      row: el('div', { className: 'gf-kw-row' }, input, addBtn),
    };
  };

  // ─── Import / Export ──────────────────────────────────────────────────────
  const exportSettings = () => {
    const json = JSON.stringify(
      { daily: S.daily, total: S.total, keywords: S.keywords }, 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', style: 'display:none' });
    fi.addEventListener('change', () => {
      const file = fi.files[0]; fi.remove();
      if (!file) return;
      const reader = new FileReader();
      reader.onload = e => {
        try {
          const d = JSON.parse(e.target.result);
          if (typeof d !== 'object' || d === null) throw 0;
          if (typeof d.daily === 'number')  save('daily', d.daily);
          if (typeof d.total === 'number')  save('total', d.total);
          if (Array.isArray(d.keywords))    save('keywords', d.keywords.map(k => String(k).toLowerCase()));
          syncUI();
          applyFilter();
          toast('Settings imported');
        } catch { toast('Invalid JSON file'); }
      };
      reader.readAsText(file);
    });
    document.body.append(fi); fi.click();
  };

  // ─── Build UI ─────────────────────────────────────────────────────────────
  const kwSection = makeTagSection('keywords', 'e.g. "mod menu", cheat…');
  const statEl    = el('span', { className: 'gf-stat' });
  const clearBtn  = el('button', { className: 'gf-clear', textContent: 'Reset all' });

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

  const makeNumRow = (label, key) => {
    const input = el('input', {
      className: 'gf-num', type: 'number', min: 0, value: S[key],
    });
    input.dataset.key = key;
    input.addEventListener('input', () => { save(key, +input.value || 0); applyFilter(); });
    return el('div', { className: 'gf-row' },
      el('span', { className: 'gf-row-text', textContent: label }), input
    );
  };

  const panel = el('div', { id: 'gf-panel', className: 'closed' },
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Install threshold' }),
      makeNumRow('Daily installs ≥', 'daily'),
      makeNumRow('Total installs ≥', 'total'),
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Block keywords' }),
      kwSection.row,
      kwSection.tagsEl,
    ),
    el('div', { className: 'gf-section' },
      el('div', { className: 'gf-label', textContent: 'Settings' }),
      el('div', { className: 'gf-io-row' },
        Object.assign(el('button', { className: 'gf-io-btn', textContent: 'Export JSON' }),
          { onclick: exportSettings }),
        Object.assign(el('button', { className: 'gf-io-btn', textContent: 'Import JSON' }),
          { onclick: importSettings }),
      ),
    ),
    el('div', { className: 'gf-footer' }, statEl, clearBtn),
  );

  // syncUI — defined after panel so querySelectorAll works at call‑time
  const syncUI = () => {
    panel.querySelectorAll('.gf-num').forEach(i => {
      const k = i.dataset.key;
      if (k) i.value = S[k];
    });
    kwSection.input.value = '';
    kwSection.addBtn.disabled = true;
    kwSection.render();
  };

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

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

  // ─── Toggle ───────────────────────────────────────────────────────────────
  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 => S.open && !panel.contains(e.target) && toggle(false));
  document.addEventListener('keydown', e => e.key === 'Escape' && toggle(false));

  // ─── Init ─────────────────────────────────────────────────────────────────
  kwSection.render();
  applyFilter();

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