OpenRice Advanced Filter

希望幫到大家揾到一D,野食出品好過Marketing嘅餐廳。

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         OpenRice Advanced Filter
// @namespace    https://www.openrice.com/
// @version      1.0
// @description  希望幫到大家揾到一D,野食出品好過Marketing嘅餐廳。
// @match        https://www.openrice.com/*
// @grant        none
// @license      9code.ai
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // ======== SETTINGS ========
    const DEFAULT_SMILE_COUNT = 200;
    const PRIMARY_COLOR = '#FFC107'; // Yellow theme
    const PANEL_MAX_WIDTH = '330px'; // Max width of the settings panel

    // Load saved settings from localStorage
    let enableSmileFiltering = localStorage.getItem('enableSmileFiltering') !== "false";
    let maxSmileCount = parseInt(localStorage.getItem('maxSmileCount')) || DEFAULT_SMILE_COUNT;
    let filterReviews = localStorage.getItem('filterReviews') === "true";
    let hideMobileSmile = localStorage.getItem('hideMobileSmile') === "true";
    let hideSponsoredRestaurants = localStorage.getItem('hideSponsoredRestaurants') !== "false"; // Default to ON

    // CSS Selectors
    const LMS_AD_CONTENT_SELECTOR = 'div.poi-list-lms-target-ad-swiper div.basic-slider.basic-slider'; // Safe selector (v2.0)
    const LMS_AD_WRAPPER_SELECTOR = 'section.poi-list-lms-target-ad-swiper-wrapper';
    const RESTAURANT_BLOCK_SELECTOR = '.poi-list-cell-desktop-container, section.poi-list-cell-wrapper';
    const REVIEW_BLOCK_SELECTOR = '.review-cell-mobile.poi-detail-review-cell-mobile, .review-post-desktop.poi-detail-review';

    // MODIFIED v5.5: Simplified selector to catch both mobile and desktop smiles
    const SMILE_ICON_SELECTOR = [
        'img.review-post-smile', // Catches both PC and Mobile review smiles
        '.icon.or-sprite.common_icon_smile', // Old generic selector
        '.icon.or-sprite-inline-block.common_smiley_smile_60x60_desktop' // Old generic selector 2
    ].join(', ');

    let hidingTimer = null;

    // ==========================
    // == REMOVAL/HIDING LOGIC ==
    // ==========================

    function removeElement(selector) {
        const element = document.querySelector(selector);
        if (element) element.remove();
    }

    function removeMatchingElements(selector, action = el => el.remove()) {
        const elements = document.querySelectorAll(selector);
        elements.forEach(action);
    }

    // --- Ad/Banner Removal Functions (Always On) ---
    function removeSmartBanner() { removeElement('.smart-banner.popup'); }
    function removePopupAds() { removeElement('.popup-ad-subview.with-mask'); }
    function removeGptAds() { removeMatchingElements('div[id*="div-gpt-ad-"]'); }
    function removeLmsAdContent() {
        removeMatchingElements(LMS_AD_CONTENT_SELECTOR);
    }

    // --- Content Hiding Functions ---
    // Using user's v5.3 logic
    function applyRestaurantHidingLogic(section) {
        if (section.matches && section.matches(LMS_AD_WRAPPER_SELECTOR)) return;

        let smileCountEl = section.querySelector('.poi-score-row .smile.icon-wrapper .text') || // PC
                           section.querySelector('.poi-score-row .smile-icon + span'); // Mobile

        let promotionBadge = section.querySelector('.poi-list-cell-sponsored-badge');

        let shouldHide = false;

        if (enableSmileFiltering && smileCountEl) {
            let smileCount = parseInt(smileCountEl.textContent.trim(), 10);
            if (!isNaN(smileCount) && smileCount > maxSmileCount) {
                shouldHide = true;
            }
        }

        if (!shouldHide && hideSponsoredRestaurants && promotionBadge) {
            shouldHide = true;
        }

        if (shouldHide) {
            section.style.display = 'none';
        } else {
             section.style.display = '';
             section.style.visibility = 'visible';
        }
    }

    function applyReviewHidingLogic(review) {
        if (!filterReviews) return false;
        const writerInfo = review.querySelector('.review-post-writer-info');
        if (writerInfo) {
            const infoDivs = Array.from(writerInfo.children).filter(el => el.tagName === 'DIV');
            for (const div of infoDivs) {
                const text = div.textContent.trim();
                if (text === '等級4' || text === 'Level4') {
                    review.style.display = 'none'; return true;
                }
            }
        }
        return false;
    }

    function applyMobileSmileReviewHidingLogic(review) {
        if (!hideMobileSmile) return false;
        const smileIcon = review.querySelector(SMILE_ICON_SELECTOR); // Now uses robust selector
        if (smileIcon) {
            review.style.display = 'none'; return true;
        }
        return false;
    }

    function applyAllReviewFilters(review) {
        review.style.visibility = 'visible';
        review.style.display = '';
        const hidByLv4 = applyReviewHidingLogic(review);
        const hidBySmile = !hidByLv4 ? applyMobileSmileReviewHidingLogic(review) : false;

        if(!hidByLv4 && !hidBySmile) {
             review.style.visibility = 'visible';
             review.style.display = '';
        }
    }

    function runAllRemovals() {
        removeSmartBanner();
        removePopupAds();
        removeGptAds();
        removeLmsAdContent(); // Using safe v2.0 logic
    }

    function runAllHiding() {
        document.querySelectorAll(RESTAURANT_BLOCK_SELECTOR).forEach(applyRestaurantHidingLogic);
        document.querySelectorAll(REVIEW_BLOCK_SELECTOR).forEach(applyAllReviewFilters);
    }


    // =============================
    // == MUTATION OBSERVER SETUP ==
    // =============================

    function processAddedNodeForAds(node) {
        if (node.matches && (
            node.matches('.smart-banner.popup') ||
            node.matches('.popup-ad-subview.with-mask')
        )) node.remove();
        if (node.id && node.id.includes("div-gpt-ad-")) node.remove();

        if (node.matches && node.matches('.basic-slider.basic-slider') && node.closest('.poi-list-lms-target-ad-swiper')) {
           node.remove();
        }
        if (node.matches && node.matches('.poi-list-lms-target-ad-swiper')) {
            node.querySelectorAll('.basic-slider.basic-slider').forEach(ad => ad.remove());
        }
        node.querySelectorAll && node.querySelectorAll('div[id*="div-gpt-ad-"]').forEach(ad => ad.remove());
        node.querySelectorAll && node.querySelectorAll(LMS_AD_CONTENT_SELECTOR).forEach(ad => ad.remove());
    }

    function preHideContent(node) {
        if (node.matches && (node.matches(RESTAURANT_BLOCK_SELECTOR) || node.matches(REVIEW_BLOCK_SELECTOR))) {
            node.style.visibility = 'hidden';
        }
        node.querySelectorAll && node.querySelectorAll(RESTAURANT_BLOCK_SELECTOR).forEach(el => el.style.visibility = 'hidden');
        node.querySelectorAll && node.querySelectorAll(REVIEW_BLOCK_SELECTOR).forEach(el => el.style.visibility = 'hidden');
    }

    const observerCallback = (mutationsList, observer) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList') {
                // Step 1: Instant processing
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === 1) { // Is element
                        processAddedNodeForAds(node);
                        preHideContent(node);
                    }
                });
            }
        }

        // Step 2: Debounce (delay) the *final* hiding/showing logic
        if (hidingTimer) clearTimeout(hidingTimer);
        hidingTimer = setTimeout(() => {
            runAllHiding(); // Run final filtering
            hidingTimer = null;
        }, 300); // 300ms delay
    };

    const observer = new MutationObserver(observerCallback);
    const observerConfig = { childList: true, subtree: true };

    // ============================
    // == PANEL UI & STYLES ========
    // ============================

    function createPanelStyles() {
        const styles = `
            #or-filter-panel-container {
                font-family: Roboto, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
                position: fixed; top: 50%; right: calc(-1 * min(${PANEL_MAX_WIDTH}, 95vw));
                transform: translateY(-50%); display: flex; align-items: center; z-index: 9999;
                transition: right 0.35s ease-out;
            }
            #or-filter-panel-container.or-filter-open { right: 0; }
            #or-filter-trigger-tab {
                background-color: ${PRIMARY_COLOR}; color: white; padding: 16px 8px 16px 10px;
                border-radius: 256px 0 0 256px; cursor: pointer; box-shadow: 0 4px 8px rgba(0,0,0,0.15);
                display: flex; align-items: center; justify-content: center;
                transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
            }
            #or-filter-trigger-tab:hover { box-shadow: 0 6px 12px rgba(0,0,0,0.2); }
            #or-filter-trigger-tab svg { transition: transform 0.3s ease-out; }
            #or-filter-panel-container.or-filter-open #or-filter-trigger-tab svg { transform: rotate(180deg); }
            #or-filter-modal {
                width: ${PANEL_MAX_WIDTH}; max-width: 95vw; background-color: #FFFFFF;
                padding: 24px; border-radius: 16px; position: relative;
                box-shadow: none; transition: box-shadow 0.3s ease-out;
            }
            #or-filter-panel-container.or-filter-open #or-filter-modal {
                box-shadow: 0 1px 2px rgba(0,0,0,0.3), 0 2px 6px 2px rgba(0,0,0,0.15);
            }
            .or-filter-modal-header h3 {
                margin: 0 0 24px 0;
                font-size: 20px;
                font-weight: 500;
                color: #1f1f1f;
                text-align: center;
            }
            .or-filter-subtitle {
                font-size: 16px;
                font-weight: 500;
                color: #444746; /* Google Material grey */
                text-align: left;
                margin-bottom: 16px;
                margin-top: 0; /* Handled by divider or header */
            }
            .or-filter-divider {
                border: none; height: 1px; background-color: #DADCE0;
                margin: 28px 0 24px 0;
            }
            .or-filter-setting-row {
                display: flex; align-items: center; justify-content: space-between;
                margin-bottom: 18px;
                min-height: 36px;
            }
            .or-filter-setting-row label {
                font-size: 15px;
                color: #3c4043;
                padding-right: 16px;
                flex: 1;
                font-weight: 400;
            }
            .or-filter-setting-row label.or-filter-switch { flex: 0 0 40px; width: 40px; padding-right: 0; }
            .or-filter-setting-row input[type="number"] {
                width: 64px;
                padding: 8px;
                text-align: right;
                font-size: 16px; /* Prevents iOS zoom */
                color: #202124;
                border: 1px solid #DADCE0;
                border-radius: 8px;
                transition: border-color 0.2s, box-shadow 0.2s;
            }
            .or-filter-setting-row input[type="number"]:focus {
                border-color: ${PRIMARY_COLOR};
                box-shadow: 0 0 0 1px ${PRIMARY_COLOR};
                outline: none;
            }
            .or-filter-button-group { display: flex; justify-content: flex-end; margin-top: 24px; }
            .or-filter-button {
                padding: 10px 24px; border: none; border-radius: 24px; cursor: pointer;
                font-weight: 500; font-size: 14px; text-transform: none;
                transition: background-color 0.2s, box-shadow 0.2s;
            }
            .or-filter-button-primary {
                background-color: ${PRIMARY_COLOR}; color: #333;
            }
            .or-filter-button-primary:hover {
                background-color: #FFD54F;
            }
            .or-filter-switch {
                position: relative; display: inline-block;
                width: 40px; height: 24px;
            }
            .or-filter-switch input { opacity: 0; width: 0; height: 0; }
            .or-filter-slider {
                position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
                background-color: #DADCE0; transition: .4s; border-radius: 24px;
            }
            .or-filter-slider:before {
                position: absolute; content: "";
                height: 18px; width: 18px; left: 3px; bottom: 3px;
                background-color: white; transition: .4s; border-radius: 50%;
                box-shadow: 0 1px 3px rgba(0,0,0,0.2);
            }
            input:checked + .or-filter-slider { background-color: ${PRIMARY_COLOR}; }
            input:checked + .or-filter-slider:before { transform: translateX(16px); }
        `;
        const styleSheet = document.createElement("style");
        styleSheet.type = "text/css";
        styleSheet.innerText = styles;
        document.head.appendChild(styleSheet);
    }

    function createFilterPanel() {
        let existingPanel = document.getElementById('or-filter-panel-container');
        if (existingPanel) existingPanel.remove();

        const container = document.createElement('div');
        container.id = 'or-filter-panel-container';

        // Using user's v5.3 HTML structure
        container.innerHTML = `
            <div id="or-filter-trigger-tab" title="Filter Settings">
                <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="white"><path d="M560-240 320-480l240-240 56 56-184 184 184 184-56 56Z"/></svg>
            </div>
            <div id="or-filter-modal">
                <div class="or-filter-modal-header"><h3>OpenRice Filter Settings</h3></div>

                <h5 class="or-filter-subtitle">RESTAURANT LIST FILTERS</h5>
                <div class="or-filter-setting-row">
                    <label for="or-filter-enableSmile">Enable Smile Filtering</label>
                    <label class="or-filter-switch"><input type="checkbox" id="or-filter-enableSmile" ${enableSmileFiltering ? 'checked' : ''}><span class="or-filter-slider"></span></label>
                </div>
                <div class="or-filter-setting-row">
                    <label for="or-filter-likesInput">Max Smiles to Show</label>
                    <input type="number" id="or-filter-likesInput" value="${maxSmileCount === Infinity ? DEFAULT_SMILE_COUNT : maxSmileCount}" pattern="[0-9]*" inputmode="numeric">
                </div>
                <div class="or-filter-setting-row">
                    <label for="or-filter-hideSponsors">Hide Sponsored Restaurants</label>
                    <label class="or-filter-switch"><input type="checkbox" id="or-filter-hideSponsors" ${hideSponsoredRestaurants ? 'checked' : ''}><span class="or-filter-slider"></span></label>
                </div>

                <hr class="or-filter-divider">

                <h5 class="or-filter-subtitle">REVIEW FILTERS</h5>
                <div class="or-filter-setting-row">
                    <label for="or-filter-filterReviews">Hide Level 4 Reviews</label>
                    <label class="or-filter-switch"><input type="checkbox" id="or-filter-filterReviews" ${filterReviews ? 'checked' : ''}><span class="or-filter-slider"></span></label>
                </div>
                <div class="or-filter-setting-row">
                    <label for="or-filter-hideMobileSmile">Hide Reviews Smile</label>
                    <label class="or-filter-switch"><input type="checkbox" id="or-filter-hideMobileSmile" ${hideMobileSmile ? 'checked' : ''}><span class="or-filter-slider"></span></label>
                </div>

                <div class="or-filter-button-group">
                    <button id="or-filter-apply" class="or-filter-button or-filter-button-primary">套用並刷新</button>
                </div>
            </div>
        `;

        document.body.appendChild(container);

        // --- Event Listeners for Buttons ---
        const triggerTab = container.querySelector('#or-filter-trigger-tab');
        const applyButton = container.querySelector('#or-filter-apply');

        const toggleHandler = (e) => {
            e.preventDefault();
            container.classList.toggle('or-filter-open');
        };
        triggerTab.addEventListener('click', toggleHandler);
        triggerTab.addEventListener('touchend', toggleHandler); // Mobile fix

        const applyAndRefreshHandler = (e) => {
            e.preventDefault();
            enableSmileFiltering = document.getElementById('or-filter-enableSmile').checked;
            maxSmileCount = parseInt(document.getElementById('or-filter-likesInput').value) || DEFAULT_SMILE_COUNT;
            filterReviews = document.getElementById('or-filter-filterReviews').checked;
            hideMobileSmile = document.getElementById('or-filter-hideMobileSmile').checked;
            hideSponsoredRestaurants = document.getElementById('or-filter-hideSponsors').checked;

            localStorage.setItem('enableSmileFiltering', enableSmileFiltering);
            localStorage.setItem('maxSmileCount', maxSmileCount);
            localStorage.setItem('filterReviews', filterReviews);
            localStorage.setItem('hideMobileSmile', hideMobileSmile);
            localStorage.setItem('hideSponsoredRestaurants', hideSponsoredRestaurants);

            location.reload();
        };
        applyButton.addEventListener('click', applyAndRefreshHandler);
        applyButton.addEventListener('touchend', applyAndRefreshHandler); // Mobile fix
    }

    // ========================
    // == MAIN EXECUTION ======
    // ========================

    createPanelStyles(); // Inject styles

    // --- Initial Scan & Cleanup ---
    runAllRemovals(); // Run ad removals ONCE
    runAllHiding(); // Run content hiding ONCE

    createFilterPanel(); // Create the UI

    // --- Start Observing ---
    observer.observe(document.body, observerConfig);

})();