记录位置、自动翻译、点击外部关闭、新用户引导、全数据展示。
// ==UserScript==
// @name Script Finder+
// @name:zh-CN Script Finder 油猴脚本查找
// @description:zh-CN 加载状态移到标题栏,避免列表跳动;桌面端面板固定宽度,窗口较小时自适应。修复桌面端靠右显示逻辑。手机竖版“查找”不遮挡。渲染优先、异步翻译、支持拖动、位置记录。
// @namespace https://github.com/HHXXYY123/script-finder-plus
// @version 2026.9.1.0458
// @author HHXXYY123
// @match *://*/*
// @connect greasyfork.org
// @connect translate.googleapis.com
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @license MIT
// @description 记录位置、自动翻译、点击外部关闭、新用户引导、全数据展示。
// ==/UserScript==
(function () {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
const userLang = (navigator.languages && navigator.languages[0]) || navigator.language || 'en'
const isChinese = userLang.startsWith('zh')
const getT = (key) => {
const dict = isChinese ? {
Author: '作者', Installs: '总安装', Daily: '日安装', Created: '创建', Updated: '更新', Loading: '加载中...', LoadMore: '加载更多', AllLoaded: '到底啦',
Search: '搜索脚本...', Scripts: '脚本查找', MiniBtn: '查找', Timeout: '超时', Install: '安装', OpenGF: '打开 GreasyFork'
} : {
Author: 'Author', Installs: 'Total', Daily: 'Daily', Created: 'Created', Updated: 'Updated', Loading: 'Loading...', LoadMore: 'More', AllLoaded: 'End',
Search: 'Search...', Scripts: 'Scripts', MiniBtn: 'Find', Timeout: 'Timeout', Install: 'Install', OpenGF: 'Open GreasyFork'
}
return dict[key] || key
}
function setLoadingStatus(text = '', visible = true) {
const status = document.querySelector('.sf-loading-status')
if (!status) return
status.textContent = text || ''
status.classList.toggle('is-visible', visible && Boolean(text))
status.setAttribute('aria-busy', visible ? 'true' : 'false')
}
const domain = window.location.hostname.split('.').slice(-2).join('.')
let neverLoaded = true, collapsed = true, loadedPages = 0, hideTimer = null, isDragging = false
let isLoadingPage = false, pendingSearchTerm = '', searchLoadTimer = null, autoPreloadTimer = null, totalLoadedItems = 0
function queueTranslation(text, element, delay) {
if (!text || /[\u4e00-\u9fa5]/.test(text)) return
setTimeout(() => {
GM_xmlhttpRequest({
method: "GET",
url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=zh-CN&dt=t&q=${encodeURIComponent(text)}`,
timeout: 8000,
onload: (res) => {
try {
const data = JSON.parse(res.responseText)
const translated = data[0].map(x => x[0]).join('')
if (translated && element) {
element.style.display = 'block'
element.innerText = `🏮 ${translated}`
}
} catch (e) {}
}
})
}, delay)
}
function getScriptsInfo(domain, page = 1, options = {}) {
const btn = document.querySelector('.sf-load-more')
const { silent = false, onComplete = null } = options
if (isLoadingPage) return
isLoadingPage = true
if (!silent) setLoadingStatus(`${getT('Loading')} (${page})`, true)
GM_xmlhttpRequest({
method: 'GET',
url: `https://greasyfork.org/scripts/by-site/${domain}?filter_locale=0&sort=updated&page=${page}`,
timeout: 10000,
onload: (res) => {
isLoadingPage = false
if (!silent) setLoadingStatus('', false)
const doc = new DOMParser().parseFromString(res.responseText, 'text/html')
const scripts = doc.querySelector('#browse-script-list')?.querySelectorAll('[data-script-id]')
if (page === 1) {
const headerText = doc.querySelector('body')?.innerText || '';
const match = headerText.match(/(?:共|of)\s*([\d,]+)\s*(?:个脚本|scripts|条)/i) || headerText.match(/(\d+)\s*(?:个脚本|scripts found)/i);
if (match) {
const count = match[1].replace(/,/g, '');
document.querySelector('.sf-total-count').innerText = ` (共 ${count} 个,已加载 ${totalLoadedItems || scripts.length} 个)`;
} else {
// 备用方案:如果没匹配到具体总数文本,获取当前页的脚本数量
document.querySelector('.sf-total-count').innerText = ` (已加载 ${totalLoadedItems || (scripts ? scripts.length : 0)} 个)`;
}
}
if (!scripts || scripts.length === 0) {
if (page === 1 && !silent) setLoadingStatus('该域暂无可用脚本', true)
if (page > 1) { loadedPages = 'max'; btn.textContent = getT('AllLoaded'); btn.disabled = true }
if (typeof onComplete === 'function') onComplete([])
return
}
totalLoadedItems += scripts.length
const totalCountNode = document.querySelector('.sf-total-count')
if (totalCountNode && !totalCountNode.innerText.includes('共 ')) totalCountNode.innerText = ` (已加载 ${totalLoadedItems} 个)`
let staggerDelay = 100
scripts.forEach(s => {
const typeBadge = s.querySelector('.script-type')?.textContent || '';
const isLibrary = typeBadge.includes('Library') || typeBadge.includes('库');
const extension = isLibrary ? '.js' : '.user.js';
const nameEncoded = encodeURIComponent(s.getAttribute('data-script-name'));
const installUrl = `https://update.greasyfork.org/scripts/${s.getAttribute('data-script-id')}/${nameEncoded}${extension}`;
const info = {
id: s.getAttribute('data-script-id'),
name: s.getAttribute('data-script-name'),
author: s.querySelector('dd.script-list-author')?.textContent || 'Unknown',
desc: s.querySelector('.script-description')?.textContent || '',
version: s.getAttribute('data-script-version'),
url: 'https://greasyfork.org/scripts/' + s.getAttribute('data-script-id'),
installUrl: installUrl,
installs: s.getAttribute('data-script-total-installs') || '0',
daily: s.getAttribute('data-script-daily-installs') || '0',
created: s.getAttribute('data-script-created-date') || '',
updated: s.getAttribute('data-script-updated-date') || '',
rating: s.getAttribute('data-script-rating-score') || '0',
typeBadge: typeBadge
}
const li = appendItem(info)
queueTranslation(info.name, li.querySelector('.sf-trans-name'), staggerDelay)
queueTranslation(info.desc, li.querySelector('.sf-trans-desc'), staggerDelay + 50)
staggerDelay += 150
})
const pageCount = scripts.length
if (pageCount < 100) {
loadedPages = 'max'; btn.textContent = getT('AllLoaded'); btn.disabled = true
} else {
loadedPages = page; btn.style.display = 'block'; btn.textContent = getT('LoadMore'); btn.disabled = false
}
if (typeof onComplete === 'function') onComplete(Array.from(scripts))
},
ontimeout: () => {
isLoadingPage = false
setLoadingStatus('', false)
if (!silent) setLoadingStatus(getT('Timeout'), true)
if (typeof onComplete === 'function') onComplete([])
}
})
}
function updateSearchResults(term) {
const normalized = (term || '').trim().toLowerCase()
const items = Array.from(document.querySelectorAll('.sf-info-item'))
let visibleCount = 0
items.forEach(li => {
const matched = !normalized || li.innerText.toLowerCase().includes(normalized)
li.style.display = matched ? 'block' : 'none'
if (matched) visibleCount++
})
return visibleCount
}
function maybeLoadMoreForSearch() {
const term = pendingSearchTerm
if (!term || isLoadingPage || loadedPages === 'max') return
const visibleCount = updateSearchResults(term)
if (visibleCount > 0) return
const nextPage = typeof loadedPages === 'number' ? loadedPages + 1 : 1
setLoadingStatus(`${getT('Loading')} (${nextPage})`, true)
getScriptsInfo(domain, nextPage, {
silent: true,
onComplete: () => {
const matchedCount = updateSearchResults(term)
if (matchedCount === 0 && loadedPages !== 'max') {
searchLoadTimer = setTimeout(maybeLoadMoreForSearch, 150)
} else {
setLoadingStatus('', false)
}
}
})
}
function scheduleAutoPreload(delay = 180) {
if (autoPreloadTimer) clearTimeout(autoPreloadTimer)
if (collapsed || isLoadingPage || loadedPages === 'max' || pendingSearchTerm) return
autoPreloadTimer = setTimeout(() => loadNextPage('auto'), delay)
}
function loadNextPage(reason = 'manual') {
if (isLoadingPage || loadedPages === 'max') return
const nextPage = typeof loadedPages === 'number' ? loadedPages + 1 : 1
setLoadingStatus(`${getT('Loading')} (${nextPage})`, true)
getScriptsInfo(domain, nextPage, {
silent: true,
onComplete: () => {
if (pendingSearchTerm) {
const matchedCount = updateSearchResults(pendingSearchTerm)
if (reason === 'search' && matchedCount === 0 && loadedPages !== 'max') {
searchLoadTimer = setTimeout(() => loadNextPage('search'), 120)
return
}
}
setLoadingStatus('', false)
if ((reason === 'auto' || reason === 'open') && loadedPages !== 'max') {
scheduleAutoPreload(180)
}
}
})
}
function maybeLoadMoreOnScroll() {
if (isLoadingPage || loadedPages === 'max') return
const panel = document.querySelector('.sf-panel')
if (!panel || panel.style.display !== 'block') return
const remaining = panel.scrollHeight - panel.scrollTop - panel.clientHeight
if (remaining <= 160) {
loadNextPage('scroll')
}
}
function appendItem(s) {
const list = document.querySelector('.sf-info-list')
const li = document.createElement('li')
li.className = 'sf-info-item'; li.style = "border-bottom: 1px solid #eee; padding: 12px 0; list-style: none;"
li.innerHTML = `
<div class="sf-item-card" style="position:relative;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; padding-right:90px;">
<div>
<a class="sf-name" href="${s.url}" target="_blank" style="font-size:17px; font-weight:bold; color:#1e90ff; text-decoration:none;" title="查看详情">${s.name}</a>
<span style="font-size:13px; color:#999; margin-left:10px;">v${s.version}</span>
${s.typeBadge ? `<span style="font-size:12px; background:#e0e0e0; color:#555; padding:2px 6px; border-radius:4px; margin-left:8px;">${s.typeBadge}</span>` : ''}
</div>
<a href="${s.installUrl}" target="_blank" style="position:absolute; right:0; top:0; padding:6px 12px; background:#4CAF50; color:white; text-decoration:none; border-radius:4px; font-size:13px; font-weight:bold; box-shadow:0 2px 4px rgba(0,0,0,0.1); transition:background 0.2s;" title="点击直接安装">${getT('Install')}</a>
</div>
<div class="sf-trans-name" style="display:none; font-size:14px; color:#d35400; background:#fff5eb; padding:3px 6px; border-radius:4px; margin:4px 0;"></div>
<p class="sf-desc" style="font-size:14px; color:#444; margin:6px 0; line-height:1.5;">${s.desc}</p>
<div class="sf-trans-desc" style="display:none; font-size:14px; color:#d35400; background:#fff5eb; padding:3px 6px; border-radius:4px; margin:4px 0;"></div>
<div style="display:flex; flex-wrap:wrap; gap:12px; font-size:13px; color:#888; margin-top:8px;">
<span>👤 ${getT('Author')}: <span style="color:#555">${s.author}</span></span>
<span>📥 ${getT('Installs')}: <span style="color:#555">${s.installs}</span></span>
<span>📈 ${getT('Daily')}: <span style="color:#555">${s.daily}</span></span>
${s.created ? `<span>📅 ${getT('Created')}: <span style="color:#555">${s.created}</span></span>` : ''}
${s.updated ? `<span>🔄 ${getT('Updated')}: <span style="color:#555">${s.updated}</span></span>` : ''}
${s.rating && s.rating !== '0' ? `<span>⭐ <span style="color:#f39c12">${s.rating}</span></span>` : ''}
</div>
</div>`
list.appendChild(li); return li
}
function setupUI() {
GM_addStyle(`
scrbutton.sf-main-btn {
position: fixed; right: 10px; padding: 10px 18px; font-size: 16px; font-weight: bold;
border: none; border-radius: 30px; background: #1e90ff; color: #fff; cursor: grab; z-index: 999999;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); opacity: 0; display: none; transition: opacity 0.4s;
user-select: none; touch-action: none;
}
@media screen and (max-width: 768px) {
scrbutton.sf-main-btn {
padding: 10px 6px; font-size: 13px; width: 28px; line-height: 1.2;
border-radius: 8px; right: 5px; text-align: center;
word-break: break-all; display: flex; align-items: center; justify-content: center;
}
}
div.sf-panel {
display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 650px !important; max-width: calc(100vw - 30px) !important; box-sizing: border-box;
background: #fff; border-radius: 12px; z-index: 1000000;
box-shadow: 0 10px 40px rgba(0,0,0,0.4); padding: 15px; max-height: 80vh; overflow-y: auto;
}
.sf-sticky-head {
position: sticky; top: -15px; z-index: 3; background: inherit; padding-top: 15px;
}
@media (prefers-color-scheme: dark) {
div.sf-panel { background: #222; color: #ddd; }
.sf-desc { color: #bbb !important; }
.sf-trans-name, .sf-trans-desc { background: #3a2a1a !important; color: #ff9900 !important; }
}
.sf-loading-status {
display: inline-block; width: 104px; overflow: hidden; text-overflow: ellipsis;
font-size: 12px; font-weight: normal; color: #999; white-space: nowrap;
vertical-align: middle; visibility: hidden;
}
.sf-loading-status.is-visible { visibility: visible; }
`)
const button = document.createElement('scrbutton'), panel = document.createElement('div')
button.className = 'sf-main-btn'
button.innerText = isMobile ? getT('MiniBtn') : getT('Scripts')
document.body.appendChild(button)
const savedPos = localStorage.getItem('sf-btn-top')
button.style.top = savedPos ? savedPos + 'px' : '70%'
panel.className = 'sf-panel'; panel.innerHTML = `
<div class="sf-sticky-head">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:2px solid #1e90ff; padding-bottom:8px; gap:10px;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<div style="font-size:18px; font-weight:bold; color:#1e90ff;">Script Finder<span class="sf-total-count" style="font-size:13px; color:#999; margin-left:6px;"></span><span class="sf-loading-status" role="status" aria-live="polite" aria-busy="false"></span></div>
<a class="sf-open-gf" href="https://greasyfork.org/scripts/by-site/${domain}?filter_locale=0&sort=updated" target="_blank" style="font-size:12px; color:#1e90ff; text-decoration:none; border:1px solid #1e90ff; border-radius:999px; padding:4px 8px;">${getT('OpenGF')}</a>
</div>
<button class="sf-close" style="border:none; background:none; cursor:pointer; font-size:24px; color:#999;">×</button>
</div>
<input type="text" class="sf-search" placeholder="${getT('Search')}" style="width:100%; padding:10px; margin:10px 0; border:1px solid #ddd; border-radius:6px; font-size:15px; box-sizing:border-box;">
</div>
<ul class="sf-info-list" style="padding:0; margin:0;"></ul>
<div class="sf-auto-more-sentinel" style="height:2px; width:100%;"></div>
<button class="sf-load-more" style="display:block; width:100%; padding:10px; background:#1e90ff; color:#fff; border:none; border-radius:4px; margin-top:10px; font-size:15px; cursor:pointer;">${getT('LoadMore')}</button>
`
document.body.appendChild(panel)
let startY, startTop, moveDist = 0
const onStart = (e) => {
isDragging = true; moveDist = 0; button.style.transition = 'none'
const cy = e.touches ? e.touches[0].clientY : e.clientY
startY = cy; startTop = button.offsetTop; clearTimeout(hideTimer)
}
const onMove = (e) => {
if (!isDragging) return
if (e.cancelable) e.preventDefault()
const cy = e.touches ? e.touches[0].clientY : e.clientY
const dy = cy - startY; moveDist = Math.abs(dy)
const nt = Math.max(10, Math.min(startTop + dy, window.innerHeight - 60))
button.style.top = nt + 'px'; localStorage.setItem('sf-btn-top', nt)
}
const onEnd = () => { if (!isDragging) return; isDragging = false; button.style.transition = 'opacity 0.4s'; if (collapsed) startTimer() }
button.addEventListener('mousedown', onStart); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onEnd)
button.addEventListener('touchstart', onStart, {passive:false}); window.addEventListener('touchmove', onMove, {passive:false}); window.addEventListener('touchend', onEnd)
const startTimer = (d = 2000) => {
clearTimeout(hideTimer); hideTimer = setTimeout(() => { if (collapsed && !isDragging) { button.style.opacity = '0'; setTimeout(() => { if (button.style.opacity === '0') button.style.display = 'none' }, 400) } }, d)
}
const showBtn = (d = 2000) => {
if (button.style.display !== 'flex') {
button.style.display = 'flex';
setTimeout(() => button.style.opacity = '0.9', 10);
} else {
button.style.opacity = '0.9';
}
if (collapsed && !isDragging) startTimer(d);
}
// --- 核心修复:鼠标靠右自动显示 ---
window.addEventListener('mousemove', (e) => {
// 使用 document.documentElement.clientWidth 兼容存在滚动条的情况,将判定范围放宽到 60px
const clientW = document.documentElement.clientWidth || window.innerWidth;
if (collapsed && e.clientX > clientW - 60) showBtn(2000)
})
window.addEventListener('scroll', () => { if (collapsed) showBtn(2000) })
document.addEventListener('mousedown', (e) => { if (!collapsed && !panel.contains(e.target) && !button.contains(e.target)) closePanel() })
const closePanel = () => { if (autoPreloadTimer) clearTimeout(autoPreloadTimer); panel.style.display = 'none'; collapsed = true; startTimer() }
button.onclick = () => {
if (moveDist > 5) return
if (collapsed) {
panel.style.display = 'block'
collapsed = false
clearTimeout(hideTimer)
if (neverLoaded) {
getScriptsInfo(domain, 1, { onComplete: () => loadNextPage('open') })
neverLoaded = false
} else {
scheduleAutoPreload(120)
}
}
else closePanel()
}
panel.querySelector('.sf-close').onclick = closePanel
panel.querySelector('.sf-search').oninput = (e) => {
pendingSearchTerm = e.target.value.trim().toLowerCase()
if (searchLoadTimer) clearTimeout(searchLoadTimer)
const visibleCount = updateSearchResults(pendingSearchTerm)
if (!pendingSearchTerm) {
setLoadingStatus('', false)
return
}
if (visibleCount === 0 && loadedPages !== 'max') {
searchLoadTimer = setTimeout(() => loadNextPage('search'), 120)
}
}
panel.querySelector('.sf-load-more').onclick = () => { if (loadedPages !== 'max' && !isLoadingPage) loadNextPage('manual') }
panel.addEventListener('scroll', maybeLoadMoreOnScroll, { passive: true })
panel.addEventListener('wheel', () => setTimeout(maybeLoadMoreOnScroll, 0), { passive: true })
panel.addEventListener('touchend', () => setTimeout(maybeLoadMoreOnScroll, 0), { passive: true })
// 初始显示 2 秒
showBtn(2000)
}
// 只要 DOM 准备好就立即执行,不用等图片等资源完全加载,提升出现速度
if (document.body) {
setupUI();
} else {
document.addEventListener('DOMContentLoaded', setupUI);
}
})()