Google Maps Place Validator

Scan Google Maps using the Nearby Search API and validate places for missing/invalid data such as website, phone number, hours or emojis in names.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Google Maps Place Validator
// @namespace    https://github.com/gncnpk/google-maps-place-validator
// @author       Gavin Canon-Phratsachack (https://github.com/gncnpk)
// @version      0.0.9
// @description  Scan Google Maps using the Nearby Search API and validate places for missing/invalid data such as website, phone number, hours or emojis in names.
// @match        https://*.google.com/maps/*@*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=google.com/maps
// @run-at       document-start
// @license      MIT
// @grant        none
// ==/UserScript==

;
(function() {
    'use strict';

    // Avoid double-inject
    if (document.getElementById('md-panel')) return;

    const STORAGE_KEY = 'md_api_key';
    const STORAGE_WHITE = 'md_whitelist';
    const STORAGE_BLACKLIST = 'md_type_blacklist';
    const STORAGE_POS = 'md_panel_pos';
    const STORAGE_SIZE = 'md_panel_size';
    const STORAGE_CACHE = 'md_results_cache';

    /**
     * Detects if text contains emojis
     */
    function hasEmoji(text) {
        if (!text || typeof text !== 'string') return false;

        // Comprehensive emoji regex pattern
        const emojiRegex = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{FE00}-\u{FE0F}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23F3}]|[\u{24C2}]|[\u{23E9}-\u{23EF}]|[\u{25B6}]|[\u{23F8}-\u{23FA}]/gu;

        return emojiRegex.test(text);
    }

    /**
     * Extracts the zoom level from a Google Maps URL of the form
     *    …@<lat>,<lng>,<zoom>z…
     * returns a Number or null if none found.
     */
    function getZoomFromUrl() {
        const m = window.location.href.match(
            /@[-\d.]+,[-\d.]+,([\d.]+)z/
        );
        return m ? parseFloat(m[1]) : null;
    }

    /**
     * Given a zoom level, returns a radius in meters.
     * At baseZoom=10 → baseRadius=50000.
     * Each zoom level ↑ halves the radius.
     * Clamped to [100, 50000].
     */
    function computeRadius(zoom) {
        const baseZoom = 10;
        const baseRadius = 50000;
        if (zoom === null) return baseRadius;
        const r = baseRadius * Math.pow(2, baseZoom - zoom);
        return Math.min(baseRadius, Math.max(100, Math.round(r)));
    }

    // Cache management
    let resultsCache = [];
    try {
        const cached = JSON.parse(localStorage.getItem(STORAGE_CACHE) || '[]');
        if (Array.isArray(cached)) resultsCache = cached;
    } catch {}

    function persistCache() {
        // Keep only last 10 cache entries to avoid storage bloat
        if (resultsCache.length > 10) {
            resultsCache = resultsCache.slice(-10);
        }
        localStorage.setItem(STORAGE_CACHE, JSON.stringify(resultsCache));
    }

    function generateCacheKey(lat, lng, radius) {
        // Round coordinates to avoid too many similar cache entries
        const roundLat = Math.round(lat * 1000) / 1000;
        const roundLng = Math.round(lng * 1000) / 1000;
        return `${roundLat},${roundLng},${radius}`;
    }

    // Build panel
    const panel = document.createElement('div');
    panel.id = 'md-panel';
    Object.assign(panel.style, {
        position: 'fixed',
        top: '10px',
        left: '10px',
        width: '360px',
        minWidth: '200px',
        minHeight: '120px',
        background: '#fff',
        border: '1px solid #333',
        borderRadius: '4px',
        boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
        zIndex: 999999,
        userSelect: 'none',
        fontFamily: 'sans-serif',
        fontSize: '14px',
        resize: 'both',
        overflow: 'auto'
    });
    panel.innerHTML = `
    <div id="md-header" style="
        background:#444;color:#fff;padding:6px 8px;
        display:flex;justify-content:space-between;
        align-items:center;border-radius:4px 4px 0 0;
        cursor:move;">
      <span>Place Validator</span>
      <button id="md-close-btn" style="
          background:transparent;border:none;
          color:#fff;font-size:16px;line-height:1;
          cursor:pointer;" title="Hide panel">×</button>
    </div>
    <div id="md-content" style="padding:8px;display:flex;flex-direction:column;height:calc(100% - 40px);">
      <div id="md-key-section" style="margin-bottom:6px;flex-shrink:0;">
        <input id="md-api-key" type="text"
          placeholder="Enter API Key"
          style="width:100%;box-sizing:border-box;
                 padding:4px;border:1px solid #ccc;
                 border-radius:2px;"/>
        <button id="md-set-btn" style="
          width:100%;margin-top:4px;padding:6px;
          background:#28a;color:#fff;border:none;
          border-radius:2px;cursor:pointer;
        ">Set API Key</button>
      </div>
      <div style="margin-bottom:6px;flex-shrink:0;">
        <button id="md-scan-btn" disabled style="
          width:100%;padding:6px;
          background:#28a;color:#fff;border:none;
          border-radius:2px;cursor:pointer;
        ">Scan Nearby</button>
      </div>
      <div style="margin-bottom:6px;display:flex;gap:4px;flex-shrink:0;">
        <button id="md-cached-btn" style="
          flex:1;padding:4px;
          background:#4a4;color:#fff;border:none;
          border-radius:2px;cursor:pointer;font-size:12px;
        ">View Cached Results</button>
        <button id="md-clear-cache-btn" style="
          padding:4px 8px;
          background:#d44;color:#fff;border:none;
          border-radius:2px;cursor:pointer;font-size:12px;
        ">Clear Cache</button>
      </div>
      <div style="margin-bottom:6px;flex-shrink:0;">
        <button id="md-manage-blacklist-btn" style="
          width:100%;padding:4px;
          background:#666;color:#fff;border:none;
          border-radius:2px;cursor:pointer;font-size:12px;
        ">Manage Type Blacklist</button>
      </div>
      <div id="md-blacklist-section" style="display:none;margin-bottom:6px;background:#f5f5f5;padding:6px;border-radius:2px;flex-shrink:0;">
        <div style="font-weight:bold;margin-bottom:4px;">Blacklisted Types:</div>
        <div id="md-blacklist-display" style="font-size:12px;margin-bottom:6px;"></div>
        <input id="md-new-blacklist-type" type="text"
          placeholder="Add type (e.g., bus_stop)"
          style="width:70%;box-sizing:border-box;
                 padding:3px;border:1px solid #ccc;
                 border-radius:2px;font-size:12px;"/>
        <button id="md-add-blacklist-btn" style="
          width:25%;margin-left:2%;padding:3px;
          background:#d44;color:#fff;border:none;
          border-radius:2px;cursor:pointer;font-size:12px;
        ">Add</button>
      </div>
      <div id="md-cache-section" style="display:none;margin-bottom:6px;background:#f0f8ff;padding:6px;border-radius:2px;flex-shrink:0;">
        <div style="font-weight:bold;margin-bottom:4px;">Cached Results:</div>
        <div id="md-cache-list" style="font-size:11px;max-height:100px;overflow-y:auto;"></div>
      </div>
      <div id="md-output" style="
          flex:1;
          min-height:150px;
          overflow-x:auto;
          overflow-y:auto;
          background:#f9f9f9;padding:6px;
          border:1px solid #ccc;border-radius:2px;
          white-space:nowrap;
      "></div>
    </div>
  `;
    document.body.appendChild(panel);

    // Restore last position
    const rawPos = localStorage.getItem(STORAGE_POS);
    if (rawPos) {
        try {
            const pos = JSON.parse(rawPos);
            if (pos.top) panel.style.top = pos.top;
            if (pos.left) panel.style.left = pos.left;
        } catch {}
    }

    // Restore last size
    const rawSize = localStorage.getItem(STORAGE_SIZE);
    if (rawSize) {
        try {
            const sz = JSON.parse(rawSize);
            if (sz.width) panel.style.width = sz.width;
            if (sz.height) panel.style.height = sz.height;
        } catch {}
    }

    // Track resizes and persist
    const ro = new ResizeObserver(entries => {
        for (const entry of entries) {
            const {
                width,
                height
            } = entry.contentRect;
            localStorage.setItem(
                STORAGE_SIZE,
                JSON.stringify({
                    width: Math.round(width) + 'px',
                    height: Math.round(height) + 'px'
                })
            );
        }
    });
    ro.observe(panel);

    function adjustPanelSize() {
        const maxHeight = window.innerHeight - 100; // Leave some margin
        const maxWidth = window.innerWidth - 100;

        const currentHeight = parseInt(panel.style.height) || 400;
        const currentWidth = parseInt(panel.style.width) || 360;

        if (currentHeight > maxHeight) {
            panel.style.height = maxHeight + 'px';
        }
        if (currentWidth > maxWidth) {
            panel.style.width = maxWidth + 'px';
        }

        // Ensure panel stays within viewport
        const rect = panel.getBoundingClientRect();
        if (rect.right > window.innerWidth) {
            panel.style.left = (window.innerWidth - rect.width - 10) + 'px';
        }
        if (rect.bottom > window.innerHeight) {
            panel.style.top = (window.innerHeight - rect.height - 10) + 'px';
        }
    }

    window.addEventListener('resize', adjustPanelSize);

    // Drag support via Pointer Events
    const header = document.getElementById('md-header');
    let dragging = false,
        offsetX = 0,
        offsetY = 0;
    header.style.touchAction = 'none';

    header.addEventListener('pointerdown', e => {
        // Don't start dragging if clicking on the close button
        if (e.target.id === 'md-close-btn') {
            return;
        }

        dragging = true;
        const r = panel.getBoundingClientRect();
        offsetX = e.clientX - r.left;
        offsetY = e.clientY - r.top;
        header.setPointerCapture(e.pointerId);
        e.preventDefault();
    });

    document.addEventListener('pointermove', e => {
        if (!dragging) return;
        panel.style.left = (e.clientX - offsetX) + 'px';
        panel.style.top = (e.clientY - offsetY) + 'px';
    });

    document.addEventListener('pointerup', e => {
        if (!dragging) return;
        dragging = false;
        try {
            header.releasePointerCapture(e.pointerId);
        } catch {}
        // Persist position
        localStorage.setItem(
            STORAGE_POS,
            JSON.stringify({
                left: panel.style.left,
                top: panel.style.top
            })
        );
    });

    header.addEventListener('pointercancel', () => {
        dragging = false;
    });

    // Close/toggle button - wait for DOM to be ready
    function setupToggleButton() {
        const closeBtn = panel.querySelector('#md-close-btn');
        const contentDiv = panel.querySelector('#md-content');

        if (closeBtn && contentDiv) {
            closeBtn.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();

                // Use getComputedStyle to check actual visibility
                const isHidden = window.getComputedStyle(contentDiv).display === 'none';

                if (isHidden) {
                    // Show content
                    contentDiv.style.display = 'flex';
                    closeBtn.textContent = '×';
                    closeBtn.title = 'Hide panel';
                    // Restore panel height
                    panel.style.height = '';
                    panel.style.minHeight = '120px';
                } else {
                    // Hide content
                    contentDiv.style.display = 'none';
                    closeBtn.textContent = '↑';
                    closeBtn.title = 'Show panel';
                    // Set panel height to just the header
                    panel.style.height = 'auto';
                    panel.style.minHeight = '40px';
                }
            });
        }
    }

    // Setup toggle button after a short delay to ensure DOM is ready
    setTimeout(setupToggleButton, 100);

    // Controls
    const keySection = document.getElementById('md-key-section');
    const keyInput = document.getElementById('md-api-key');
    const setBtn = document.getElementById('md-set-btn');
    const scanBtn = document.getElementById('md-scan-btn');
    const cachedBtn = document.getElementById('md-cached-btn');
    const clearCacheBtn = document.getElementById('md-clear-cache-btn');
    const cacheSection = document.getElementById('md-cache-section');
    const cacheList = document.getElementById('md-cache-list');
    const output = document.getElementById('md-output');

    // Load API key
    if (localStorage.getItem(STORAGE_KEY)) {
        keySection.style.display = 'none';
        scanBtn.disabled = false;
    }

    // Update cached button state
    function updateCacheButtonState() {
        cachedBtn.disabled = resultsCache.length === 0;
        clearCacheBtn.disabled = resultsCache.length === 0;
        if (resultsCache.length === 0) {
            cachedBtn.style.background = '#ccc';
            clearCacheBtn.style.background = '#ccc';
        } else {
            cachedBtn.style.background = '#4a4';
            clearCacheBtn.style.background = '#d44';
        }
    }

    updateCacheButtonState();

    // Whitelist
    let whitelist = [];
    try {
        const w = JSON.parse(localStorage.getItem(STORAGE_WHITE) || '[]');
        if (Array.isArray(w)) whitelist = w;
    } catch {}

    function persistWhitelist() {
        localStorage.setItem(STORAGE_WHITE, JSON.stringify(whitelist));
    }

    let typeBlacklist = ['bus_stop', 'public_bathroom', 'doctor', 'consultant', 'transit_station', 'playground', 'swimming_pool'];
    try {
        const b = JSON.parse(localStorage.getItem(STORAGE_BLACKLIST) || '[]');
        if (Array.isArray(b) && b.length > 0) typeBlacklist = b;
    } catch {}

    function persistTypeBlacklist() {
        localStorage.setItem(STORAGE_BLACKLIST, JSON.stringify(typeBlacklist));
    }

    // Blacklist management UI
    const manageBlacklistBtn = document.getElementById('md-manage-blacklist-btn');
    const blacklistSection = document.getElementById('md-blacklist-section');
    const blacklistDisplay = document.getElementById('md-blacklist-display');
    const newBlacklistInput = document.getElementById('md-new-blacklist-type');
    const addBlacklistBtn = document.getElementById('md-add-blacklist-btn');

    function updateBlacklistDisplay() {
        if (typeBlacklist.length === 0) {
            blacklistDisplay.textContent = 'None';
        } else {
            blacklistDisplay.innerHTML = typeBlacklist.map(type => {
                return `<span style="background:#ddd;padding:2px 6px;margin:2px;border-radius:2px;display:inline-block;">
                    ${type}
                    <button onclick="removeFromBlacklist('${type}')" style="background:none;border:none;color:#666;cursor:pointer;margin-left:4px;">×</button>
                </span>`;
            }).join('');
        }
    }

    // Cache management UI
    function updateCacheList() {
        if (resultsCache.length === 0) {
            cacheList.innerHTML = '<div style="color:#666;">No cached results</div>';
            return;
        }

        cacheList.innerHTML = resultsCache.map((cache, idx) => {
            const date = new Date(cache.timestamp).toLocaleString();
            const location = `${cache.lat.toFixed(3)}, ${cache.lng.toFixed(3)}`;
            return `<div style="margin-bottom:4px;padding:4px;background:#fff;border-radius:2px;">
                <div style="font-weight:bold;">${date}</div>
                <div>Location: ${location} (${cache.radius}m radius)</div>
                <div>Results: ${cache.results.length} places</div>
                <button onclick="loadCachedResult(${idx})" style="
                    background:#28a;color:#fff;border:none;
                    border-radius:2px;padding:2px 6px;cursor:pointer;
                    font-size:10px;margin-top:2px;">Load</button>
            </div>`;
        }).join('');
    }

    // Make functions globally accessible for inline onclick
    window.removeFromBlacklist = function(type) {
        typeBlacklist = typeBlacklist.filter(t => t !== type);
        persistTypeBlacklist();
        updateBlacklistDisplay();
    };

    window.loadCachedResult = function(idx) {
        if (idx >= 0 && idx < resultsCache.length) {
            const cache = resultsCache[idx];
            displayResults(cache.results, true, new Date(cache.timestamp));
            cacheSection.style.display = 'none';
        }
    };

    // Cache management event listeners
    cachedBtn.addEventListener('click', () => {
        const isVisible = cacheSection.style.display !== 'none';
        cacheSection.style.display = isVisible ? 'none' : 'block';
        if (!isVisible) updateCacheList();
    });

    clearCacheBtn.addEventListener('click', () => {
        if (confirm('Clear all cached results?')) {
            resultsCache = [];
            localStorage.removeItem(STORAGE_CACHE);
            updateCacheButtonState();
            updateCacheList();
            cacheSection.style.display = 'none';
        }
    });

    manageBlacklistBtn.addEventListener('click', () => {
        const isVisible = blacklistSection.style.display !== 'none';
        blacklistSection.style.display = isVisible ? 'none' : 'block';
        if (!isVisible) updateBlacklistDisplay();
    });

    addBlacklistBtn.addEventListener('click', () => {
        const newType = newBlacklistInput.value.trim().toLowerCase();
        if (newType && !typeBlacklist.includes(newType)) {
            typeBlacklist.push(newType);
            persistTypeBlacklist();
            updateBlacklistDisplay();
            newBlacklistInput.value = '';
        }
    });

    newBlacklistInput.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') {
            addBlacklistBtn.click();
        }
    });

    // Set API key
    setBtn.addEventListener('click', () => {
        const k = keyInput.value.trim();
        if (!k) return alert('Please enter a valid API key.');
        localStorage.setItem(STORAGE_KEY, k);
        keySection.style.display = 'none';
        scanBtn.disabled = false;
    });

    // Field labels
    const FIELD_LABELS = {
        websiteUri: 'Website',
        nationalPhoneNumber: 'Phone number',
        currentOpeningHours: 'Hours',
        hasEmoji: 'Has emoji in name'
    };

    function getPlaceName(p) {
        const d = p.displayName;
        if (typeof d === 'string') return d;
        if (d && typeof d.text === 'string') return d.text;
        if (d && typeof d.name === 'string') return d.name;
        return p.id;
    }

    function findMissing(arr) {
        return arr.reduce((acc, p) => {
            // Filter out places where name is just street number + route or just route
            if (p.addressComponents && Array.isArray(p.addressComponents)) {
                const streetNumberComponent = p.addressComponents.find(
                    c => c.types && c.types.includes('street_number')
                );

                const routeComponent = p.addressComponents.find(
                    c => c.types && c.types.includes('route')
                );

                const placeName = getPlaceName(p);

                if (routeComponent) {
                    const routeShort = routeComponent.shortText;
                    const routeLong = routeComponent.longText;

                    // Skip if the place name is just the street name (short or long)
                    if (placeName === routeShort || placeName === routeLong) {
                        return acc;
                    }

                    // Skip if the place name is street number + route (any combination)
                    if (streetNumberComponent) {
                        const streetNumberShort = streetNumberComponent.shortText;
                        const streetNumberLong = streetNumberComponent.longText;

                        const combinations = [
                            `${streetNumberShort} ${routeShort}`,
                            `${streetNumberShort} ${routeLong}`,
                            `${streetNumberLong} ${routeShort}`,
                            `${streetNumberLong} ${routeLong}`
                        ];

                        if (combinations.includes(placeName)) {
                            return acc;
                        }
                    }
                }
            }

            const miss = [];
            const placeName = getPlaceName(p);

            // Check for missing data
            if (!p.websiteUri ||
                !p.websiteUri.trim()) miss.push(
                FIELD_LABELS.websiteUri
            );
            if (!p.nationalPhoneNumber ||
                !p.nationalPhoneNumber.trim()) miss.push(
                FIELD_LABELS.nationalPhoneNumber
            );
            if (!p.currentOpeningHours ||
                typeof p.currentOpeningHours !== 'object') miss.push(
                FIELD_LABELS.currentOpeningHours
            );

            // Check for emojis in name
            if (hasEmoji(placeName)) {
                miss.push(FIELD_LABELS.hasEmoji);
            }

            // Add to results if has missing data OR has emojis
            if (miss.length) {
                // capture primaryType if present
                const typeName = p.primaryTypeDisplayName?.text || ''
                acc.push({
                    id: p.id,
                    name: placeName,
                    uri: p.googleMapsUri,
                    missing: miss,
                    primaryTypeDisplayName: typeName,
                    primaryType: p.primaryType
                });
            }
            return acc;
        }, []);
    }

    function displayResults(missing, isFromCache = false, cacheDate = null) {
        if (!missing.length) {
            const message = isFromCache ?
                `✅ All places had complete data and no emojis (cached ${cacheDate ? cacheDate.toLocaleString() : ''})` :
                '✅ All places have complete data and no emojis in names.';
            output.textContent = message;
            return;
        }

        // Filter out whitelisted places
        missing = missing.filter(p => !whitelist.includes(p.id));

        if (!missing.length) {
            const message = isFromCache ?
                `✅ All remaining places had complete data and no emojis (cached ${cacheDate ? cacheDate.toLocaleString() : ''})` :
                '✅ All places have complete data and no emojis in names.';
            output.textContent = message;
            return;
        }

        // Create header with cache info
        output.innerHTML = '';

        if (isFromCache && cacheDate) {
            const cacheInfo = document.createElement('div');
            cacheInfo.style.cssText = 'background:#e6f3ff;padding:4px;margin-bottom:6px;border-radius:2px;font-size:12px;color:#0066cc;';
            cacheInfo.textContent = `📄 Cached results from ${cacheDate.toLocaleString()}`;
            output.appendChild(cacheInfo);
        }

        // Render list
        const ul = document.createElement('ul');
        ul.style.listStyle = 'disc';
        ul.style.margin = '0';
        ul.style.padding = '0 0 0 1em';

        missing.forEach(p => {
            const li = document.createElement('li');
            li.style.whiteSpace = 'nowrap';
            li.style.marginBottom = '9px';
            li.style.position = 'relative';
            li.style.paddingRight = '120px'; // Add space to prevent text from going under buttons

            const a = document.createElement('a');
            a.href = p.uri;
            a.textContent = p.name;
            a.target = '_blank';
            a.style.fontWeight = 'bold';

            // Add emoji indicator if name has emojis
            if (hasEmoji(p.name)) {
                a.style.color = '#ff6600'; // Orange color for emoji names
            }

            li.appendChild(a);

            // show type next to the link
            if (p.primaryTypeDisplayName) {
                li.appendChild(
                    document.createTextNode(
                        ` (${p.primaryTypeDisplayName})`
                    )
                );
            }
            // then show missing fields
            li.appendChild(
                document.createTextNode(
                    ' – flagged for: ' + p.missing.join(', ')
                )
            );

            // Create button container for proper alignment
            const buttonContainer = document.createElement('div');
            buttonContainer.style.cssText = 'position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: inline-flex; gap: 4px; align-items: center; background: rgba(255,255,255,0.9); border-radius: 2px; padding: 2px;';

            const btn = document.createElement('button');
            btn.textContent = 'Whitelist';
            btn.style.cssText = `
    background: #28a;
    color: #fff;
    border: none;
    border-radius: 2px;
    padding: 2px 8px;
    cursor: pointer;
    font-size: 11px;
    height: 22px;
    line-height: 1;
`;
            btn.addEventListener('click', () => {
                if (!whitelist.includes(p.id)) {
                    whitelist.push(p.id);
                    persistWhitelist();
                }
                li.remove();
                if (!ul.childElementCount) {
                    const message = isFromCache ?
                        `✅ All remaining places had complete data and no emojis (cached ${cacheDate ? cacheDate.toLocaleString() : ''})` :
                        '✅ All places have complete data and no emojis in names.';
                    output.textContent = message;
                }
            });
            buttonContainer.appendChild(btn);

            // Add blacklist button for the type (only for fresh results)
            if (p.primaryType && !isFromCache) {
                const blacklistBtn = document.createElement('button');
                blacklistBtn.textContent = 'Blacklist Type';
                blacklistBtn.style.cssText = `
        background: #d44;
        color: #fff;
        border: none;
        border-radius: 2px;
        padding: 2px 8px;
        cursor: pointer;
        font-size: 11px;
        height: 22px;
        line-height: 1;
    `;
                blacklistBtn.addEventListener('click', () => {
                    const type = p.primaryType.toLowerCase();
                    if (!typeBlacklist.includes(type)) {
                        typeBlacklist.push(type);
                        persistTypeBlacklist();
                        alert(`Added "${type}" to blacklist. Please scan again to see updated results.`);
                    }
                });
                buttonContainer.appendChild(blacklistBtn);
            }

            li.appendChild(buttonContainer);

            ul.appendChild(li);
        });

        output.appendChild(ul);
    }

    // Scan action
    scanBtn.addEventListener('click', async () => {
        output.textContent = '';
        const key = localStorage.getItem(STORAGE_KEY);
        if (!key) {
            output.textContent = '❌ API key missing.';
            return;
        }

        // Parse coords
        let lat, lng;
        try {
            const part = window.location.href.split('@')[1].split('/')[0];
            [lat, lng] = part.split(',').map(n => parseFloat(n));
            if (isNaN(lat) || isNaN(lng)) throw 0;
        } catch {
            output.textContent =
                '❌ Could not parse "@lat,lng" from URL.';
            return;
        }

        const zoom = getZoomFromUrl();
        const radius = computeRadius(zoom);
        const cacheKey = generateCacheKey(lat, lng, radius);

        // Check if we have recent cached results for this location
        const recentCache = resultsCache.find(cache => {
            const cacheAge = Date.now() - cache.timestamp;
            const maxAge = 30 * 60 * 1000; // 30 minutes
            return cache.cacheKey === cacheKey && cacheAge < maxAge;
        });

        if (recentCache) {
            const ageMinutes = Math.round((Date.now() - recentCache.timestamp) / (60 * 1000));
            if (confirm(`Found cached results from ${ageMinutes} minutes ago. Use cached results instead of making a new API request?`)) {
                displayResults(recentCache.results, true, new Date(recentCache.timestamp));
                return;
            }
        }

        const body = {
            locationRestriction: {
                circle: {
                    center: {
                        latitude: lat,
                        longitude: lng
                    },
                    radius: radius
                }
            },
            rankPreference: "DISTANCE"
        };

        // Add excludedTypes to the request body if there are any blacklisted types
        if (typeBlacklist.length > 0) {
            body.excludedTypes = typeBlacklist;
        }

        // Fetch
        let data;
        try {
            const res = await fetch(
                `https://places.googleapis.com/v1/places:searchNearby?key=` +
                `${encodeURIComponent(key)}`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-Goog-FieldMask': ['places.id',
                            'places.displayName',
                            'places.websiteUri',
                            'places.nationalPhoneNumber',
                            'places.currentOpeningHours',
                            'places.googleMapsUri',
                            'places.primaryType',
                            'places.primaryTypeDisplayName',
                            'places.addressComponents'
                        ].join(",")
                    },
                    body: JSON.stringify(body)
                }
            );
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            data = await res.json();
        } catch (err) {
            output.textContent = '❌ Fetch error: ' + err.message;
            return;
        }

        const arr = Array.isArray(data.places) ? data.places :
            Array.isArray(data.results) ? data.results : [];

        let missing = findMissing(arr);

        // Cache the results
        const cacheEntry = {
            timestamp: Date.now(),
            lat: lat,
            lng: lng,
            radius: radius,
            cacheKey: cacheKey,
            results: missing
        };

        // Remove any existing cache for this location to avoid duplicates
        resultsCache = resultsCache.filter(cache => cache.cacheKey !== cacheKey);
        resultsCache.push(cacheEntry);
        persistCache();
        updateCacheButtonState();

        displayResults(missing, false);
    });
})();