您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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); }); })();