您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Stable: floating purple search, multi-term partial AND filtering, re-sorts visible items by price, preserves grid without assumptions about wrapper classes.
当前为
// ==UserScript== // @name Camerax Floating Live Multi-Term Search + Price Sort (Stable) // @namespace http://tampermonkey.net/ // @version 2.21 // @description Stable: floating purple search, multi-term partial AND filtering, re-sorts visible items by price, preserves grid without assumptions about wrapper classes. // @match https://camerax.com/product-category/* // @grant none // @license GNU GPLv3 // ==/UserScript== (function() { 'use strict'; const FLOAT_WIDTH = '320px'; const PURPLE = '#b200ff'; const DEBOUNCE_MS = 160; function log(...args) { try { console.log('[CamX-userscript]', ...args); } catch (e) {} } function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } function findProductsContainer() { return document.querySelector('.products') || document.querySelector('.products.row') || document.querySelector('ul.products') || document.querySelector('#main .content') || document.querySelector('.shop-container') || null; } // For each product element (.product-small), find the wrapper that is a direct child of container. // This preserves the exact structure the theme expects. function collectProductEntries(container) { if (!container) return []; const productEls = Array.from(container.querySelectorAll('.product-small')); const entries = productEls.map(productEl => { // climb ancestors until we reach an element whose parent is the container (or we stop at root) let wrapper = productEl; try { while (wrapper.parentElement && wrapper.parentElement !== container && wrapper !== document.body) { wrapper = wrapper.parentElement; } } catch (e) { /* defensive */ } // If we didn't find a wrapper whose parent is container, fallback to the productEl itself if (!wrapper || wrapper.parentElement !== container) { wrapper = productEl; } return { productEl, wrapperEl: wrapper }; }); // Remove duplicates (in case multiple product-small select the same wrapper) const seen = new Set(); const dedup = []; for (const e of entries) { if (!seen.has(e.wrapperEl)) { seen.add(e.wrapperEl); dedup.push(e); } } return dedup; } // Parse numeric price from wrapper: take last .woocommerce-Price-amount (handles ins/del) function parsePriceFromWrapper(el) { if (!el) return Infinity; try { const amounts = Array.from(el.querySelectorAll('.woocommerce-Price-amount')); if (amounts.length === 0) return Infinity; const node = amounts[amounts.length - 1]; const raw = (node.textContent || '').replace(/[^0-9.]/g, ''); const n = parseFloat(raw); return Number.isFinite(n) ? n : Infinity; } catch (e) { return Infinity; } } function getTitleAndMeta(entry) { try { const titleNode = entry.productEl.querySelector('.product-title a, .woocommerce-loop-product__title, .name.product-title'); const title = (titleNode && titleNode.textContent || '').trim(); const condNode = entry.productEl.querySelector('.condition-grade-shop, .condition-grade, .product-info .stock, .price-wrapper, .product-meta'); const condText = condNode ? condNode.textContent.trim() : ''; return (title + ' ' + condText).replace(/\s+/g, ' ').trim(); } catch (e) { return ''; } } // filter & resort: always re-collect entries to stay in sync with dynamic changes function filterAndResort(container, desc = false, query = '') { if (!container) return; try { const entries = collectProductEntries(container); const tokens = (query || '').toLowerCase().trim().split(/\s+/).filter(t => t.length > 0); // Show/hide wrappers based on tokens (AND semantics) entries.forEach(entry => { const text = (getTitleAndMeta(entry) || '').toLowerCase(); let visible = true; for (const tok of tokens) { if (!text.includes(tok)) { visible = false; break; } } // Use inline style display to hide wrapper try { entry.wrapperEl.style.display = visible ? '' : 'none'; } catch (e) {} }); // Collect visible wrappers and sort them by price const visible = entries .filter(e => e.wrapperEl && e.wrapperEl.offsetParent !== null) // visible in layout .sort((a, b) => { const pa = parsePriceFromWrapper(a.wrapperEl); const pb = parsePriceFromWrapper(b.wrapperEl); return desc ? pb - pa : pa - pb; }); // Re-append visible wrappers in sorted order (preserves existing nodes/classes) visible.forEach(e => { try { container.appendChild(e.wrapperEl); } catch (err) {} }); } catch (err) { log('filterAndResort error', err); } } function createFloatingUI(onChangeCallback) { // Avoid creating duplicate UI if (document.getElementById('camx-floating-filter')) { const existingInput = document.querySelector('#camx-floating-filter input[type="search"]'); return { root: document.getElementById('camx-floating-filter'), input: existingInput, getDesc: () => document.getElementById('camx-toggle')?.dataset.desc === '1' }; } const wrap = document.createElement('div'); wrap.id = 'camx-floating-filter'; wrap.style.position = 'fixed'; wrap.style.top = '18px'; wrap.style.right = '18px'; wrap.style.zIndex = '999999'; wrap.style.width = FLOAT_WIDTH; wrap.style.maxWidth = '46vw'; wrap.style.background = PURPLE; wrap.style.padding = '12px'; wrap.style.borderRadius = '10px'; wrap.style.boxShadow = '0 6px 18px rgba(0,0,0,0.25)'; wrap.style.fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial'; wrap.style.color = '#fff'; wrap.style.display = 'flex'; wrap.style.flexDirection = 'column'; wrap.style.gap = '8px'; wrap.style.alignItems = 'stretch'; const header = document.createElement('div'); header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; header.style.gap = '8px'; const title = document.createElement('div'); title.textContent = 'Filter & Sort'; title.style.fontWeight = '700'; title.style.fontSize = '13px'; const small = document.createElement('div'); small.style.fontSize = '11px'; small.textContent = 'price'; small.style.opacity = '0.95'; header.appendChild(title); header.appendChild(small); wrap.appendChild(header); const input = document.createElement('input'); input.type = 'search'; input.placeholder = 'space-separated terms (AND). partial words OK'; input.style.padding = '8px'; input.style.fontSize = '14px'; input.style.border = '2px solid rgba(255,255,255,0.95)'; input.style.borderRadius = '6px'; input.style.background = PURPLE; input.style.color = '#fff'; input.style.outline = 'none'; input.style.width = '100%'; input.autocomplete = 'off'; const controls = document.createElement('div'); controls.style.display = 'flex'; controls.style.gap = '6px'; controls.style.alignItems = 'center'; const toggle = document.createElement('button'); toggle.id = 'camx-toggle'; toggle.dataset.desc = '0'; toggle.textContent = 'Asc'; toggle.title = 'Toggle Asc/Desc'; toggle.style.flex = '1'; toggle.style.padding = '7px 8px'; toggle.style.border = 'none'; toggle.style.borderRadius = '6px'; toggle.style.background = '#fff'; toggle.style.color = PURPLE; toggle.style.fontWeight = '600'; toggle.style.cursor = 'pointer'; const clear = document.createElement('button'); clear.textContent = 'Clear'; clear.title = 'Clear filter'; clear.style.padding = '7px 8px'; clear.style.border = 'none'; clear.style.borderRadius = '6px'; clear.style.background = 'rgba(255,255,255,0.15)'; clear.style.color = '#fff'; clear.style.cursor = 'pointer'; controls.appendChild(toggle); controls.appendChild(clear); wrap.appendChild(input); wrap.appendChild(controls); document.body.appendChild(wrap); input.addEventListener('focus', () => input.style.boxShadow = '0 0 0 4px rgba(255,255,255,0.06)'); let desc = false; toggle.addEventListener('click', () => { desc = !desc; toggle.dataset.desc = desc ? '1' : '0'; toggle.textContent = desc ? 'Desc' : 'Asc'; onChangeCallback(input.value, desc); }); clear.addEventListener('click', () => { input.value = ''; onChangeCallback('', desc); input.focus(); }); input.addEventListener('input', debounce(() => onChangeCallback(input.value, desc), DEBOUNCE_MS)); return { root: wrap, input, toggle, clear, getDesc: () => toggle.dataset.desc === '1' }; } function initWhenReady(retries = 50) { const container = findProductsContainer(); if (container) { initApp(container); return; } if (retries <= 0) { log('products container not found; giving up.'); return; } setTimeout(() => initWhenReady(retries - 1), 300); } function initApp(container) { try { const ui = createFloatingUI((query, desc) => { filterAndResort(container, desc, query); }); // initial run filterAndResort(container, ui.getDesc(), ui.input.value || ''); // observe container for dynamic changes (safe debounce) const mo = new MutationObserver(debounce(() => { try { // on DOM changes, re-run filter/resort with current UI state filterAndResort(container, ui.getDesc(), ui.input.value || ''); } catch (e) { log('MO callback error', e); } }, 300)); mo.observe(container, { childList: true, subtree: true }); // re-run after history navigation window.addEventListener('popstate', () => { setTimeout(() => filterAndResort(container, ui.getDesc(), ui.input.value || ''), 300); }); log('CamX userscript initialized'); } catch (e) { log('initApp error', e); } } // Start initWhenReady(); })();