AliExpress Advanced Search UI

Upgrades AliExpress with an advanced search UI for filtering products by keywords, phrases, and logical operators.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         AliExpress Advanced Search UI
// @name:lt      AliExpress išplėstinės paieškos vartotojo sąsaja
// @namespace    http://tampermonkey.net/
// @version      1.04
// @description  Upgrades AliExpress with an advanced search UI for filtering products by keywords, phrases, and logical operators.
// @description:lt Praplečia AliExpress su išplėstine paieškos vartotojo sąsaja, skirta filtruoti produktus pagal raktinius žodžius, frazes ir loginius operatorius.
// @author       LetMeFixIt
// @license      MIT
// @match        *://www.aliexpress.com/w/wholesale*
// @match        *://www.aliexpress.com/store/*/pages/all-items*
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_ID = 'ali-advanced-filter';
    const CONTAINER_ID = `${SCRIPT_ID}-container`;
    const FILTER_INPUT_ID = `${SCRIPT_ID}-input`;
    const CLEAR_BTN_ID = `${SCRIPT_ID}-clear-btn`;
    const TOGGLE_ID = `${SCRIPT_ID}-toggle`;
    const HELP_ID = `${SCRIPT_ID}-help`;
    const TOOLTIP_ID = `${SCRIPT_ID}-tooltip`;

    function addStyles() {
        GM_addStyle(`
            #_global_header_23_ { min-height: 100px !important; }
            /* Anchor for our filter bar */
            .pc-header--search--3hnHLKw, div[class*="shop-home-header-search-box"] {
                position: relative !important;
            }

            #${CONTAINER_ID} {
                position: absolute;
                top: 51px; /* Default for wholesale page */
                left: 0;
                width: 100%;
                display: flex;
                align-items: center;
                z-index: 999;
                padding: 0 5px;
                box-sizing: border-box;
            }
            /* Adjust position for smaller search bar on store pages */
            div[class*="shop-home-header-search-box"] #${CONTAINER_ID} {
                top: 35px;
            }

            #${FILTER_INPUT_ID} {
                flex-grow: 1;
                height: 34px;
                border: 1px solid #ccc;
                border-radius: 17px;
                padding: 0 85px 0 15px; /* Make space for all buttons */
                font-size: 14px;
                color: #000;
            }
            #${CLEAR_BTN_ID} {
                position: absolute;
                right: 80px; /* Adjusted for container padding */
                top: 50%;
                transform: translateY(-50%);
                cursor: pointer;
                font-size: 24px;
                color: #999;
                display: none;
                z-index: 10;
            }
            #${CLEAR_BTN_ID}:hover { color: #333; }
            .ali-filter-switch {
                position: absolute;
                right: 40px; /* Adjusted for container padding */
                top: 50%;
                transform: translateY(-50%);
                width: 40px;
                height: 22px;
            }
            .ali-filter-switch input { opacity: 0; width: 0; height: 0; }
            .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 22px; }
            .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 2px; bottom: 2px; background-color: white; transition: .4s; border-radius: 50%; }
            input:checked + .slider { background-color: #2196F3; }
            input:checked + .slider:before { transform: translateX(18px); }
            #${HELP_ID} {
                position: absolute;
                right: 10px; /* Adjusted for container padding */
                top: 50%;
                transform: translateY(-50%);
                width: 20px; height: 20px; border-radius: 50%; background-color: #f0f0f0; color: #666;
                display: flex; align-items: center; justify-content: center; font-weight: bold; cursor: pointer;
            }
            #${TOOLTIP_ID} {
                display: none; position: absolute; top: 34px; right: 0; width: 300px;
                background: #FFFFE0; color: #000; border: 1px solid #ccc; border-radius: 6px;
                padding: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 99999 !important;
            }
            #${TOOLTIP_ID} ul { margin: 0; padding: 0 0 0 18px; }
            #${TOOLTIP_ID} li { margin-bottom: 5px; }
            #${HELP_ID}:hover + #${TOOLTIP_ID}, #${TOOLTIP_ID}:hover { display: block; }

            /* More robust fix for multi-line titles, anchored to the #card-list container */
            #card-list .search-card-item div[title],
            #card-list .search-card-item h3 {
                white-space: normal !important;
                text-overflow: clip !important;
                height: auto !important;
                display: -webkit-box !important;
                -webkit-line-clamp: 5 !important;
                -webkit-box-orient: vertical !important;
                overflow: hidden !important;
            }

            /* Fix for multi-line titles on store pages - scoped to be inside a link to avoid affecting other elements */
            div[st_page_id] a span[numberOfLines] {
                white-space: normal !important;
                height: auto !important;
                display: -webkit-box !important;
                -webkit-line-clamp: 5 !important; /* Allow up to 5 lines */
                -webkit-box-orient: vertical !important;
                overflow: hidden !important;
            }
        `);
    }

    function setupUI(anchorElement) {
        if (document.getElementById(CONTAINER_ID)) return;
        const filterContainer = document.createElement('div');
        filterContainer.id = CONTAINER_ID;
        filterContainer.innerHTML = ' \
            <input type="text" id="' + FILTER_INPUT_ID + '" placeholder="Filter: (a OR b) AND c -d"> \
            <span id="' + CLEAR_BTN_ID + '">&times;</span> \
            <label class="ali-filter-switch"> \
                <input type="checkbox" id="' + TOGGLE_ID + '"> \
                <span class="slider round"></span> \
            </label> \
            <span id="' + HELP_ID + '">?</span> \
            <div id="' + TOOLTIP_ID + '"> \
                <strong>Advanced Filtering Rules:</strong> \
                <ul> \
                    <li><b>Implicit AND:</b> <code>word1 word2</code></li> \
                    <li><b>Exact Phrase:</b> <code>"exact phrase"</code></li> \
                    <li><b>Exclude:</b> <code>-word</code> or <code>-"phrase"</code></li> \
                    <li><b>OR Logic:</b> <code>word1 OR word2</code></li> \
                    <li><b>Grouping:</b> <code>(word1 OR word2) AND word3</code></li> \
                </ul> \
            </div> \
        ';
        anchorElement.appendChild(filterContainer);
    }

    function parseQueryToRPN(query) {
        if (!query) return [];

        // 1. Tokenize the query string more robustly
        const tokens = query.match(/-?"[^"]+"|\bAND\b|\bOR\b|[\w-]+|[()]/g) || [];

        // 2. Insert implicit AND operators
        const processedTokens = [];
        for (let i = 0; i < tokens.length; i++) {
            processedTokens.push(tokens[i]);
            const current = tokens[i].toUpperCase();
            const next = (tokens[i + 1] || '').toUpperCase();

            const isCurrentTerm = current !== '(' && current !== 'AND' && current !== 'OR';
            const isNextTerm = next && next !== ')' && next !== 'AND' && next !== 'OR';

            if (i < tokens.length - 1 && isCurrentTerm && isNextTerm) {
                processedTokens.push('AND');
            }
        }

        // 3. Shunting-yard algorithm
        const precedence = { 'OR': 1, 'AND': 2 };
        const output = [];
        const operators = [];

        for (const token of processedTokens) {
            const upperToken = token.toUpperCase();
            if (precedence[upperToken]) {
                while (
                    operators.length &&
                    operators[operators.length - 1] !== '(' &&
                    precedence[operators[operators.length - 1]] >= precedence[upperToken]
                ) {
                    output.push(operators.pop());
                }
                operators.push(upperToken);
            } else if (token === '(') {
                operators.push(token);
            } else if (token === ')') {
                while (operators.length && operators[operators.length - 1] !== '(') {
                    output.push(operators.pop());
                }
                operators.pop(); // Pop the '('
            } else {
                output.push(token); // This is a term
            }
        }
        return output.concat(operators.reverse());
    }


    function evaluateRPN(rpn, title) {
        if (!rpn || rpn.length === 0) return true;
        const stack = [];
        for (const token of rpn) {
            if (token === 'AND' || token === 'OR') {
                if (stack.length < 2) return false; // Invalid expression
                const b = stack.pop();
                const a = stack.pop();
                stack.push(token === 'AND' ? (a && b) : (a || b));
            } else {
                let term = token.toLowerCase();
                const isNegative = term.startsWith('-');
                if (isNegative) term = term.substring(1);

                const isPhrase = term.startsWith('"') && term.endsWith('"');
                if (isPhrase) term = term.slice(1, -1);

                // If the term is empty after all stripping, treat as a neutral match.
                if (term === '') {
                    stack.push(true);
                    continue;
                }

                const match = title.includes(term);
                stack.push(isNegative ? !match : match);
            }
        }
        return stack.length === 1 ? stack.pop() : false; // Should be a single value left
    }

    function runFilter() {
        const toggle = document.getElementById(TOGGLE_ID);
        const input = document.getElementById(FILTER_INPUT_ID);
        if (!toggle || !input) return;

        const isEnabled = toggle.checked;
        const query = input.value;
        // Use :has(a) to ensure we only select product items on store pages, not other UI elements with st_page_id
        const productElements = document.querySelectorAll('div[data-tticheck="true"], div[st_page_id]:has(a)');

        if (!isEnabled) {
            productElements.forEach(p => { p.style.display = 'block'; });
            return;
        }

        const rpn = parseQueryToRPN(query);

        productElements.forEach(productEl => {
            let title = '';
            const titleEl = productEl.querySelector('h3, span[numberOfLines]');

            if (titleEl) {
                title = titleEl.textContent.toLowerCase();
            } else {
                const imgEl = productEl.querySelector('img[alt]');
                if (imgEl) {
                    title = imgEl.alt.toLowerCase();
                }
            }

            if (title) {
                try {
                    const shouldShow = query.trim() ? evaluateRPN(rpn.slice(), title) : true;
                    productEl.style.display = shouldShow ? 'block' : 'none';
                } catch (e) {
                    console.error("Filtering error:", e);
                    productEl.style.display = 'block'; // Failsafe
                }
            } else {
                 productEl.style.display = 'block'; // Failsafe if no title
            }
        });
    }

    function updateUI() {
        const input = document.getElementById(FILTER_INPUT_ID);
        const clear = document.getElementById(CLEAR_BTN_ID);
        if (!input || !clear) return;
        const hasText = input.value.length > 0;
        clear.style.display = hasText ? 'block' : 'none';
        input.style.backgroundColor = hasText ? 'lightyellow' : 'white';
    }

    function main() {
        const searchContainer = document.querySelector('.pc-header--search--3hnHLKw, div[class*="shop-home-header-search-box"]');
        if (!searchContainer) {
            console.error("AliExpress Filter: Search container not found.");
            return;
        }

        addStyles();
        setupUI(searchContainer);

        const filterInput = document.getElementById(FILTER_INPUT_ID);
        const clearBtn = document.getElementById(CLEAR_BTN_ID);
        const toggleCheckbox = document.getElementById(TOGGLE_ID);

        filterInput.value = sessionStorage.getItem('ali-filter-query') || '';
        toggleCheckbox.checked = (sessionStorage.getItem('ali-filter-enabled') || 'true') === 'true';
        updateUI();

        filterInput.addEventListener('input', () => {
            sessionStorage.setItem('ali-filter-query', filterInput.value);
            updateUI();
            runFilter();
        });

        clearBtn.addEventListener('click', () => {
            filterInput.value = '';
            sessionStorage.removeItem('ali-filter-query');
            updateUI();
            runFilter();
        });

        toggleCheckbox.addEventListener('change', () => {
            sessionStorage.setItem('ali-filter-enabled', toggleCheckbox.checked);
            runFilter();
        });

        const observer = new MutationObserver(runFilter);
        const targetNode = document.getElementById('root');
        if (targetNode) {
            setTimeout(runFilter, 1500);
            observer.observe(targetNode, { childList: true, subtree: true });
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }
})();