Google Search Better Favicons

Replaces low-resolution favicons in Google search results with higher-quality versions.

// ==UserScript==
// @name               Google Search Better Favicons
// @namespace          https://greasyfork.org/en/users/1467948-stonedkhajiit
// @version            0.1.0
// @description        Replaces low-resolution favicons in Google search results with higher-quality versions.
// @author             StonedKhajiit
// @icon               https://www.google.com/s2/favicons?sz=64&domain=google.com
// @match              https://www.google.tld/search*
// @exclude            /^https?:\/\/([a-z0-9-]+\.)*google\.[a-z.]+\/search.*(udm=2|udm=7|udm=28|udm=36|udm=39)/
// @run-at             document-end
// @grant              GM_addStyle
// @grant              GM_getValue
// @grant              GM_setValue
// @grant              GM_registerMenuCommand
// @license            MIT
// ==/UserScript==

(function() {
    'use strict';

    // ---[ 1. CONFIGURATION & STATE ]---

    const GSBF_FAVICON_SHAPE_KEY = 'gsbf_favicon_shape';
    const GSBF_SQUARE_FILL_KEY = 'gsbf_square_fill';
    const GSBF_MENU_COMMAND_SHAPE_ID = 'gsbf_toggle_favicon_shape';
    const GSBF_MENU_COMMAND_FILL_ID = 'gsbf_toggle_square_fill';

    const SELECTORS = {
        // Selector for result items on the "News" tab, used for style exclusions.
        newsSearchResult: '.SoaBEf',
        // The primary selector for containers that are treated as a single search result.
        searchResultItem: '.MjjYud, .SoaBEf, .m7jPZ, .A6K0A',
        // Selects the element that directly contains the favicon image within a result.
        faviconContainer: 'span.DDKf1c, span.H9lube, g-img.QyR1Ze',
        // Selects the main link within various result item structures.
        linkElement: '.yuRUbf a[href], .xe8e1b a[href], a.WlydOe[href], .a-no-hover-decoration[href], .h4kbcd[href]'
    };

    // ---[ 2. STYLES ]---

    const STYLES = `
        /* gsbf-processed-favicon: Base class for all script-handled icons. */
        /* gsbf-upgraded-favicon: Added only after a successful HQ replacement to trigger the fill effect. */

        /* Style the image directly for .DDKf1c containers. */
        body.gsbf-square-favicons :is(.MjjYud, .m7jPZ, .A6K0A):not(:has(.SoaBEf)) .DDKf1c.gsbf-processed-favicon img {
            border-radius: 4px !important;
        }

        /* Style the container for .H9lube containers to create a mask. */
        body.gsbf-square-favicons :is(.MjjYud, .m7jPZ, .A6K0A):not(:has(.SoaBEf)) .H9lube.gsbf-processed-favicon {
            border-radius: 4px !important;
            overflow: hidden !important;
        }

        /* Ensure the image inside a styled .H9lube container remains a true square. */
        body.gsbf-square-favicons :is(.MjjYud, .m7jPZ, .A6K0A):not(:has(.SoaBEf)) .H9lube.gsbf-processed-favicon img {
            border-radius: 0 !important;
        }

        /* Enlarge the icon to fill its container if it was successfully upgraded. */
        body.gsbf-square-favicons.gsbf-square-fill-enabled :is(.MjjYud, .m7jPZ, .A6K0A):not(:has(.SoaBEf)) .H9lube.gsbf-upgraded-favicon img.XNo5Ab {
            width: 26px !important;
            height: 26px !important;
        }

        /* Add a transition effect for smoothness. */
        .gsbf-processed-favicon,
        .gsbf-processed-favicon img {
             transition: border-radius 0.2s ease-in-out, width 0.2s ease-in-out, height 0.2s ease-in-out;
        }
    `;

    // ---[ 3. CORE LOGIC ]---

    /**
     * Reads all stored preferences and applies the corresponding CSS classes to the document body.
     */
    function applyPreferences() {
        // Apply shape preference, with "square" as the new default.
        const shape = GM_getValue(GSBF_FAVICON_SHAPE_KEY, 'square');
        document.body.classList.toggle('gsbf-square-favicons', shape === 'square');

        // Apply square fill preference
        const fill = GM_getValue(GSBF_SQUARE_FILL_KEY, true); // Default to true (enabled)
        document.body.classList.toggle('gsbf-square-fill-enabled', fill);

        updateMenuCommands();
    }

    /**
     * Registers and updates the state of all toggleable menu commands.
     */
    function updateMenuCommands() {
        // Command 1: Toggle favicon shape
        const currentShape = GM_getValue(GSBF_FAVICON_SHAPE_KEY, 'square');
        const isSquare = currentShape === 'square';
        const shapeMenuText = `${isSquare ? '[✓]' : '[  ]'} Use Square Favicons`;

        GM_registerMenuCommand(shapeMenuText, () => {
            GM_setValue(GSBF_FAVICON_SHAPE_KEY, isSquare ? 'circle' : 'square');
            applyPreferences();
        }, { id: GSBF_MENU_COMMAND_SHAPE_ID });

        // Command 2: Toggle fill effect for square icons
        const currentFill = GM_getValue(GSBF_SQUARE_FILL_KEY, true);
        const fillMenuText = `  ${currentFill ? '[✓]' : '[  ]'} Enlarge HQ Square Icons to Fill`;

        GM_registerMenuCommand(fillMenuText, () => {
            GM_setValue(GSBF_SQUARE_FILL_KEY, !currentFill);
            applyPreferences();
        }, { id: GSBF_MENU_COMMAND_FILL_ID });
    }


    /**
     * Enhances a single favicon by replacing it with a high-quality version if available.
     * @param {HTMLElement} faviconContainer - The DOM element containing the favicon.
     * @param {string} domain - The domain of the search result.
     */
    function enhanceResultFavicon(faviconContainer, domain) {
        if (!faviconContainer || !domain) return;

        // Immediately add the 'processed' class to apply basic styles.
        faviconContainer.classList.add('gsbf-processed-favicon');

        const existingImg = faviconContainer.querySelector('img');
        const existingSvg = faviconContainer.querySelector('svg');

        // If Google shows a generic SVG placeholder, skip network request.
        if (existingSvg) {
            return;
        }

        // Proceed only if there is an actual image to potentially replace.
        if (!existingImg) {
            return;
        }

        const targetSize = 96;
        const highQualityUrl = `https://www.google.com/s2/favicons?sz=${targetSize}&domain=${domain}`;

        const newImg = new Image();
        newImg.onload = () => {
            // Replace the icon only if the new one is higher resolution.
            if (newImg.naturalWidth > (existingImg.naturalWidth || 16)) {
                existingImg.src = highQualityUrl;
                // Add the 'upgraded' class only AFTER successful replacement.
                faviconContainer.classList.add('gsbf-upgraded-favicon');
            }
        };
        newImg.onerror = () => { /* Graceful failure */ };
        newImg.src = highQualityUrl;
    }

    /**
     * Processes a single search result DOM node to find and enhance its favicon.
     * @param {HTMLElement} resultNode - The container element of a single search result.
     */
    function processResultItem(resultNode) {
        if (resultNode.dataset.gsbfProcessed) return;
        resultNode.dataset.gsbfProcessed = 'true';

        const faviconContainer = resultNode.querySelector(SELECTORS.faviconContainer);
        const linkElement = resultNode.querySelector(SELECTORS.linkElement);
        if (!faviconContainer || !linkElement) return;

        try {
            let targetUrl;
            const href = linkElement.href;

            // Handle Google's redirect links.
            if (href && href.startsWith(window.location.origin + '/url?q=')) {
                targetUrl = new URL(href).searchParams.get('q');
            } else {
                targetUrl = href;
            }

            const domain = new URL(targetUrl).hostname;

            enhanceResultFavicon(faviconContainer, domain);
        } catch(e) {
            // Ignore nodes that don't have a valid URL structure.
        }
    }

    /**
     * Sets up the MutationObserver and initializes the script.
     */
    function main() {
        GM_addStyle(STYLES);
        applyPreferences();

        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                for (const addedNode of mutation.addedNodes) {
                    if (addedNode.nodeType === 1) {
                        if (addedNode.matches(SELECTORS.searchResultItem)) {
                            processResultItem(addedNode);
                        }
                        addedNode.querySelectorAll(SELECTORS.searchResultItem).forEach(processResultItem);
                    }
                }
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });

        // Process results already on the page at script load.
        document.querySelectorAll(SELECTORS.searchResultItem).forEach(processResultItem);
    }

    main();

})();