Add search buttons to Exoticaz and MissAV pages
// ==UserScript== // @name Search Button Enhancer // @namespace http://tampermonkey.net/ // @version 1.9 // @description Add search buttons to Exoticaz and MissAV pages // @author troublesis <[email protected]> // @match https://exoticaz.to/torrents* // @match https://exoticaz.to/torrent/* // @match https://missav.ws/* // @match https://missav.live/* // @match https://javdb.com/* // @match https://www.javdb.com/* // @match https://www5.javmost.com/* // @match https://javbus.com/* // @match https://www.javbus.com/* // @grant none // @license MIT // ==/UserScript== ;(function () { 'use strict' // Configuration: Add or modify search providers here const SEARCH_PROVIDERS = [ { name: 'Exoticaz', icon: '', url: 'https://exoticaz.to/torrents?in=1&search=%s#jump', className: 'btn-warning', hostPattern: /(^|\.)exoticaz\.to$/, }, { name: 'NetFlav', icon: '', url: 'https://netflav.com/search?type=title&keyword=', className: 'btn-warning', }, { name: 'JavDB', icon: '', url: 'https://JavDB.com/search?f=all&q=%s&sb=0#query#jump&locale=zh#jump', className: 'btn-warning', hostPattern: /(^|\.)javdb\.com$/i, }, { name: 'JavMost', icon: '', url: 'https://www5.javmost.com/tag/%s#jump', className: 'btn-warning', hostPattern: /(^|\.)javmost\.com$/i, }, { name: 'JavBus', icon: '', url: 'https://www.JavBus.com/search/%s#query#jump', className: 'btn-warning', hostPattern: /(^|\.)javbus\.com$/i, }, { name: 'Missav', icon: '', url: 'https://missav.ws/dm18/cn/%s#jump', className: 'btn-warning', hostPattern: /(^|\.)missav\.(ws|live)$/i, }, // Add more providers here: // { // name: 'Google', // icon: '🌐', // url: 'https://www.google.com/search?q=', // className: 'btn-info' // } ] function buildProviderUrl(provider, code) { const encodedCode = encodeURIComponent(code) return provider.url.includes('%s') ? provider.url.replace('%s', encodedCode) : `${provider.url}${encodedCode}` } function getVisibleProviders() { const host = location.hostname return SEARCH_PROVIDERS.filter(provider => { if (!provider.hostPattern) return true return !provider.hostPattern.test(host) }) } function createSearchButton(code, provider, isListPage = false) { const btn = document.createElement(isListPage ? 'button' : 'a') btn.textContent = `${provider.icon} ${provider.name}` btn.className = `btn btn-xs ${provider.className} search-btn` btn.dataset.provider = provider.name // used by the hide-panel toggles btn.setAttribute('data-provider', provider.name) // explicit attr for iOS compat btn.style.marginLeft = '6px' btn.style.marginTop = isListPage ? '4px' : '0' if (isListPage) { // For list page buttons btn.style.padding = '2px 6px' btn.style.fontSize = '12px' btn.style.border = '1px solid #ccc' btn.style.borderRadius = '4px' btn.style.cursor = 'pointer' btn.onclick = e => { e.stopPropagation() window.open(buildProviderUrl(provider, code), '_blank') } } else { // For detail page buttons (links) btn.href = buildProviderUrl(provider, code) btn.target = '_blank' } return btn } function addSearchButtons(container, code, isListPage = false) { // Remove existing search buttons to avoid duplicates container.querySelectorAll('.search-btn').forEach(btn => btn.remove()) // Add all configured search buttons getVisibleProviders().forEach(provider => { const searchBtn = createSearchButton(code, provider, isListPage) container.appendChild(searchBtn) }) } function handleDetailPage() { const titleEl = document.querySelector('h1.h4') if (!titleEl) return const match = titleEl.textContent.match(/\[([^\]]+)\]/) if (!match) return const code = match[1] // Set up hide toggle first (creates the button in the card-header) handleExoticazHideToggle() // Place search buttons in card-header: after hide btn, before bookmark btn const bookmarkBtn = document.querySelector( 'button[title="Bookmark this Torrent"]' ) if (!bookmarkBtn) return const headerContainer = bookmarkBtn.parentElement if (!headerContainer) return let searchWrapper = headerContainer.querySelector('.search-wrapper-header') if (!searchWrapper) { searchWrapper = document.createElement('span') searchWrapper.className = 'search-wrapper-header' searchWrapper.style.marginRight = '6px' // Insert after hide button (before bookmark button) headerContainer.insertBefore(searchWrapper, bookmarkBtn) } addSearchButtons(searchWrapper, code, false) } function handleExoticazHideToggle() { // Only run once if (document.getElementById('exz-hide-toggle-btn')) return const bookmarkBtn = document.querySelector( 'button[title="Bookmark this Torrent"]' ) if (!bookmarkBtn) return const mainCard = bookmarkBtn.closest('.card.mb-3') // ── Hideable item definitions ──────────────────────────────────────────── const HIDE_ITEMS = [ { id: 'exz-item-navbar', label: 'Navigation Bar', getEls: () => { const el = document.querySelector('nav.navbar.navbar-expand-lg.fixed-top') || document.querySelector('.navbar.navbar-expand-lg.fixed-top') return el ? [el] : [] }, }, { id: 'exz-item-ratiobar', label: 'Ratio Bar', getEls: () => { const el = document.querySelector('.ratio-bar.mb-1') return el ? [el] : [] }, }, { id: 'exz-item-titleheader', label: 'Title Header', getEls: () => { const el = document.querySelector('h1.h4')?.closest('.card-header') return el ? [el] : [] }, }, { id: 'exz-item-suggestedits', label: 'Suggest Edits Btn', getEls: () => { const el = document.querySelector('button[title="Suggest Changes"]') return el ? [el] : [] }, }, { id: 'exz-item-reportbtn', label: 'Report Btn', getEls: () => { const el = document.querySelector( 'button[title="Report this torrent"]' ) return el ? [el] : [] }, }, { id: 'exz-item-cardbody', label: 'Torrent Details', getEls: () => { const el = mainCard?.querySelector(':scope > .card-body') return el ? [el] : [] }, }, { id: 'exz-item-screenshotsheader', label: 'Screenshots Header', getEls: () => { const el = document .querySelector('[data-target="#screenshots"], [href="#screenshots"]') ?.closest('.card-header') return el ? [el] : [] }, }, ] // ── State: load from localStorage, default all-hidden ─────────────────── const STORAGE_KEY = 'exz-hide-state' const state = {} let savedState = {} try { savedState = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') } catch (e) {} HIDE_ITEMS.forEach(item => { state[item.id] = item.id in savedState ? savedState[item.id] : true }) function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) } catch (e) {} } function applyItem(item) { item.getEls().forEach(el => { el.style.display = state[item.id] ? 'none' : '' }) } function applyAll() { HIDE_ITEMS.forEach(applyItem) } // Apply saved/default state immediately on page load applyAll() // ── Build popup panel ──────────────────────────────────────────────────── const panel = document.createElement('div') panel.id = 'exz-hide-panel' panel.style.cssText = [ 'display:none', 'position:fixed', 'z-index:99999', 'background:#fff', 'border:1px solid #d0d0d0', 'border-radius:7px', 'box-shadow:0 6px 20px rgba(0,0,0,0.18)', 'padding:10px 14px 12px', 'min-width:280px', 'font-size:13px', 'font-family:inherit', ].join(';') // Header row: title + Hide All / Show All const headerRow = document.createElement('div') headerRow.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;padding-bottom:7px;border-bottom:1px solid #ebebeb' const panelTitle = document.createElement('span') panelTitle.textContent = 'Visibility' panelTitle.style.cssText = 'font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:#888' const actionsDiv = document.createElement('div') function makeActionBtn(label, bg) { const b = document.createElement('button') b.type = 'button' b.textContent = label b.style.cssText = [ `background:${bg}`, 'color:#fff', 'border:none', 'border-radius:4px', 'padding:2px 9px', 'font-size:11px', 'cursor:pointer', 'margin-left:4px', ].join(';') return b } const hideAllBtn = makeActionBtn('Hide All', '#6c757d') const showAllBtn = makeActionBtn('Show All', '#17a2b8') hideAllBtn.onclick = () => { HIDE_ITEMS.forEach(item => (state[item.id] = true)) applyAll() saveState() syncCheckboxes() } showAllBtn.onclick = () => { HIDE_ITEMS.forEach(item => (state[item.id] = false)) applyAll() saveState() syncCheckboxes() } actionsDiv.appendChild(hideAllBtn) actionsDiv.appendChild(showAllBtn) headerRow.appendChild(panelTitle) headerRow.appendChild(actionsDiv) panel.appendChild(headerRow) // 2-column checkbox grid const grid = document.createElement('div') grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:2px 12px' const checkboxEls = {} HIDE_ITEMS.forEach(item => { const lbl = document.createElement('label') lbl.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;padding:4px 2px;user-select:none;color:#333' const cb = document.createElement('input') cb.type = 'checkbox' cb.checked = state[item.id] cb.style.cssText = 'cursor:pointer;width:13px;height:13px;flex-shrink:0' cb.addEventListener('change', () => { state[item.id] = cb.checked applyItem(item) saveState() }) checkboxEls[item.id] = cb const txt = document.createElement('span') txt.textContent = item.label txt.style.cssText = 'font-size:12px;line-height:1.3' lbl.appendChild(cb) lbl.appendChild(txt) grid.appendChild(lbl) }) panel.appendChild(grid) // ── Search Buttons section ─────────────────────────────────────────────── const visibleProviders = getVisibleProviders() if (visibleProviders.length) { const divider = document.createElement('div') divider.style.cssText = 'border-top:1px solid #ebebeb;margin:10px 0 8px' panel.appendChild(divider) const searchSectionHeader = document.createElement('div') searchSectionHeader.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:6px' const searchTitle = document.createElement('span') searchTitle.textContent = 'Search Buttons' searchTitle.style.cssText = 'font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:#888' const searchActionsDiv = document.createElement('div') const searchHideAllBtn = makeActionBtn('Hide All', '#6c757d') const searchShowAllBtn = makeActionBtn('Show All', '#17a2b8') searchActionsDiv.appendChild(searchHideAllBtn) searchActionsDiv.appendChild(searchShowAllBtn) searchSectionHeader.appendChild(searchTitle) searchSectionHeader.appendChild(searchActionsDiv) panel.appendChild(searchSectionHeader) const searchGrid = document.createElement('div') searchGrid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:2px 12px' // State: checked = shown; load from localStorage, default all on const SEARCH_STORAGE_KEY = 'exz-search-state' const searchState = {} const searchCheckboxEls = {} let savedSearchState = {} try { savedSearchState = JSON.parse(localStorage.getItem(SEARCH_STORAGE_KEY) || '{}') } catch (e) {} visibleProviders.forEach(p => { searchState[p.name] = p.name in savedSearchState ? savedSearchState[p.name] : true }) function saveSearchState() { try { localStorage.setItem(SEARCH_STORAGE_KEY, JSON.stringify(searchState)) } catch (e) {} } // iOS-safe: match by data-provider attr OR text content fallback function applySearchItem(name) { let el = document.querySelector( `.search-wrapper-header [data-provider="${name}"]` ) if (!el) { // Fallback: match by text content (for iOS where attr query may fail) document .querySelectorAll('.search-wrapper-header .search-btn') .forEach(candidate => { if (candidate.textContent.trim().includes(name)) el = candidate }) } if (el) el.style.display = searchState[name] ? '' : 'none' } // Apply saved search state AFTER search buttons are in the DOM // (addSearchButtons() runs after this function returns, so defer by one tick) setTimeout(() => { visibleProviders.forEach(p => applySearchItem(p.name)) }, 0) visibleProviders.forEach(p => { const lbl = document.createElement('label') lbl.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;padding:4px 2px;user-select:none;color:#333' const cb = document.createElement('input') cb.type = 'checkbox' cb.checked = searchState[p.name] cb.style.cssText = 'cursor:pointer;width:13px;height:13px;flex-shrink:0' cb.addEventListener('change', () => { searchState[p.name] = cb.checked applySearchItem(p.name) saveSearchState() }) searchCheckboxEls[p.name] = cb const txt = document.createElement('span') txt.textContent = p.name txt.style.cssText = 'font-size:12px;line-height:1.3' lbl.appendChild(cb) lbl.appendChild(txt) searchGrid.appendChild(lbl) }) panel.appendChild(searchGrid) function syncSearchCheckboxes() { visibleProviders.forEach(p => { if (searchCheckboxEls[p.name]) searchCheckboxEls[p.name].checked = searchState[p.name] }) } searchHideAllBtn.onclick = () => { visibleProviders.forEach(p => (searchState[p.name] = false)) visibleProviders.forEach(p => applySearchItem(p.name)) saveSearchState() syncSearchCheckboxes() } searchShowAllBtn.onclick = () => { visibleProviders.forEach(p => (searchState[p.name] = true)) visibleProviders.forEach(p => applySearchItem(p.name)) saveSearchState() syncSearchCheckboxes() } } document.body.appendChild(panel) function syncCheckboxes() { HIDE_ITEMS.forEach(item => { if (checkboxEls[item.id]) checkboxEls[item.id].checked = state[item.id] }) } // ── Toggle button (always labeled "Hide") ──────────────────────────────── const btn = document.createElement('button') btn.id = 'exz-hide-toggle-btn' btn.type = 'button' btn.className = 'btn btn-secondary btn-sm' btn.style.marginRight = '6px' btn.textContent = 'Hide' btn.addEventListener('click', e => { e.stopPropagation() const open = panel.style.display !== 'none' if (open) { panel.style.display = 'none' } else { const rect = btn.getBoundingClientRect() panel.style.top = rect.bottom + 4 + 'px' panel.style.left = rect.left + 'px' panel.style.display = 'block' } }) // Close panel when clicking outside document.addEventListener('click', e => { if (!panel.contains(e.target) && e.target !== btn) { panel.style.display = 'none' } }) // Insert to the LEFT of the bookmark button bookmarkBtn.parentElement.insertBefore(btn, bookmarkBtn) } function handleListPage() { // Use requestAnimationFrame to avoid blocking the main thread const processInBatches = () => { const torrentLinks = document.querySelectorAll( 'a.torrent-link:not([data-search-processed])' ) const batchSize = 10 // Process 10 items at a time for (let i = 0; i < Math.min(batchSize, torrentLinks.length); i++) { const link = torrentLinks[i] link.setAttribute('data-search-processed', 'true') const title = link.getAttribute('title') || link.textContent const match = title.match(/\[([^\]]+)\]/) if (!match) continue const code = match[1] const row = link.closest('tr') if (!row) continue const actionTd = row.querySelector('td > .align-top')?.parentElement const alignBottom = actionTd?.querySelector('.align-bottom') if (!alignBottom) continue // Create a wrapper for search buttons if it doesn't exist let searchWrapper = alignBottom.querySelector('.search-wrapper') if (!searchWrapper) { searchWrapper = document.createElement('div') searchWrapper.className = 'search-wrapper' searchWrapper.style.marginTop = '4px' alignBottom.appendChild(searchWrapper) } addSearchButtons(searchWrapper, code, true) } // If there are more items to process, schedule the next batch if (torrentLinks.length > batchSize) { requestAnimationFrame(processInBatches) } } requestAnimationFrame(processInBatches) } function extractMissavCode() { const segments = location.pathname.split('/').filter(Boolean) if (segments.length === 0) return null const slug = segments[segments.length - 1] if (!slug) return null // Example: juq-609-uncensored-leak -> juq-609 const match = slug.match(/[a-z]{2,8}-\d{2,6}/i) return match ? match[0].toLowerCase() : null } function createMissavSearchButton(code, provider) { const link = document.createElement('a') link.href = buildProviderUrl(provider, code) link.target = '_blank' link.rel = 'noopener noreferrer' link.className = 'inline-flex items-center whitespace-nowrap text-sm leading-4 font-medium focus:outline-none text-nord4 hover:text-nord6 search-btn' link.style.marginLeft = '12px' link.textContent = provider.name return link } function handleMissavPage() { let code = extractMissavCode() // Check for search result header (Point 3) const searchHeader = document.querySelector( 'h1.text-center.text-2xl.text-nord4.mb-6' ) if ( !code && searchHeader && (searchHeader.textContent.includes('的搜寻结果') || searchHeader.textContent.includes('Search results for')) ) { // Extract query from "XYZ 的搜寻结果" or "Search results for XYZ" code = searchHeader.textContent .replace('的搜寻结果', '') .replace('Search results for', '') .trim() } if (!code) return // Point 1: Standard Action Bar (Video detail page) const actionBar = document.querySelector( 'div.flex.flex-wrap.justify-center.py-8.rounded-md.shadow-sm' ) if (actionBar) { let wrapper = actionBar.querySelector('.search-wrapper-missav') if (!wrapper) { wrapper = document.createElement('span') wrapper.className = 'search-wrapper-missav inline-flex items-center' actionBar.appendChild(wrapper) } wrapper.querySelectorAll('.search-btn').forEach(btn => btn.remove()) getVisibleProviders().forEach(provider => { const btn = createMissavSearchButton(code, provider) wrapper.appendChild(btn) }) } // Point 2: Under "Back to Home" button const homeBtn = Array.from( document.querySelectorAll('a.button-primary') ).find( el => el.textContent.includes('返回主页') || (el.href && (el.href.endsWith('/cn') || el.href.endsWith('/en') || el.href.endsWith('/ja'))) ) if (homeBtn) { let wrapper = homeBtn.parentElement.querySelector( '.search-wrapper-missav-home' ) if (!wrapper) { wrapper = document.createElement('div') wrapper.className = 'search-wrapper-missav-home mt-4 flex flex-wrap justify-center' homeBtn.after(wrapper) } wrapper.querySelectorAll('.search-btn').forEach(btn => btn.remove()) getVisibleProviders().forEach(provider => { const btn = createMissavSearchButton(code, provider) btn.style.margin = '4px 6px' wrapper.appendChild(btn) }) } // Point 3: Under Search Header if (searchHeader) { let wrapper = searchHeader.nextElementSibling?.classList.contains( 'search-wrapper-missav-header' ) ? searchHeader.nextElementSibling : null if (!wrapper) { wrapper = document.createElement('div') wrapper.className = 'search-wrapper-missav-header mb-6 flex flex-wrap justify-center' searchHeader.after(wrapper) } wrapper.querySelectorAll('.search-btn').forEach(btn => btn.remove()) getVisibleProviders().forEach(provider => { const btn = createMissavSearchButton(code, provider) btn.style.margin = '4px 6px' wrapper.appendChild(btn) }) } } function shouldAutoJump() { return /(^|#)jump($|#|&)/i.test(location.hash) } function getFirstResultLinkByHost() { const selectorsByHost = [ { host: /(^|\.)exoticaz\.to$/i, selectors: ['a.torrent-link[href*="/torrent/"]'], }, { host: /(^|\.)javdb\.com$/i, selectors: [ '.movie-list a.box[href]', 'a.box[href*="/v/"]', 'a[href*="/v/"]', ], }, { host: /(^|\.)javmost\.com$/i, selectors: ['.post a[href]', '.entry-title a[href]', 'article a[href]'], }, { host: /(^|\.)javbus\.com$/i, selectors: [ 'a.movie-box[href]', 'a[href*="/uncensored/"]', 'a[href*="/search/"]', ], }, { host: /(^|\.)missav\.(ws|live)$/i, selectors: ['a[href*="/dm"]', 'a[href*="/cn/"]'], }, ] const hostRule = selectorsByHost.find(rule => rule.host.test(location.hostname) ) if (!hostRule) return null for (const selector of hostRule.selectors) { const link = document.querySelector(selector) if (!link) continue const href = link.getAttribute('href') || '' if (!href || href.startsWith('#') || href.startsWith('javascript:')) continue if (link.closest('header, nav, footer')) continue return link } return null } function handleAutoJump() { if (!shouldAutoJump()) return let attempts = 0 const maxAttempts = 20 const jumpTimer = setInterval(() => { attempts += 1 const firstLink = getFirstResultLinkByHost() if (!firstLink) { if (attempts >= maxAttempts) clearInterval(jumpTimer) return } clearInterval(jumpTimer) const destination = firstLink.href if (!destination || destination === location.href) return location.assign(destination) }, 250) } function init() { const isExoticaz = location.hostname === 'exoticaz.to' const isMissav = /(^|\.)missav\.(ws|live)$/.test(location.hostname) handleAutoJump() if (isExoticaz) { const isDetailPage = /https:\/\/exoticaz\.to\/torrent\/\d+/.test( location.href ) if (isDetailPage) { handleDetailPage() } else { handleListPage() // Throttled observer to prevent excessive function calls let observerTimeout const throttledHandleListPage = () => { clearTimeout(observerTimeout) observerTimeout = setTimeout(handleListPage, 250) // Wait 250ms before processing } const observer = new MutationObserver(throttledHandleListPage) observer.observe(document.body, { childList: true, subtree: true, // Only observe specific changes to reduce overhead attributeFilter: ['class', 'data-search-processed'], }) } } else if (isMissav) { handleMissavPage() let observerTimeout const throttledHandleMissavPage = () => { clearTimeout(observerTimeout) observerTimeout = setTimeout(handleMissavPage, 250) } const observer = new MutationObserver(throttledHandleMissavPage) observer.observe(document.body, { childList: true, subtree: true, }) } } // Use both DOMContentLoaded and load events for better reliability if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { // Small delay to ensure page is fully rendered setTimeout(init, 100) }) } else { setTimeout(init, 100) } // Backup initialization on window load window.addEventListener('load', () => { setTimeout(init, 200) }) })()