// ==UserScript==
// @name UserScript Finder
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Finds GreasyFork/SleazyFork/GitHub scripts for the current domain
// @author SysAdminDoc
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @icon https://raw.githubusercontent.com/SysAdminDoc/UserScript-Finder/main/img/icon.png
// @connect greasyfork.org
// @connect sleazyfork.org
// @connect api.github.com
// @connect raw.githubusercontent.com
// @license WTFPL
// @run-at document-idle
// @downloadURL https://raw.githubusercontent.com/SysAdminDoc/UserScript-Finder/main/UserScript-Finder.user.js
// @updateURL https://raw.githubusercontent.com/SysAdminDoc/UserScript-Finder/main/UserScript-Finder.user.js
// @homepageURL https://github.com/SysAdminDoc/UserScript-Finder
// @supportURL https://github.com/SysAdminDoc/UserScript-Finder/issues
// ==/UserScript==
(function() {
"use strict";
try { if (window.self !== window.top) return; } catch(e) { return; }
// ── TrustedHTML policy ──────────────────────────────────────────────
const _ttPolicy = (typeof trustedTypes !== 'undefined' && trustedTypes.createPolicy)
? trustedTypes.createPolicy('gf-script-finder', { createHTML: s => s })
: { createHTML: s => s };
function _safeHTML(el, html) { el.innerHTML = _ttPolicy.createHTML(html); }
// ── Default Settings ────────────────────────────────────────────────
const DEFAULT_SETTINGS = {
cacheDuration: 5 * 60 * 1000,
defaultSort: "daily",
denseMode: false,
lastService: "greasyfork"
};
// ── Catppuccin Mocha + OLED palette ─────────────────────────────────
const THEME = {
base: '#0a0a0f',
mantle: '#0f0f17',
crust: '#06060a',
surface0: '#14141f',
surface1: '#1a1a2a',
surface2: '#232336',
overlay0: '#2e2e44',
overlay1: '#3a3a55',
text: '#cdd6f4',
subtext1: '#bac2de',
subtext0: '#a6adc8',
overlay: '#7f849c',
green: '#a6e3a1',
greenDim: '#40b65e',
teal: '#94e2d5',
purple: '#cba6f7',
purpleDim:'#a855c7',
mauve: '#b4befe',
red: '#f38ba8',
peach: '#fab387',
yellow: '#f9e2af',
blue: '#89b4fa',
flamingo: '#f2cdcd',
rosewater:'#f5e0dc',
glass: 'rgba(14, 14, 22, 0.82)',
glassBorder: 'rgba(255, 255, 255, 0.06)',
glassHover: 'rgba(255, 255, 255, 0.03)',
glow: 'rgba(166, 227, 161, 0.15)',
glowPurple: 'rgba(203, 166, 247, 0.15)',
github: '#f0883e',
githubDim: '#d2691e',
glowGithub: 'rgba(240, 136, 62, 0.15)',
shadow: 'rgba(0, 0, 0, 0.5)'
};
// ── Icons (Phosphor) ────────────────────────────────────────────────
const ICONS = {
moon: '',
search: '',
scales: '',
user: '',
gitBranch: '',
download: '',
chartBar: '',
star: '',
flame: '',
clockwise: '',
calendarPlus: '',
install: '',
gear: '',
x: '',
eyeSlash: '',
arrowsHorizontal: '',
rows: '',
undo: '',
githubLogo: '',
gitFork: ''
};
function getIcon(name) { return ICONS[name] || ''; }
// ── Utility ─────────────────────────────────────────────────────────
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text || "";
return div.innerHTML;
}
function relativeTime(iso) {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
const now = Date.now();
const diff = now - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function formatNumber(num) {
const n = Number(num);
if (!Number.isFinite(n)) return null;
if (n >= 1000000) return (n / 1000000).toFixed(1).replace('.0', '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace('.0', '') + 'k';
return n.toString();
}
// ── Settings Service ────────────────────────────────────────────────
class SettingsService {
constructor() { this.settings = this.loadSettings(); }
loadSettings() { return { ...DEFAULT_SETTINGS, ...GM_getValue("sf_settings_v4", {}) }; }
saveSettings() { GM_setValue("sf_settings_v4", this.settings); }
get(key) { return this.settings[key]; }
set(key, value) { this.settings[key] = value; this.saveSettings(); }
}
// ── Host Service ────────────────────────────────────────────────────
class HostService {
static getCurrentHost() { return window.location.hostname.replace(/^(www\.|m\.|mobile\.)/, ""); }
static extractRootDomain(host) {
const parts = host.split('.');
if (parts.length <= 2) return host;
const ccTLDs = ['com','net','org','edu','gov','mil','co','ac'];
if (ccTLDs.includes(parts[parts.length - 2])) return parts.slice(-3).join('.');
return parts.slice(-2).join('.');
}
}
// ── Script Service ──────────────────────────────────────────────────
class ScriptService {
constructor(baseUrl, serviceName) {
this.baseUrl = baseUrl;
this.serviceName = serviceName;
this.cache = new Map();
}
async searchScriptsByHost(host, settings) {
let scripts = await this._searchWithDomain(host, settings);
if (scripts.length === 0) {
const root = HostService.extractRootDomain(host);
if (root !== host) scripts = await this._searchWithDomain(root, settings);
}
return scripts;
}
async _searchWithDomain(domain, settings) {
const cacheKey = `${this.serviceName}_${domain}`;
const cacheDuration = settings.get("cacheDuration");
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < cacheDuration) return cached.data;
let scripts = [];
try {
scripts = await this._fetchBySite(domain);
} catch {
try { scripts = await this._fetchSearch(domain); } catch { scripts = []; }
}
const filtered = this._filter(scripts, domain);
this.cache.set(cacheKey, { data: filtered, timestamp: Date.now() });
return filtered;
}
_fetch(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url, headers: { Accept: "application/json" },
onload: r => {
if (r.status === 200) { try { resolve(JSON.parse(r.responseText)); } catch(e) { reject(e); } }
else if (r.status === 404) resolve([]);
else reject(new Error(`HTTP ${r.status}`));
},
onerror: reject
});
});
}
_fetchBySite(domain) { return this._fetch(`${this.baseUrl}/scripts/by-site/${domain}.json`); }
_fetchSearch(domain) { return this._fetch(`${this.baseUrl}/scripts.json?q=${encodeURIComponent(domain)}&sort=updated`); }
_filter(scripts, domain) {
const root = HostService.extractRootDomain(domain);
return scripts.filter(s => {
if (!s.domains) return true;
return s.domains.some(d =>
d === domain || d === `*.${domain}` ||
d === root || d === `*.${root}` ||
domain.includes(d.replace('*.','')) ||
d.replace('*.','').includes(domain)
);
}).slice(0, 200);
}
getDirectSearchUrl(domain) { return `${this.baseUrl}/scripts/by-site/${domain}`; }
}
// ── GitHub Script Service ───────────────────────────────────────────
class GitHubScriptService {
constructor() {
this.serviceName = "github";
this.cache = new Map();
}
async searchScriptsByHost(host, settings) {
const cacheKey = `github_${host}`;
const cacheDuration = settings.get("cacheDuration");
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < cacheDuration) return cached.data;
let results = [];
try {
// Search repos mentioning the domain + userscript keywords
const queries = [
`${host} userscript`,
`${host} tampermonkey`,
`${host} greasemonkey`
];
const seen = new Set();
for (const q of queries) {
try {
const data = await this._fetchAPI(
`https://api.github.com/search/repositories?q=${encodeURIComponent(q)}+language:javascript&sort=stars&per_page=20`
);
if (data?.items) {
for (const repo of data.items) {
if (!seen.has(repo.full_name)) {
seen.add(repo.full_name);
results.push(this._normalize(repo));
}
}
}
} catch { /* rate limit or network — continue */ }
}
} catch { results = []; }
this.cache.set(cacheKey, { data: results, timestamp: Date.now() });
return results;
}
_fetchAPI(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url,
headers: { Accept: "application/vnd.github.v3+json", 'User-Agent': 'ScriptFinder/4' },
onload: r => {
if (r.status === 200) { try { resolve(JSON.parse(r.responseText)); } catch(e) { reject(e); } }
else if (r.status === 403) reject(new Error("GitHub rate limit — try again in a minute"))
else reject(new Error(`GitHub API ${r.status}`));
},
onerror: reject
});
});
}
_normalize(repo) {
return {
_source: "github",
name: repo.name,
description: repo.description || "",
url: repo.html_url,
code_url: null,
version: null,
license: repo.license?.spdx_id || null,
users: [{ name: repo.owner?.login }],
daily_installs: null,
total_installs: null,
good_ratings: repo.stargazers_count || 0,
fan_score: null,
code_updated_at: repo.updated_at,
created_at: repo.created_at,
_stars: repo.stargazers_count || 0,
_forks: repo.forks_count || 0,
_language: repo.language,
_topics: repo.topics || [],
_owner: repo.owner?.login,
_full_name: repo.full_name
};
}
getDirectSearchUrl(domain) {
return `https://github.com/search?q=${encodeURIComponent(domain + ' userscript')}&type=repositories&l=JavaScript`;
}
}
// ── Toast Service ───────────────────────────────────────────────────
class ToastService {
constructor(shadowRoot) { this.root = shadowRoot; this.el = null; this.timer = null; this.undoCallback = null; }
show(message, undoCallback = null) {
this.hide();
this.undoCallback = undoCallback;
this.el = document.createElement("div");
this.el.className = "sf-toast";
const msgSpan = document.createElement("span");
msgSpan.textContent = message;
this.el.appendChild(msgSpan);
if (undoCallback) {
const btn = document.createElement("button");
btn.className = "sf-toast-undo";
btn.textContent = "Undo";
btn.addEventListener("click", () => { undoCallback(); this.hide(); });
this.el.appendChild(btn);
}
this.root.appendChild(this.el);
requestAnimationFrame(() => requestAnimationFrame(() => this.el.classList.add("show")));
this.timer = setTimeout(() => this.hide(), undoCallback ? 5000 : 3000);
}
hide() {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
if (this.el) {
this.el.classList.remove("show");
const old = this.el;
setTimeout(() => old.remove(), 350);
this.el = null;
}
}
}
// ── CSS ─────────────────────────────────────────────────────────────
const CSS = `
/* ── HOST ── */
:host {
all: initial !important;
display: block !important;
position: fixed !important;
bottom: 0 !important;
right: 0 !important;
z-index: 2147483647 !important;
font-family: -apple-system,BlinkMacSystemFont,system-ui,sans-serif !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
overflow: visible !important;
}
/* ── ANIMATIONS ── */
@keyframes sfSpin { to { transform: rotate(360deg); } }
@keyframes sfSlideUp {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes sfFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sfShimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
@keyframes sfModalIn {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes sfModalOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(10px) scale(0.97); }
}
/* ── TOAST ── */
.sf-toast {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%) translateY(-20px);
background: ${THEME.surface1}; color: ${THEME.text};
padding: 12px 20px; border-radius: 12px; font: 600 13px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;
box-shadow: 0 12px 40px ${THEME.shadow}; border: 1px solid ${THEME.glassBorder};
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
opacity: 0; transition: all 0.35s cubic-bezier(0.34,1.56,0.64,1);
z-index: 2147483647; pointer-events: auto; display: flex; align-items: center; gap: 12px;
max-width: 440px; width: max-content;
}
.sf-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.sf-toast-undo {
background: ${THEME.green}; color: ${THEME.base}; border: none; padding: 4px 12px;
border-radius: 6px; font: 700 12px/1 inherit; cursor: pointer;
transition: all 0.15s ease; white-space: nowrap;
}
.sf-toast-undo:hover { filter: brightness(1.1); transform: scale(1.04); }
/* ── MODAL ── */
.sf-modal {
position: fixed; bottom: 14px; right: 14px; width: 500px;
max-height: min(84vh, 800px);
background: ${THEME.base}; border-radius: 16px;
border: 1px solid ${THEME.glassBorder};
box-shadow: 0 32px 80px ${THEME.shadow}, 0 0 0 1px rgba(255,255,255,0.02);
overflow: hidden; display: flex; flex-direction: column;
opacity: 0; pointer-events: none;
transform: translateY(10px) scale(0.97);
transition: all 0.3s cubic-bezier(0.34,1.56,0.64,1);
}
.sf-modal.visible {
opacity: 1; pointer-events: auto; transform: translateY(0) scale(1);
}
/* Header */
.sf-modal-header {
padding: 16px 20px; position: relative;
background: linear-gradient(180deg, ${THEME.surface0} 0%, ${THEME.base} 100%);
border-bottom: 1px solid ${THEME.glassBorder};
}
.sf-modal-header::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, ${THEME.green}, ${THEME.teal});
transition: background 0.3s ease;
}
.sf-modal-header.sleazyfork::before { background: linear-gradient(90deg, ${THEME.purple}, ${THEME.mauve}); }
.sf-modal-header.github::before { background: linear-gradient(90deg, ${THEME.github}, ${THEME.peach}); }
.sf-header-row { display: flex; align-items: center; gap: 12px; }
.sf-header-left { flex: 1; min-width: 0; }
.sf-modal-title {
font: 700 16px/1.3 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;
color: ${THEME.text}; margin: 0 0 4px 0; letter-spacing: -0.3px;
background: linear-gradient(90deg, ${THEME.text}, ${THEME.subtext1}, ${THEME.text});
background-size: 200% auto;
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
animation: sfShimmer 4s linear infinite;
}
.sf-modal-subtitle {
font: 600 12px/1 inherit; color: ${THEME.subtext0}; margin: 0;
display: flex; align-items: center; gap: 6px;
}
.sf-subtitle-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px;
background: ${THEME.surface2}; border-radius: 6px;
font: 700 11px/1 inherit; color: ${THEME.text};
}
.sf-header-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid ${THEME.glassBorder};
cursor: pointer; background: ${THEME.surface1}; color: ${THEME.subtext0};
display: grid; place-items: center; transition: all 0.2s ease; flex-shrink: 0;
}
.sf-header-btn:hover { background: ${THEME.surface2}; color: ${THEME.text}; transform: scale(1.06); }
.sf-header-btn:active { transform: scale(0.94); }
.sf-header-btn svg { width: 14px; height: 14px; }
/* Search bar */
.sf-search-wrap {
padding: 0 20px 12px; background: transparent; margin-top: -2px;
}
.sf-search-box {
display: flex; align-items: center; gap: 8px;
background: ${THEME.surface0}; border: 1px solid ${THEME.glassBorder};
border-radius: 10px; padding: 0 12px; height: 36px;
transition: all 0.2s ease;
}
.sf-search-box:focus-within { border-color: ${THEME.green}33; box-shadow: 0 0 0 3px ${THEME.green}11; }
.sf-search-box.sleazyfork:focus-within { border-color: ${THEME.purple}33; box-shadow: 0 0 0 3px ${THEME.purple}11; }
.sf-search-box.github:focus-within { border-color: ${THEME.github}33; box-shadow: 0 0 0 3px ${THEME.github}11; }
.sf-search-box svg { width: 14px; height: 14px; color: ${THEME.overlay}; flex-shrink: 0; }
.sf-search-input {
flex: 1; border: none; background: transparent; outline: none;
font: 500 13px/1 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;
color: ${THEME.text}; padding: 0;
}
.sf-search-input::placeholder { color: ${THEME.overlay}; }
.sf-search-count { font: 600 11px/1 inherit; color: ${THEME.overlay}; white-space: nowrap; }
/* Tabs */
.sf-tabs {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px;
padding: 10px 20px; background: ${THEME.surface0}44;
border-bottom: 1px solid ${THEME.glassBorder};
}
.sf-tab {
padding: 9px 10px; border: 1px solid ${THEME.glassBorder}; border-radius: 8px;
cursor: pointer; background: ${THEME.surface0}; font: 600 12px/1 inherit;
color: ${THEME.subtext0}; transition: all 0.2s ease; position: relative;
text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sf-tab:hover { background: ${THEME.surface1}; color: ${THEME.text}; }
.sf-tab.active {
background: linear-gradient(135deg, ${THEME.greenDim}, ${THEME.green}33);
color: ${THEME.green}; border-color: ${THEME.green}33;
box-shadow: 0 0 16px ${THEME.glow};
}
.sf-tab.sleazyfork.active {
background: linear-gradient(135deg, ${THEME.purpleDim}44, ${THEME.purple}22);
color: ${THEME.purple}; border-color: ${THEME.purple}33;
box-shadow: 0 0 16px ${THEME.glowPurple};
}
.sf-tab.github.active {
background: linear-gradient(135deg, ${THEME.githubDim}44, ${THEME.github}22);
color: ${THEME.github}; border-color: ${THEME.github}33;
box-shadow: 0 0 16px ${THEME.glowGithub};
}
/* Sort bar */
.sf-sort-bar {
padding: 10px 20px; background: ${THEME.surface0}22;
border-bottom: 1px solid ${THEME.glassBorder};
display: flex; align-items: center; gap: 10px;
}
.sf-sort-label { font: 600 12px/1 inherit; color: ${THEME.subtext0}; flex-shrink: 0; }
.sf-sort-select {
flex: 1; padding: 7px 10px; border-radius: 8px;
border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0};
color: ${THEME.text}; font: 500 12px/1 inherit; cursor: pointer; outline: none;
transition: border-color 0.2s ease;
}
.sf-sort-select option { background: ${THEME.surface1}; color: ${THEME.text}; }
.sf-sort-select:focus { border-color: ${THEME.green}44; }
.sf-sort-select.sleazyfork:focus { border-color: ${THEME.purple}44; }
.sf-sort-select.github:focus { border-color: ${THEME.github}44; }
/* Content */
.sf-content {
flex: 1; overflow-y: auto; background: ${THEME.base};
scrollbar-width: thin; scrollbar-color: ${THEME.surface2} transparent;
}
.sf-content::-webkit-scrollbar { width: 6px; }
.sf-content::-webkit-scrollbar-track { background: transparent; }
.sf-content::-webkit-scrollbar-thumb { background: ${THEME.surface2}; border-radius: 3px; }
.sf-content::-webkit-scrollbar-thumb:hover { background: ${THEME.overlay0}; }
/* Script items */
.sf-item {
padding: 14px 20px; border-bottom: 1px solid ${THEME.glassBorder};
cursor: pointer; position: relative; background: transparent;
transition: all 0.2s ease;
animation: sfFadeIn 0.3s ease both;
}
.sf-item:hover { background: ${THEME.glassHover}; transform: translateY(-1px); }
.sf-item:hover::after {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
background: linear-gradient(180deg, ${THEME.green}, ${THEME.teal});
}
.sf-item.sleazyfork:hover::after { background: linear-gradient(180deg, ${THEME.purple}, ${THEME.mauve}); }
.sf-item.github:hover::after { background: linear-gradient(180deg, ${THEME.github}, ${THEME.peach}); }
.sf-item:last-child { border-bottom: none; }
/* Dense mode */
:host(.dense) .sf-item { padding: 10px 20px; }
:host(.dense) .sf-script-title { font-size: 13px; }
:host(.dense) .sf-script-desc { display: none; }
:host(.dense) .sf-script-meta { gap: 4px; }
:host(.dense) .sf-badge { padding: 3px 7px; font-size: 10px; }
.sf-script-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.sf-script-info { flex: 1; min-width: 0; }
.sf-script-title {
display: block; text-decoration: none; color: ${THEME.text};
font: 700 14px/1.4 inherit; transition: color 0.15s ease;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sf-script-title:hover { color: ${THEME.green}; }
.sf-item.sleazyfork .sf-script-title:hover { color: ${THEME.purple}; }
.sf-item.github .sf-script-title:hover { color: ${THEME.github}; }
.sf-script-sub {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
font: 500 11px/1.2 inherit; color: ${THEME.overlay}; margin-top: 3px;
}
.sf-script-sub svg { width: 12px; height: 12px; margin-right: 2px; vertical-align: -1px; }
.sf-dot { opacity: 0.3; font-size: 8px; }
.sf-install-btn {
flex-shrink: 0; display: flex; align-items: center; gap: 4px;
padding: 6px 12px; border-radius: 8px; border: none;
background: ${THEME.green}22; color: ${THEME.green};
font: 700 11px/1 inherit; cursor: pointer;
transition: all 0.2s ease; white-space: nowrap;
}
.sf-install-btn:hover { background: ${THEME.green}44; transform: scale(1.04); }
.sf-install-btn:active { transform: scale(0.96); }
.sf-install-btn svg { width: 14px; height: 14px; }
.sf-item.sleazyfork .sf-install-btn { background: ${THEME.purple}22; color: ${THEME.purple}; }
.sf-item.sleazyfork .sf-install-btn:hover { background: ${THEME.purple}44; }
.sf-item.github .sf-install-btn { background: ${THEME.github}22; color: ${THEME.github}; }
.sf-item.github .sf-install-btn:hover { background: ${THEME.github}44; }
.sf-script-desc {
color: ${THEME.subtext0}; font: 400 12px/1.5 inherit;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; margin-bottom: 8px;
}
.sf-script-meta { display: flex; flex-wrap: wrap; gap: 5px; }
.sf-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 8px; border-radius: 6px; font: 700 10px/1 inherit;
background: ${THEME.surface1}; color: ${THEME.subtext1};
border: 1px solid ${THEME.glassBorder}; transition: all 0.15s ease;
}
.sf-badge svg { width: 12px; height: 12px; }
.sf-badge:hover { background: ${THEME.surface2}; border-color: ${THEME.overlay0}; }
.sf-badge.score-high { background: ${THEME.green}18; color: ${THEME.green}; border-color: ${THEME.green}33; }
.sf-badge.score-mid { background: ${THEME.yellow}18; color: ${THEME.yellow}; border-color: ${THEME.yellow}33; }
.sf-badge.score-low { background: ${THEME.red}18; color: ${THEME.red}; border-color: ${THEME.red}33; }
/* Loading / empty / error */
.sf-loading { padding: 50px 20px; text-align: center; display: grid; gap: 14px; place-items: center; }
.sf-spinner {
width: 36px; height: 36px; border-radius: 50%;
border: 3px solid ${THEME.surface2}; border-top-color: ${THEME.green};
animation: sfSpin 0.7s linear infinite;
}
.sf-spinner.sleazyfork { border-top-color: ${THEME.purple}; }
.sf-spinner.github { border-top-color: ${THEME.github}; }
.sf-loading-text { font: 500 13px/1 inherit; color: ${THEME.subtext0}; }
.sf-empty, .sf-error { padding: 50px 28px; text-align: center; }
.sf-empty-title, .sf-error-title { font: 700 15px/1.3 inherit; color: ${THEME.text}; margin-bottom: 8px; }
.sf-error-title { color: ${THEME.red}; }
.sf-empty-text, .sf-error-text { color: ${THEME.subtext0}; font: 400 13px/1.5 inherit; margin-bottom: 18px; }
.sf-action-btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 10px 18px; border-radius: 10px;
background: linear-gradient(135deg, ${THEME.greenDim}, ${THEME.green}88);
color: ${THEME.base}; font: 700 13px/1 inherit; border: none;
cursor: pointer; transition: all 0.2s ease;
}
.sf-action-btn:hover { transform: translateY(-1px); filter: brightness(1.1); }
.sf-action-btn.sleazyfork { background: linear-gradient(135deg, ${THEME.purpleDim}, ${THEME.purple}88); }
.sf-action-btn.github { background: linear-gradient(135deg, ${THEME.githubDim}, ${THEME.github}88); }
/* Footer */
.sf-footer {
padding: 12px 20px; border-top: 1px solid ${THEME.glassBorder};
background: ${THEME.surface0}44;
display: flex; align-items: center; justify-content: space-between; gap: 10px;
font: 600 11px/1 inherit;
}
.sf-footer-text { color: ${THEME.overlay}; }
.sf-footer a { color: ${THEME.green}; text-decoration: none; font-weight: 700; }
.sf-footer a:hover { text-decoration: underline; }
.sf-footer a.sleazyfork { color: ${THEME.purple}; }
.sf-footer a.github { color: ${THEME.github}; }
/* Settings panel */
.sf-settings {
display: none; padding: 16px 20px; border-top: 1px solid ${THEME.glassBorder};
background: ${THEME.surface0}44;
}
.sf-settings.visible { display: block; }
.sf-settings-title { font: 700 13px/1 inherit; color: ${THEME.text}; margin-bottom: 12px; }
.sf-setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 0; border-bottom: 1px solid ${THEME.glassBorder};
}
.sf-setting-row:last-child { border-bottom: none; }
.sf-setting-label { font: 500 12px/1.3 inherit; color: ${THEME.subtext1}; }
.sf-toggle {
position: relative; width: 36px; height: 20px; border-radius: 10px;
background: ${THEME.surface2}; cursor: pointer; transition: background 0.2s ease;
border: none; padding: 0;
}
.sf-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: ${THEME.subtext0}; transition: all 0.2s ease;
}
.sf-toggle.on { background: ${THEME.green}55; }
.sf-toggle.on::after { left: 18px; background: ${THEME.green}; }
.sf-setting-select {
padding: 4px 8px; border-radius: 6px; border: 1px solid ${THEME.glassBorder};
background: ${THEME.surface0}; color: ${THEME.text}; font: 500 12px/1 inherit;
cursor: pointer; outline: none;
}
.sf-setting-select option { background: ${THEME.surface1}; }
/* Responsive */
@media (max-width: 520px) {
.sf-modal { width: calc(100vw - 24px); right: 12px; max-height: min(88vh, 700px); }
.sf-modal-header { padding: 14px 16px; }
.sf-tabs { padding: 8px 16px; }
.sf-sort-bar, .sf-search-wrap { padding-left: 16px; padding-right: 16px; }
.sf-item { padding: 12px 16px; }
.sf-footer { padding: 10px 16px; flex-wrap: wrap; justify-content: center; }
}
`;
// ── Main Controller ─────────────────────────────────────────────────
class ScriptFinder {
constructor() {
this.settings = new SettingsService();
this.services = {
greasyfork: new ScriptService("https://greasyfork.org", "greasyfork"),
sleazyfork: new ScriptService("https://sleazyfork.org", "sleazyfork"),
github: new GitHubScriptService()
};
this.currentService = this.settings.get("lastService") || "greasyfork";
this.currentSort = this.settings.get("defaultSort");
this.currentDomain = HostService.extractRootDomain(HostService.getCurrentHost());
this.isOpen = false;
this.isLoading = false;
this.allScripts = [];
this.searchQuery = "";
this.settingsOpen = false;
this.uiBuilt = false;
}
init() {
this._setupMenuCommands();
}
_ensureUI() {
if (this.uiBuilt) return;
this._buildUI();
this._setupEvents();
this.uiBuilt = true;
}
// ── UI Build ────────────────────────────────────────────────────
_buildUI() {
this.host = document.createElement("div");
this.shadow = this.host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CSS;
this.shadow.appendChild(style);
this.toast = new ToastService(this.shadow);
// Modal
this.modal = document.createElement("div");
this.modal.className = "sf-modal";
_safeHTML(this.modal, `
Sort by
Settings
Dense mode
Default sort
Cache (minutes)
`);
this.shadow.appendChild(this.modal);
// Refs
this.content = this.modal.querySelector(".sf-content");
this.searchInput = this.modal.querySelector(".sf-search-input");
this.searchCount = this.modal.querySelector(".sf-search-count");
this.searchBox = this.modal.querySelector(".sf-search-box");
this.sortSelect = this.modal.querySelector(".sf-sort-select");
if (this.settings.get("denseMode")) this.host.classList.add("dense");
document.body.appendChild(this.host);
}
// ── Events ──────────────────────────────────────────────────────
_setupEvents() {
// Modal buttons
this.modal.querySelector(".sf-btn-close").addEventListener("click", () => this._close());
this.modal.querySelector(".sf-btn-settings").addEventListener("click", () => this._toggleSettings());
// Tabs
this.modal.querySelectorAll(".sf-tab").forEach(tab => {
tab.addEventListener("click", () => {
const svc = tab.dataset.service;
if (svc !== this.currentService) {
this.currentService = svc;
this.settings.set("lastService", svc);
this._updateTabs();
this._loadScripts();
}
});
});
// Sort
this.sortSelect.addEventListener("change", e => {
this.currentSort = e.target.value;
this._displayScripts();
});
// Search filter
this.searchInput.addEventListener("input", e => {
this.searchQuery = e.target.value.toLowerCase().trim();
this._displayScripts();
});
// Settings toggles
this.modal.querySelectorAll(".sf-toggle").forEach(btn => {
btn.addEventListener("click", () => {
const key = btn.dataset.key;
const val = !this.settings.get(key);
this.settings.set(key, val);
btn.classList.toggle("on", val);
if (key === "denseMode") this.host.classList.toggle("dense", val);
});
});
// Settings selects
this.modal.querySelectorAll(".sf-setting-select").forEach(sel => {
sel.addEventListener("change", () => {
const key = sel.dataset.key;
let val = sel.value;
if (key === "cacheDuration") val = parseInt(val);
this.settings.set(key, val);
if (key === "defaultSort") { this.currentSort = val; this.sortSelect.value = val; this._displayScripts(); }
});
});
// Outside click / Escape
document.addEventListener("click", e => { if (this.isOpen && !this.host.contains(e.target)) this._close(); });
document.addEventListener("keydown", e => { if (e.key === "Escape" && this.isOpen) this._close(); });
}
// ── Menu commands ───────────────────────────────────────────────
_setupMenuCommands() {
if (typeof GM_registerMenuCommand !== "function") return;
if (window._sfMenuIds) window._sfMenuIds.forEach(id => { try { GM_unregisterMenuCommand(id); } catch {} });
window._sfMenuIds = [];
const domain = HostService.extractRootDomain(this.currentDomain);
window._sfMenuIds.push(GM_registerMenuCommand(`Find Scripts for ${domain} (GreasyFork)`, () => {
this._ensureUI(); this.currentService = "greasyfork"; this._open();
}));
window._sfMenuIds.push(GM_registerMenuCommand(`Find Scripts for ${domain} (SleazyFork)`, () => {
this._ensureUI(); this.currentService = "sleazyfork"; this._open();
}));
window._sfMenuIds.push(GM_registerMenuCommand(`Find Scripts for ${domain} (GitHub)`, () => {
this._ensureUI(); this.currentService = "github"; this._open();
}));
window._sfMenuIds.push(GM_registerMenuCommand("Reset Script Finder Settings", () => {
GM_deleteValue("sf_settings_v4"); location.reload();
}));
}
// ── Modal control ───────────────────────────────────────────────
_open() {
this.isOpen = true;
this._updateTabs();
this._updateServiceColors();
this.sortSelect.value = this.currentSort;
this.modal.classList.add("visible");
this.searchInput.value = "";
this.searchQuery = "";
this._loadScripts();
}
_close() {
this.isOpen = false;
this.settingsOpen = false;
this.modal.querySelector(".sf-settings").classList.remove("visible");
this.modal.classList.remove("visible");
}
_toggleSettings() {
this.settingsOpen = !this.settingsOpen;
this.modal.querySelector(".sf-settings").classList.toggle("visible", this.settingsOpen);
}
// ── Tab/color updates ───────────────────────────────────────────
_updateTabs() {
this.modal.querySelectorAll(".sf-tab").forEach(t => t.classList.toggle("active", t.dataset.service === this.currentService));
this._updateServiceColors();
}
_updateServiceColors() {
const svc = this.currentService;
const svcNames = ["greasyfork", "sleazyfork", "github"];
const header = this.modal.querySelector(".sf-modal-header");
svcNames.forEach(s => header.classList.toggle(s, s === svc));
svcNames.forEach(s => this.sortSelect.classList.toggle(s, s === svc));
svcNames.forEach(s => this.searchBox.classList.toggle(s, s === svc));
const footerLink = this.modal.querySelector(".sf-footer a");
if (footerLink) {
svcNames.forEach(s => footerLink.classList.toggle(s, s === svc));
const labels = { greasyfork: "GreasyFork", sleazyfork: "SleazyFork", github: "GitHub" };
const urls = { greasyfork: "https://greasyfork.org", sleazyfork: "https://sleazyfork.org", github: "https://github.com" };
footerLink.textContent = labels[svc];
footerLink.href = urls[svc];
}
}
_setResultCount(count) {
const countEl = this.modal.querySelector(".sf-subtitle-count");
const textEl = this.modal.querySelector(".sf-subtitle-text");
const isGH = this.currentService === "github";
const unit = isGH ? "repo" : "script";
if (countEl) countEl.textContent = count || 0;
if (textEl) textEl.textContent = count === 1 ? `${unit} found` : `${unit}s found`;
}
// ── Data ────────────────────────────────────────────────────────
async _loadScripts() {
if (this.isLoading) return;
this.isLoading = true;
const svc = this.services[this.currentService];
const svcClass = this.currentService === "greasyfork" ? "" : this.currentService;
const svcLabels = { greasyfork: "GreasyFork", sleazyfork: "SleazyFork", github: "GitHub" };
const svcLabel = svcLabels[this.currentService];
_safeHTML(this.content, ``);
try {
const host = HostService.getCurrentHost();
this.currentDomain = HostService.extractRootDomain(host);
this.allScripts = await svc.searchScriptsByHost(this.currentDomain, this.settings);
this._setResultCount(this.allScripts.length);
this._displayScripts();
} catch(err) {
this._setResultCount(0);
_safeHTML(this.content, `
Something went wrong
${escapeHtml(err?.message || 'Unknown error')}
`);
this.content.querySelector(".sf-action-btn")?.addEventListener("click", () => this._loadScripts());
} finally {
this.isLoading = false;
}
}
_sortScripts(scripts) {
const copy = [...scripts];
switch (this.currentSort) {
case "daily": return copy.sort((a,b) => (b.daily_installs || b._stars || 0) - (a.daily_installs || a._stars || 0));
case "total": return copy.sort((a,b) => (b.total_installs || b._stars || 0) - (a.total_installs || a._stars || 0));
case "good": return copy.sort((a,b) => (b.good_ratings || 0) - (a.good_ratings || 0));
case "fanscore": return copy.sort((a,b) => (b.fan_score || b._forks || 0) - (a.fan_score || a._forks || 0));
case "updatedate": return copy.sort((a,b) => new Date(b.code_updated_at||0) - new Date(a.code_updated_at||0));
case "createdate": return copy.sort((a,b) => new Date(b.created_at||0) - new Date(a.created_at||0));
default: return copy.sort((a,b) => (b.daily_installs || b._stars || 0) - (a.daily_installs || a._stars || 0));
}
}
_displayScripts() {
let scripts = this.allScripts || [];
const svcClass = this.currentService === "greasyfork" ? "" : this.currentService;
const svcLabels = { greasyfork: "GreasyFork", sleazyfork: "SleazyFork", github: "GitHub" };
const svcLabel = svcLabels[this.currentService];
const displayHost = HostService.extractRootDomain(this.currentDomain);
// Update title
this.modal.querySelector(".sf-modal-title").textContent = `Scripts for ${displayHost}`;
// Filter by search
if (this.searchQuery) {
scripts = scripts.filter(s =>
(s.name || "").toLowerCase().includes(this.searchQuery) ||
(s.description || "").toLowerCase().includes(this.searchQuery) ||
(s.users?.[0]?.name || "").toLowerCase().includes(this.searchQuery) ||
(s._full_name || "").toLowerCase().includes(this.searchQuery) ||
(s._topics || []).some(t => t.toLowerCase().includes(this.searchQuery))
);
}
this.searchCount.textContent = this.searchQuery ? `${scripts.length}/${this.allScripts.length}` : "";
if (!scripts.length) {
const directUrl = this.services[this.currentService].getDirectSearchUrl(this.currentDomain);
if (this.searchQuery) {
_safeHTML(this.content, `No matches
No scripts match "${escapeHtml(this.searchQuery)}"
`);
} else {
_safeHTML(this.content, `
No scripts found
Nothing matched ${escapeHtml(displayHost)} on ${svcLabel}.
Search manually
`);
}
this._setResultCount(this.allScripts.length);
return;
}
const sorted = this._sortScripts(scripts);
this._setResultCount(this.allScripts.length);
// Build items
_safeHTML(this.content, "");
sorted.forEach((script, i) => {
const item = this._createScriptItem(script, svcClass, i);
this.content.appendChild(item);
});
}
_createScriptItem(script, svcClass, index) {
const item = document.createElement("div");
item.className = `sf-item ${svcClass}`;
item.style.animationDelay = `${Math.min(index * 30, 300)}ms`;
const isGH = script._source === "github";
const daily = formatNumber(script.daily_installs);
const total = formatNumber(script.total_installs);
const good = formatNumber(script.good_ratings);
const fanScore = script.fan_score != null ? Number(script.fan_score) : null;
const fanText = Number.isFinite(fanScore) ? fanScore.toFixed(1) : null;
const updated = relativeTime(script.code_updated_at);
const created = relativeTime(script.created_at);
const author = script.users?.[0]?.name || null;
const baseUrls = { sleazyfork: "https://sleazyfork.org", greasyfork: "https://greasyfork.org" };
const scriptUrl = isGH ? script.url : (script.url?.startsWith("http") ? script.url : (baseUrls[svcClass] || baseUrls.greasyfork) + (script.url || ""));
const installUrl = script.code_url || null;
const fanClass = fanScore >= 8 ? "score-high" : fanScore >= 6 ? "score-mid" : fanScore >= 0 ? "score-low" : "";
const badge = (icon, text, title, cls = "") => {
if (!text) return "";
return `${getIcon(icon)} ${escapeHtml(text)}`;
};
// GitHub: show stars + forks; GreasyFork/SleazyFork: show installs + ratings
let metaHtml;
if (isGH) {
const stars = formatNumber(script._stars);
const forks = formatNumber(script._forks);
const lang = script._language;
metaHtml = `
${badge("star", stars, "Stars")}
${badge("gitFork", forks, "Forks")}
${lang ? badge("gitBranch", lang, "Language") : ""}
${badge("clockwise", updated, "Updated")}
${badge("calendarPlus", created, "Created")}
`;
} else {
metaHtml = `
${badge("download", daily ? `${daily}/day` : null, "Daily installs")}
${badge("chartBar", total, "Total installs")}
${badge("star", good, "Ratings")}
${badge("flame", fanText, "Fan score", fanText ? fanClass : "")}
${badge("clockwise", updated, "Updated")}
${badge("calendarPlus", created, "Created")}
`;
}
// GitHub repos get a "View" button; GreasyFork/SleazyFork get "Install"
let actionBtn;
if (isGH) {
actionBtn = ``;
} else if (installUrl) {
actionBtn = ``;
} else {
actionBtn = "";
}
_safeHTML(item, `
${escapeHtml(script.name || "Untitled")}
${author ? `${getIcon('user')} ${escapeHtml(author)}` : ""}
${author && script.version ? `•` : ""}
${script.version ? `${getIcon('gitBranch')} v${escapeHtml(script.version)}` : ""}
${(author || script.version) && script.license ? `•` : ""}
${script.license ? `${getIcon('scales')} ${escapeHtml(script.license)}` : ""}
${actionBtn}
${escapeHtml(script.description || "No description")}
${metaHtml}
`);
// Action button handler
const actionEl = item.querySelector(".sf-install-btn");
if (actionEl) {
actionEl.addEventListener("click", (e) => {
e.stopPropagation();
GM_openInTab(actionEl.dataset.url, { active: true });
});
}
// Click-to-open script page
item.addEventListener("click", (e) => {
if (e.target.closest("a") || e.target.closest(".sf-install-btn")) return;
GM_openInTab(scriptUrl, { active: true });
});
return item;
}
}
// ── Init ────────────────────────────────────────────────────────────
function boot() {
try { new ScriptFinder().init(); }
catch(e) { console.error("[Script Finder v4]", e); }
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
else setTimeout(boot, 50);
})();