// ==UserScript==
// @name ✨ 网站油猴脚本发现器:智能匹配、高效管理,您的专属脚本宝库! ✨
// @namespace http://tampermonkey.net/
// @version 5.2
// @description Finds, filters, and sorts userscripts for the current site from GreasyFork. Now with a responsive design for desktop and mobile!
// @author 一只会飞的旺旺 (Optimized by AI)
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @connect greasyfork.org
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const LOG_PREFIX = '[Userscript Finder]';
// Configuration constants
const SORT_OPTIONS = {
'total_installs': '总安装量',
'rating': '评分',
'daily_installs': '日安装量',
'updated': '更新日期',
'created': '创建日期',
'name': '名称',
};
const DEFAULT_SORT = 'total_installs';
const RESULT_LIMIT = 20;
const ANIMATION_DURATION = 300; // ms for panel slide
const DRAG_SENSITIVITY = 0.8; // 拖动敏感度 (0.1 - 1.0),值越小拖动越慢
// Get button position from storage or use defaults
const DEFAULT_POSITION = { top: 20, right: 20, bottom: 'auto', left: 'auto' };
let buttonPosition = GM_getValue('button_position', DEFAULT_POSITION);
/**
* Gets the root domain from a hostname
*/
function getRootDomain(hostname) {
const parts = hostname.split('.');
const commonSLDs = /^(co|com|net|org|gov|edu)\.\w{2}$/;
if (parts.length > 2) {
const lastTwo = parts.slice(-2).join('.');
if (commonSLDs.test(lastTwo)) {
return parts.slice(-3).join('.');
}
return lastTwo;
}
return hostname;
}
const fullHostname = window.location.hostname;
const rootDomain = getRootDomain(fullHostname);
console.log(`${LOG_PREFIX} Initializing on ${fullHostname} (root: ${rootDomain})`);
/**
* Creates a reusable Promise-based GM_xmlhttpRequest helper
*/
function GM_xhr_promise(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({ ...options, onload: resolve, onerror: reject, ontimeout: reject });
});
}
/**
* Search GreasyFork by HTML with sorting
*/
async function searchGreasyForkByHTML(domain, sortBy = DEFAULT_SORT) {
console.log(`${LOG_PREFIX} Searching for domain: ${domain}, sorting by: ${sortBy}`);
const sortQuery = sortBy === 'daily_installs' ? '' : `?sort=${sortBy}`;
const url = `https://greasyfork.org/zh-CN/scripts/by-site/${domain}${sortQuery}`;
console.log(`${LOG_PREFIX} Requesting URL: ${url}`);
try {
const response = await GM_xhr_promise({ method: "GET", url: url });
console.log(`${LOG_PREFIX} Received response. Status: ${response.status}`);
if (response.status !== 200) {
console.error(`${LOG_PREFIX} GreasyFork search failed: HTTP Status ${response.status}`);
return [];
}
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
const scriptElements = doc.querySelectorAll('#browse-script-list > li');
const scripts = [];
scriptElements.forEach(item => {
const relativeUrl = item.querySelector('a.script-link')?.getAttribute('href') ?? '#';
scripts.push({
source: 'GreasyFork',
title: item.dataset.scriptName || 'Untitled',
url: `https://greasyfork.org${relativeUrl}`,
installs: parseInt(item.dataset.scriptTotalInstalls, 10) || 0,
updatedDate: item.dataset.scriptUpdatedDate,
description: item.querySelector('.script-description')?.textContent.trim() ?? '',
author: item.querySelector('.script-list-author a')?.textContent.trim() ?? 'Unknown'
});
});
console.log(`${LOG_PREFIX} Parsed ${scripts.length} scripts from HTML.`);
return scripts;
} catch (error) {
console.error(`${LOG_PREFIX} CRITICAL ERROR while searching:`, error);
return [];
}
}
/**
* Creates sorting and filtering controls
*/
function createControls(parent) {
const controlsContainer = document.createElement('div');
controlsContainer.id = 'userscript-finder-controls';
controlsContainer.style.cssText = 'display: flex; align-items: center; gap: 10px; padding: 5px 20px 12px; border-bottom: 1px solid rgba(255,255,255,0.2);';
const label = document.createElement('label');
label.textContent = '排序方式:';
label.style.fontSize = '12px';
label.setAttribute('for', 'sort-select');
const select = document.createElement('select');
select.id = 'sort-select';
select.style.cssText = `
background: rgba(0, 0, 0, 0.3);
color: #ecf0f1;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ECF0F1%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.4-5.4-13z%22%2F%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-position: right 8px top 50%;
background-size: .65em auto;
padding-right: 2em;
`;
for (const [value, text] of Object.entries(SORT_OPTIONS)) {
const option = document.createElement('option');
option.value = value;
option.textContent = text;
select.appendChild(option);
}
const savedSort = GM_getValue('sort_preference', DEFAULT_SORT);
select.value = savedSort;
select.addEventListener('change', (event) => {
const newSort = event.target.value;
GM_setValue('sort_preference', newSort);
updateAndRenderScripts();
});
const resetButton = document.createElement('button');
resetButton.textContent = '重置按钮位置';
resetButton.style.cssText = `
background: rgba(255,255,255,0.1); color: #ecf0f1; border: 1px solid rgba(255,255,255,0.3);
border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; margin-left: auto;
transition: background-color 0.2s, transform 0.1s;
`;
resetButton.addEventListener('click', resetButtonPosition);
resetButton.addEventListener('mouseenter', () => resetButton.style.backgroundColor = 'rgba(255,255,255,0.2)');
resetButton.addEventListener('mouseleave', () => resetButton.style.backgroundColor = 'rgba(255,255,255,0.1)');
resetButton.addEventListener('mousedown', () => resetButton.style.transform = 'scale(0.98)');
resetButton.addEventListener('mouseup', () => resetButton.style.transform = 'scale(1)');
controlsContainer.appendChild(label);
controlsContainer.appendChild(select);
controlsContainer.appendChild(resetButton);
parent.appendChild(controlsContainer);
}
function resetButtonPosition() {
const toggleButton = document.getElementById('userscript-finder-toggle');
if (toggleButton) {
Object.assign(toggleButton.style, {
top: `${DEFAULT_POSITION.top}px`,
right: `${DEFAULT_POSITION.right}px`,
bottom: 'auto',
left: 'auto',
transform: 'none',
transition: 'top 0.3s, right 0.3s, bottom 0.3s, left 0.3s, opacity 0.2s, transform 0.2s, box-shadow 0.2s'
});
buttonPosition = DEFAULT_POSITION;
GM_setValue('button_position', buttonPosition);
toggleButton.classList.add('position-saved');
setTimeout(() => toggleButton.classList.remove('position-saved'), 500);
}
}
function makeDraggable(element) {
let isDragging = false, initialMouseX, initialMouseY, initialButtonX, initialButtonY;
let currentOffsetX = 0, currentOffsetY = 0, animationFrameId = null;
let wasDragged = false;
function dragStart(e) {
if (e.button === 2) return;
wasDragged = false;
if (e.type === 'touchstart') {
initialMouseX = e.touches[0].clientX;
initialMouseY = e.touches[0].clientY;
} else {
initialMouseX = e.clientX;
initialMouseY = e.clientY;
}
const rect = element.getBoundingClientRect();
initialButtonX = rect.left;
initialButtonY = rect.top;
element.style.left = `${initialButtonX}px`;
element.style.top = `${initialButtonY}px`;
element.style.right = 'auto';
element.style.bottom = 'auto';
element.style.transition = 'none';
element.classList.add('is-dragging');
isDragging = true;
if (e.type === 'mousedown') e.preventDefault();
}
function drag(e) {
if (!isDragging) return;
e.preventDefault();
let clientX, clientY;
if (e.type === 'touchmove') {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
currentOffsetX = (clientX - initialMouseX) * DRAG_SENSITIVITY;
currentOffsetY = (clientY - initialMouseY) * DRAG_SENSITIVITY;
if (Math.abs(currentOffsetX) > 2 || Math.abs(currentOffsetY) > 2) {
wasDragged = true;
}
if (!animationFrameId) {
animationFrameId = requestAnimationFrame(updateTransform);
}
}
function updateTransform() {
element.style.transform = `translate(${currentOffsetX}px, ${currentOffsetY}px)`;
animationFrameId = null;
}
function dragEnd() {
if (!isDragging) return;
isDragging = false;
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
const rect = element.getBoundingClientRect();
const finalLeft = rect.left;
const finalTop = rect.top;
element.style.transform = '';
element.style.transition = '';
element.classList.remove('is-dragging');
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const isCloserToRight = viewportWidth - (finalLeft + rect.width) < finalLeft;
const isCloserToBottom = viewportHeight - (finalTop + rect.height) < finalTop;
let newPosition = {};
if (isCloserToRight) {
newPosition.right = Math.max(10, viewportWidth - (finalLeft + rect.width));
newPosition.left = 'auto';
} else {
newPosition.left = Math.max(10, finalLeft);
newPosition.right = 'auto';
}
if (isCloserToBottom) {
newPosition.bottom = Math.max(10, viewportHeight - (finalTop + rect.height));
newPosition.top = 'auto';
} else {
newPosition.top = Math.max(10, finalTop);
newPosition.bottom = 'auto';
}
Object.keys(newPosition).forEach(prop => {
element.style[prop] = (newPosition[prop] !== 'auto') ? `${newPosition[prop]}px` : 'auto';
});
buttonPosition = newPosition;
GM_setValue('button_position', buttonPosition);
element.classList.add('position-saved');
setTimeout(() => element.classList.remove('position-saved'), 500);
currentOffsetX = 0;
currentOffsetY = 0;
}
element.addEventListener('touchstart', dragStart, { passive: false });
document.addEventListener('touchend', dragEnd);
document.addEventListener('touchmove', drag, { passive: false });
element.addEventListener('mousedown', dragStart);
document.addEventListener('mouseup', dragEnd);
document.addEventListener('mousemove', drag);
return { wasDragged: () => wasDragged };
}
async function updateAndRenderScripts() {
const container = document.getElementById('userscript-finder-container');
const content = container.querySelector('#userscript-finder-content');
const titleElement = container.querySelector('#userscript-finder-title');
content.innerHTML = `<div id="userscript-finder-loading">正在加载...</div>`;
titleElement.textContent = '脚本查找器';
try {
const sortBy = GM_getValue('sort_preference', DEFAULT_SORT);
const allScripts = await searchGreasyForkByHTML(rootDomain, sortBy);
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const recentScripts = allScripts.filter(script => {
return script.updatedDate && new Date(script.updatedDate) >= oneYearAgo;
});
console.log(`${LOG_PREFIX} Filtered by date: ${recentScripts.length} scripts remaining (updated within 1 year).`);
const finalScripts = recentScripts.slice(0, RESULT_LIMIT);
titleElement.textContent = `脚本查找器 (${finalScripts.length} 个结果)`;
if (finalScripts.length > 0) {
let html = '';
finalScripts.forEach(script => {
const descriptionHTML = script.description ? `<p class="script-description">${script.description}</p>` : '';
html += `
<div class="script-item">
<h4 class="script-title"><a href="${script.url}" target="_blank" rel="noopener noreferrer">${script.title}</a></h4>
<div class="script-meta">
<span class="script-source">更新于: ${script.updatedDate}</span>
<span class="script-installs">${script.installs.toLocaleString()} 安装</span>
</div>
${descriptionHTML}
</div>`;
});
content.innerHTML = html;
} else {
content.innerHTML = `<div class="no-scripts"><p>未找到适用于 ${rootDomain} 的脚本(或没有近一年内更新的)。</p></div>`;
}
} catch (error) {
console.error(`${LOG_PREFIX} Error during render:`, error);
content.innerHTML = `<div class="no-scripts"><p>搜索脚本时出现错误。</p><p>请按 F12 打开控制台查看日志。</p></div>`;
}
}
function createUI() {
GM_addStyle(`
/* == RESPONSIVE DESIGN VARIABLES == */
:root {
/* Default (Desktop): Slide from right */
--panel-hidden-transform: translateX(calc(100% + 20px));
}
/* Main Panel Styles */
#userscript-finder-container {
position: fixed;
top: 20px;
right: 20px;
width: 400px;
max-width: 95vw; /* Ensure it doesn't exceed viewport width */
max-height: 80vh;
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
z-index: 999999;
font-family: 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
color: #ecf0f1;
overflow: hidden;
display: flex; /* Use flexbox for layout */
flex-direction: column;
transform: var(--panel-hidden-transform); /* Initially hidden using variable */
transition: transform ${ANIMATION_DURATION}ms ease-in-out;
}
#userscript-finder-container.show {
transform: translateX(0) translateY(0); /* Universal "show" state */
}
/* Header */
#userscript-finder-header {
padding: 16px 20px 0;
background: rgba(0,0,0,0.15);
flex-shrink: 0; /* Prevent header from shrinking */
}
#header-top-row {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 12px;
}
#userscript-finder-title { font-size: 17px; font-weight: 700; margin: 0; }
#userscript-finder-close {
background: none; border: none; color: #ecf0f1; font-size: 24px;
cursor: pointer; padding: 0; width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; transition: background-color 0.2s, transform 0.2s;
}
#userscript-finder-close:hover { background-color: rgba(255,255,255,0.1); transform: rotate(90deg); }
#userscript-finder-close:active { transform: scale(0.9) rotate(90deg); }
/* Content Area */
#userscript-finder-content {
overflow-y: auto;
flex-grow: 1; /* Allow content to fill available space */
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.3) transparent;
}
#userscript-finder-content::-webkit-scrollbar { width: 8px; }
#userscript-finder-content::-webkit-scrollbar-thumb { background-color: rgba(255,255,255,0.3); border-radius: 4px; }
#userscript-finder-content::-webkit-scrollbar-track { background-color: transparent; }
/* Script List Item */
.script-item { padding: 14px 20px; border-bottom: 1px solid rgba(255,255,255,0.1); transition: background-color 0.2s; }
.script-item:hover { background-color: rgba(255,255,255,0.08); }
.script-item:last-child { border-bottom: none; }
.script-title { font-size: 15px; font-weight: 600; margin: 0 0 6px 0; line-height: 1.3; }
.script-title a { color: #add8e6; text-decoration: none; transition: color 0.2s; }
.script-title a:hover { text-decoration: underline; color: #87ceeb; }
.script-meta { display: flex; justify-content: space-between; align-items: center; font-size: 11px; opacity: 0.7; margin-bottom: 6px; }
.script-installs { font-weight: 600; }
.script-description { font-size: 12px; opacity: 0.6; line-height: 1.5; margin: 0; max-height: 40px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.no-scripts, #userscript-finder-loading { padding: 40px 20px; text-align: center; opacity: 0.8; }
/* Toggle Button Styles */
#userscript-finder-toggle {
position: fixed; width: 54px; height: 54px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none; border-radius: 50%; color: white; font-size: 22px; cursor: move;
box-shadow: 0 4px 15px rgba(0,0,0,0.4); z-index: 999998;
transition: opacity 0.2s, transform 0.2s, box-shadow 0.2s;
display: flex; align-items: center; justify-content: center;
user-select: none; text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
#userscript-finder-toggle:hover { opacity: 0.9; transform: scale(1.05); }
#userscript-finder-toggle.hidden { display: none; }
#userscript-finder-toggle.minimized { opacity: 0.6; transform: scale(0.7); }
#userscript-finder-toggle.position-saved { box-shadow: 0 0 0 4px rgba(255,255,255,0.6); }
#userscript-finder-toggle.is-dragging { cursor: grabbing; opacity: 0.7; }
/* Context Menu Styles */
.toggle-context-menu {
position: absolute; background: rgba(44, 62, 80, 0.98);
border: 1px solid rgba(255,255,255,0.2); border-radius: 6px;
box-shadow: 0 6px 15px rgba(0,0,0,0.4); padding: 6px 0;
z-index: 999999; font-size: 13px;
}
.menu-option { padding: 8px 15px; cursor: pointer; color: #ecf0f1; transition: background-color 0.2s; }
.menu-option:hover { background: rgba(255,255,255,0.12); }
.menu-separator { height: 1px; background: rgba(255,255,255,0.15); margin: 6px 0; }
#sort-select option { background-color: #34495e; color: #ecf0f1; }
/* == MOBILE / SMALL SCREEN STYLES == */
@media (max-width: 768px) {
:root {
/* On mobile, slide from bottom */
--panel-hidden-transform: translateY(100%);
}
#userscript-finder-container {
top: auto;
bottom: 0;
right: 0;
left: 0;
width: 100%;
max-width: 100%; /* Override desktop max-width */
max-height: 70vh; /* A bit shorter for bottom sheet */
border-radius: 16px 16px 0 0; /* Rounded top corners */
}
#userscript-finder-header { padding: 12px 16px 0; }
#userscript-finder-title { font-size: 16px; }
#userscript-finder-controls { padding: 5px 16px 12px; }
.script-item { padding: 12px 16px; }
.script-title { font-size: 14px; }
.script-description { font-size: 11px; }
}
`);
const container = document.createElement("div");
container.id = "userscript-finder-container";
container.innerHTML = `
<div id="userscript-finder-header">
<div id="header-top-row">
<h3 id="userscript-finder-title">脚本查找器</h3>
<button id="userscript-finder-close" title="关闭">×</button>
</div>
<!-- Controls will be inserted here -->
</div>
<div id="userscript-finder-content"></div>
`;
document.body.appendChild(container);
createControls(container.querySelector('#userscript-finder-header'));
const toggleButton = document.createElement("button");
toggleButton.id = "userscript-finder-toggle";
toggleButton.innerHTML = "🔍";
toggleButton.title = "查找可用脚本 (左键打开, 右键菜单, 双击最小化, 按住拖动)";
Object.keys(buttonPosition).forEach(prop => {
toggleButton.style[prop] = (buttonPosition[prop] !== 'auto') ? `${buttonPosition[prop]}px` : 'auto';
});
document.body.appendChild(toggleButton);
const draggable = makeDraggable(toggleButton);
toggleButton.addEventListener('dblclick', (e) => {
e.preventDefault(); e.stopPropagation();
toggleButton.classList.toggle('minimized');
});
toggleButton.addEventListener('contextmenu', (e) => {
e.preventDefault();
const existingMenu = document.querySelector('.toggle-context-menu');
if (existingMenu) existingMenu.remove();
const contextMenu = document.createElement('div');
contextMenu.className = 'toggle-context-menu';
contextMenu.innerHTML = `
<div class="menu-option" id="menu-open">打开脚本查找器</div>
<div class="menu-separator"></div>
<div class="menu-option" id="menu-minimize">最小化/恢复图标</div>
<div class="menu-option" id="menu-reset-position">重置位置</div>
<div class="menu-option" id="menu-hide">暂时隐藏 (30秒)</div>
`;
document.body.appendChild(contextMenu);
const rect = toggleButton.getBoundingClientRect();
const menuRect = contextMenu.getBoundingClientRect();
let menuTop = rect.top;
let menuLeft = rect.right + 10;
if (menuLeft + menuRect.width > window.innerWidth) {
menuLeft = rect.left - menuRect.width - 10;
}
if (menuTop + menuRect.height > window.innerHeight) {
menuTop = window.innerHeight - menuRect.height - 10;
}
contextMenu.style.top = `${Math.max(10, menuTop)}px`;
contextMenu.style.left = `${Math.max(10, menuLeft)}px`;
const closeMenu = (event) => {
if (!contextMenu.contains(event.target) && event.target !== toggleButton) {
contextMenu.remove();
document.removeEventListener('click', closeMenu, true);
document.removeEventListener('contextmenu', closeMenu, true);
}
};
setTimeout(() => { // Use setTimeout to avoid capturing the same click that opened it
document.addEventListener('click', closeMenu, true);
document.addEventListener('contextmenu', closeMenu, true);
}, 0);
document.getElementById('menu-open').addEventListener('click', () => {
openFinderPanel();
contextMenu.remove();
});
document.getElementById('menu-minimize').addEventListener('click', () => {
toggleButton.classList.toggle('minimized');
contextMenu.remove();
});
document.getElementById('menu-reset-position').addEventListener('click', () => {
resetButtonPosition();
contextMenu.remove();
});
document.getElementById('menu-hide').addEventListener('click', () => {
toggleButton.classList.add('hidden');
setTimeout(() => toggleButton.classList.remove('hidden'), 30000);
contextMenu.remove();
});
});
const openFinderPanel = () => {
container.classList.add('show');
toggleButton.classList.add('hidden');
if (!container.dataset.loaded) {
updateAndRenderScripts();
container.dataset.loaded = "true";
}
};
const closeFinderPanel = () => {
container.classList.remove('show');
setTimeout(() => toggleButton.classList.remove('hidden'), ANIMATION_DURATION);
};
toggleButton.addEventListener('click', (e) => {
if (draggable.wasDragged()) return;
openFinderPanel();
});
container.querySelector('#userscript-finder-close').addEventListener('click', closeFinderPanel);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && container.classList.contains('show')) {
closeFinderPanel();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createUI);
} else {
createUI();
}
})();