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.

// ==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);
    });
})();