AliExpress Advanced Search UI

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
    }
})();