const axios = require('axios'); const winston = require('winston'); // Configure logger const logger = winston.createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [ new winston.transports.Console({ format: winston.format.simple() }) ] }); // Cache for geocoding results (simple in-memory cache) const geocodeCache = new Map(); const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours // Clean up old cache entries periodically setInterval(() => { const now = Date.now(); for (const [key, value] of geocodeCache.entries()) { if (now - value.timestamp > CACHE_TTL) { geocodeCache.delete(key); } } }, 60 * 60 * 1000); // Run every hour /** * Multi-provider geocoding configuration * Providers are tried in order until one succeeds with good confidence */ // Provider configuration - order matters (higher quality providers first) const GEOCODING_PROVIDERS = [ // Premium provider (when API key is available) { name: 'Mapbox', func: geocodeWithMapbox, enabled: () => !!(process.env.MAPBOX_ACCESS_TOKEN || process.env.MAPBOX_API_KEY), options: { timeout: 8000, delay: 0 } }, // Free providers (fallbacks) { name: 'Nominatim', func: geocodeWithNominatim, enabled: () => true, options: { timeout: 5000, delay: 1000 } }, { name: 'Photon', func: geocodeWithPhoton, enabled: () => true, options: { timeout: 5000, delay: 500 } }, { name: 'LocationIQ', func: geocodeWithLocationIQ, enabled: () => !!process.env.LOCATIONIQ_API_KEY, options: { timeout: 5000, delay: 0 } }, { name: 'ArcGIS', func: geocodeWithArcGIS, enabled: () => true, options: { timeout: 8000, delay: 500 } } ]; /** * Geocode with Nominatim (OpenStreetMap) */ // Geocoding provider functions async function geocodeWithMapbox(address, options = {}) { const { timeout = 5000, delay = 0 } = options; const apiKey = process.env.MAPBOX_ACCESS_TOKEN || process.env.MAPBOX_API_KEY; if (!apiKey) { throw new Error('Mapbox API key not configured'); } // Rate limiting (Mapbox has generous rate limits) if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } // Parse address components for structured input const addressComponents = parseAddressString(address); let url; if (addressComponents.hasComponents) { // Use structured input for better accuracy const params = new URLSearchParams({ access_token: apiKey, limit: 1, country: addressComponents.country || 'ca' // Default to Canada }); if (addressComponents.address_number) params.append('address_number', addressComponents.address_number); if (addressComponents.street) params.append('street', addressComponents.street); if (addressComponents.place) params.append('place', addressComponents.place); if (addressComponents.region) params.append('region', addressComponents.region); if (addressComponents.postcode) params.append('postcode', addressComponents.postcode); url = `https://api.mapbox.com/search/geocode/v6/forward?${params.toString()}`; } else { // Fallback to simple search url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${apiKey}&limit=1&country=ca`; } logger.info(`Geocoding with Mapbox: ${address}`); try { const response = await axios.get(url, { timeout }); const data = response.data; // Debug: Log the API response structure logger.debug(`Mapbox response structure:`, { hasFeatures: !!(data.features && data.features.length > 0), hasData: !!(data.data && data.data.length > 0), featuresLength: data.features?.length || 0, dataLength: data.data?.length || 0, firstFeature: data.features?.[0] ? Object.keys(data.features[0]) : null, firstData: data.data?.[0] ? Object.keys(data.data[0]) : null }); let result; // Handle direct feature response (newer format) if (data.geometry && data.properties) { result = data; } else if (data.features && data.features.length > 0) { // v5 API response format (legacy) result = data.features[0]; } else if (data.data && data.data.length > 0) { // v6 API response format (structured) result = data.data[0]; } if (!result) { logger.info(`Mapbox returned no results for address: ${address}`); return null; } // Extract coordinates - try multiple possible locations let latitude, longitude; if (result.properties?.coordinates?.latitude && result.properties?.coordinates?.longitude) { // New format: properties.coordinates object latitude = result.properties.coordinates.latitude; longitude = result.properties.coordinates.longitude; } else if (result.geometry?.coordinates && Array.isArray(result.geometry.coordinates) && result.geometry.coordinates.length >= 2) { // GeoJSON format: geometry.coordinates [lng, lat] longitude = result.geometry.coordinates[0]; latitude = result.geometry.coordinates[1]; } else if (result.center && Array.isArray(result.center) && result.center.length >= 2) { // v5 format: center [lng, lat] longitude = result.center[0]; latitude = result.center[1]; } else { logger.error(`Mapbox result missing valid coordinates:`, { hasPropsCoords: !!(result.properties?.coordinates), hasGeomCoords: !!(result.geometry?.coordinates), hasCenter: !!result.center, result: result }); return null; } // Extract formatted address let formattedAddress = result.properties?.full_address || result.properties?.name_preferred || result.properties?.name || result.place_name || 'Unknown Address'; // Calculate confidence from match_code if available let confidence = 100; if (result.properties?.match_code) { const matchCode = result.properties.match_code; if (matchCode.confidence === 'exact') confidence = 100; else if (matchCode.confidence === 'high') confidence = 90; else if (matchCode.confidence === 'medium') confidence = 70; else if (matchCode.confidence === 'low') confidence = 50; else confidence = (result.relevance || 1) * 100; } else { confidence = (result.relevance || 1) * 100; } return { latitude: latitude, longitude: longitude, formattedAddress: formattedAddress, provider: 'Mapbox', confidence: confidence, components: extractMapboxComponents(result), raw: result }; // No results found logger.info(`Mapbox returned no results for address: ${address}`); return null; } catch (error) { logger.error('Mapbox geocoding error:', error.message); throw error; } } async function geocodeWithNominatim(address, options = {}) { const { timeout = 5000, delay = 1000 } = options; // Rate limiting for Nominatim (1 request per second) if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1&addressdetails=1`; logger.info(`Geocoding with Nominatim: ${address}`); try { const response = await axios.get(url, { timeout, headers: { 'User-Agent': 'MapApp/1.0' } }); const data = response.data; if (!data || data.length === 0) { return null; } const result = data[0]; return { latitude: parseFloat(result.lat), longitude: parseFloat(result.lon), formattedAddress: result.display_name, provider: 'Nominatim', confidence: calculateNominatimConfidence(result), components: extractAddressComponents(result.address || {}), raw: result }; } catch (error) { logger.error('Nominatim geocoding error:', error.message); throw error; } } /** * Geocode with Photon (OpenStreetMap-based) */ async function geocodeWithPhoton(address, options = {}) { const { timeout = 15000, delay = 500 } = options; // Rate limiting if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } const response = await axios.get('https://photon.komoot.io/api/', { params: { q: address, limit: 1, lang: 'en' }, timeout }); if (!response.data?.features || response.data.features.length === 0) { return null; } const feature = response.data.features[0]; const coords = feature.geometry.coordinates; const props = feature.properties; return { provider: 'photon', latitude: coords[1], longitude: coords[0], formattedAddress: buildFormattedAddressFromPhoton(props), components: extractPhotonComponents(props), confidence: calculatePhotonConfidence(feature), raw: feature }; } /** * Geocode with LocationIQ (fallback) */ async function geocodeWithLocationIQ(address, options = {}) { const { timeout = 15000, delay = 0 } = options; const apiKey = process.env.LOCATIONIQ_API_KEY; // Rate limiting if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } // LocationIQ can work without API key but has rate limits const params = { format: 'json', q: address, limit: 1, addressdetails: 1, countrycodes: 'ca' }; // Add API key if available for better rate limits if (apiKey) { params.key = apiKey; } const response = await axios.get('https://us1.locationiq.com/v1/search.php', { params, headers: { 'User-Agent': 'NocoDB Map Viewer 1.0' }, timeout }); if (!response.data || response.data.length === 0) { return null; } const data = response.data[0]; return { provider: 'locationiq', latitude: parseFloat(data.lat), longitude: parseFloat(data.lon), formattedAddress: buildFormattedAddress(data.address), components: extractAddressComponents(data.address), confidence: calculateNominatimConfidence(data), // Similar format to Nominatim boundingBox: data.boundingbox, raw: data }; } /** * Geocode with ArcGIS World Geocoding Service */ async function geocodeWithArcGIS(address, options = {}) { const { timeout = 15000, delay = 500 } = options; // Rate limiting if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } const response = await axios.get('https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates', { params: { SingleLine: address, f: 'json', outFields: '*', maxLocations: 1, countryCode: 'CA' }, timeout }); if (!response.data?.candidates || response.data.candidates.length === 0) { return null; } const candidate = response.data.candidates[0]; const location = candidate.location; const attributes = candidate.attributes; return { provider: 'arcgis', latitude: location.y, longitude: location.x, formattedAddress: attributes.LongLabel || candidate.address, components: extractArcGISComponents(attributes), confidence: candidate.score || 50, // ArcGIS provides score 0-100 raw: candidate }; } /** * Helper functions for address processing */ function buildFormattedAddress(addressComponents) { if (!addressComponents) return ''; let formattedAddress = ''; if (addressComponents.house_number) formattedAddress += addressComponents.house_number + ' '; if (addressComponents.road) formattedAddress += addressComponents.road + ', '; if (addressComponents.suburb) formattedAddress += addressComponents.suburb + ', '; if (addressComponents.city) formattedAddress += addressComponents.city + ', '; if (addressComponents.state) formattedAddress += addressComponents.state + ' '; if (addressComponents.postcode) formattedAddress += addressComponents.postcode; return formattedAddress.trim().replace(/,$/, ''); } function buildFormattedAddressFromPhoton(props) { let address = ''; if (props.housenumber) address += props.housenumber + ' '; if (props.street) address += props.street + ', '; if (props.district) address += props.district + ', '; if (props.city) address += props.city + ', '; if (props.state) address += props.state + ' '; if (props.postcode) address += props.postcode; return address.trim().replace(/,$/, ''); } function extractAddressComponents(address) { // If address is a string, parse it if (typeof address === 'string') { return parseAddressString(address); } // If address is already an object (from Nominatim/LocationIQ response) return { house_number: address?.house_number || '', road: address?.road || '', suburb: address?.suburb || address?.neighbourhood || '', city: address?.city || address?.town || address?.village || '', state: address?.state || address?.province || '', postcode: address?.postcode || '', country: address?.country || '' }; } function parseAddressString(addressStr) { // Parse a string address into components for Mapbox structured input const components = { address_number: '', street: '', place: '', region: '', postcode: '', country: 'ca', hasComponents: false }; if (!addressStr || typeof addressStr !== 'string') { return components; } // Clean up the address string const cleanAddress = addressStr.trim(); // Basic regex patterns for Canadian addresses const patterns = { // Match house number at the start houseNumber: /^(\d+[A-Za-z]?)\s+(.+)/, // Match postal code (Canadian format: A1A 1A1) postalCode: /([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)$/i, // Match province abbreviations province: /,?\s*(AB|BC|MB|NB|NL|NT|NS|NU|ON|PE|QC|SK|YT|Alberta|British Columbia|Manitoba|New Brunswick|Newfoundland|Northwest Territories|Nova Scotia|Nunavut|Ontario|Prince Edward Island|Quebec|Saskatchewan|Yukon)\s*,?\s*CA(?:NADA)?$/i }; let workingAddress = cleanAddress; // Extract postal code const postalMatch = workingAddress.match(patterns.postalCode); if (postalMatch) { components.postcode = postalMatch[1].replace(/\s/g, ''); workingAddress = workingAddress.replace(patterns.postalCode, '').trim(); } // Extract province/region const provinceMatch = workingAddress.match(patterns.province); if (provinceMatch) { components.region = provinceMatch[1]; workingAddress = workingAddress.replace(patterns.province, '').trim(); } // Extract house number and street const houseMatch = workingAddress.match(patterns.houseNumber); if (houseMatch) { components.address_number = houseMatch[1]; workingAddress = houseMatch[2]; components.hasComponents = true; } // The remaining part is likely the street and city const parts = workingAddress.split(',').map(p => p.trim()).filter(p => p); if (parts.length > 0) { components.street = parts[0]; components.hasComponents = true; } if (parts.length > 1) { components.place = parts[1]; } return components; } function extractPhotonComponents(props) { return { house_number: props.housenumber || '', road: props.street || '', suburb: props.district || '', city: props.city || '', state: props.state || '', postcode: props.postcode || '', country: props.country || '' }; } function extractArcGISComponents(attributes) { return { house_number: attributes.AddNum || '', road: attributes.StName || '', suburb: attributes.District || '', city: attributes.City || '', state: attributes.Region || '', postcode: attributes.Postal || '', country: attributes.Country || '' }; } function extractMapboxComponents(result) { // Universal Mapbox component extractor for multiple API formats const components = { house_number: '', road: '', suburb: '', city: '', state: '', postcode: '', country: '' }; // New format: properties.context object if (result.properties?.context) { const ctx = result.properties.context; components.house_number = ctx.address?.address_number || ''; components.road = ctx.address?.street_name || ctx.street?.name || ''; components.suburb = ctx.neighborhood?.name || ''; components.city = ctx.place?.name || ''; components.state = ctx.region?.name || ''; components.postcode = ctx.postcode?.name || ''; components.country = ctx.country?.name || ''; return components; } // Legacy v6 format: properties object if (result.properties) { const props = result.properties; components.house_number = props.address_number || ''; components.road = props.street || ''; components.suburb = props.neighborhood || ''; components.city = props.place || ''; components.state = props.region || ''; components.postcode = props.postcode || ''; components.country = props.country || ''; return components; } // Legacy v5 format: context array if (result.context && Array.isArray(result.context)) { result.context.forEach(item => { const id = item.id || ''; if (id.startsWith('postcode.')) components.postcode = item.text; else if (id.startsWith('place.')) components.city = item.text; else if (id.startsWith('region.')) components.state = item.text; else if (id.startsWith('country.')) components.country = item.text; else if (id.startsWith('neighborhood.')) components.suburb = item.text; }); // Extract house number from place name (first part) if (result.place_name) { const match = result.place_name.match(/^(\d+[A-Za-z]?)\s+/); if (match) components.house_number = match[1]; // Extract road from place name or address if (components.house_number) { const addressPart = result.place_name.replace(new RegExp(`^${components.house_number}\\s+`), '').split(',')[0]; components.road = addressPart.trim(); } } } return components; } function calculateNominatimConfidence(data) { // Basic confidence calculation for Nominatim results let confidence = 100; if (!data.address?.house_number) confidence -= 20; if (!data.address?.road) confidence -= 30; if (data.type === 'administrative') confidence -= 25; if (data.class === 'place' && data.type === 'suburb') confidence -= 20; return Math.max(confidence, 10); } function calculatePhotonConfidence(feature) { // Basic confidence for Photon results let confidence = 100; const props = feature.properties; if (!props.housenumber) confidence -= 20; if (!props.street) confidence -= 30; if (props.osm_type === 'relation') confidence -= 15; return Math.max(confidence, 10); } async function reverseGeocode(lat, lng) { // Create cache key - use full precision const cacheKey = `${lat},${lng}`; // Check cache first const cached = geocodeCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { logger.debug(`Geocoding cache hit for ${cacheKey}`); return cached.data; } try { // Add delay to respect Nominatim rate limits (max 1 request per second) await new Promise(resolve => setTimeout(resolve, 1000)); logger.info(`Reverse geocoding: ${lat}, ${lng}`); const response = await axios.get('https://nominatim.openstreetmap.org/reverse', { params: { format: 'json', lat: lat, lon: lng, zoom: 18, addressdetails: 1, 'accept-language': 'en' }, headers: { 'User-Agent': 'NocoDB Map Viewer 1.0 (https://github.com/yourusername/nocodb-map-viewer)' }, timeout: 10000 }); if (response.data.error) { throw new Error(response.data.error); } // Process the response const result = processGeocodeResponse(response.data); // Cache the result geocodeCache.set(cacheKey, { data: result, timestamp: Date.now() }); return result; } catch (error) { logger.error('Reverse geocoding error:', error.message); if (error.response?.status === 429) { throw new Error('Rate limit exceeded. Please try again later.'); } else if (error.code === 'ECONNABORTED') { throw new Error('Geocoding service timeout'); } else { throw new Error('Geocoding service unavailable'); } } } /** * Forward geocode address to get coordinates (for search - returns multiple results) * @param {string} address - Address to search * @param {number} limit - Maximum number of results to return * @returns {Promise} Array of geocoding results */ async function forwardGeocodeSearch(address, limit = 5) { // Create cache key const cacheKey = `search:${address.toLowerCase()}:${limit}`; // Check cache first const cached = geocodeCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { logger.debug(`Geocoding search cache hit for ${cacheKey}`); return cached.data; } try { // Add delay to respect rate limits await new Promise(resolve => setTimeout(resolve, 1000)); logger.info(`Forward geocoding search: ${address}`); const response = await axios.get('https://nominatim.openstreetmap.org/search', { params: { format: 'json', q: address, limit: limit, addressdetails: 1, 'accept-language': 'en', countrycodes: 'ca' // Limit to Canada for this application }, headers: { 'User-Agent': 'NocoDB Map Viewer 1.0 (contact@example.com)' }, timeout: 15000 }); if (!response.data || response.data.length === 0) { return []; } // Process all results const results = response.data.map(item => processGeocodeResponse(item)); // Cache the results geocodeCache.set(cacheKey, { data: results, timestamp: Date.now() }); return results; } catch (error) { logger.error('Forward geocoding search error:', error.message); if (error.response?.status === 429) { throw new Error('Rate limit exceeded. Please try again later.'); } else if (error.response?.status === 403) { throw new Error('Access denied by geocoding service'); } else if (error.response?.status === 500) { throw new Error('Geocoding service internal error'); } else if (error.code === 'ECONNABORTED') { throw new Error('Geocoding request timeout'); } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { throw new Error('Cannot connect to geocoding service'); } else { throw new Error(`Geocoding search failed: ${error.message}`); } } } /** * Validate geocoding result and calculate confidence score * @param {string} originalAddress - The original address searched * @param {Object} geocodeResult - The geocoding result * @returns {Object} Validation result with confidence score */ function validateGeocodeResult(originalAddress, geocodeResult) { const validation = { isValid: true, confidence: 100, warnings: [], isMalformed: false }; if (!geocodeResult || !geocodeResult.formattedAddress) { validation.isValid = false; validation.confidence = 0; return validation; } // Extract key components from original address const originalLower = originalAddress.toLowerCase(); const resultLower = geocodeResult.formattedAddress.toLowerCase(); // Check for street number presence const streetNumberMatch = originalAddress.match(/^\d+/); const resultStreetNumber = geocodeResult.components.house_number; if (streetNumberMatch && !resultStreetNumber) { validation.warnings.push('Street number not found in result'); validation.confidence -= 30; validation.isMalformed = true; } else if (streetNumberMatch && resultStreetNumber) { if (streetNumberMatch[0] !== resultStreetNumber) { validation.warnings.push('Street number mismatch'); validation.confidence -= 40; validation.isMalformed = true; } } // Check for street name presence const streetNameWords = originalLower .replace(/^\d+\s*/, '') // Remove leading number .replace(/,.*$/, '') // Remove everything after first comma .trim() .split(/\s+/) .filter(word => !['nw', 'ne', 'sw', 'se', 'street', 'st', 'avenue', 'ave', 'road', 'rd', 'crescent', 'close'].includes(word)); let matchedWords = 0; streetNameWords.forEach(word => { if (resultLower.includes(word)) { matchedWords++; } }); const matchPercentage = streetNameWords.length > 0 ? (matchedWords / streetNameWords.length) * 100 : 0; if (matchPercentage < 50) { validation.warnings.push('Poor street name match'); validation.confidence -= 30; validation.isMalformed = true; } else if (matchPercentage < 75) { validation.warnings.push('Partial street name match'); validation.confidence -= 15; } // Check for generic/fallback results (often indicates geocoding failure) const genericIndicators = ['castle downs', 'clover bar', 'downtown', 'city centre']; const hasGenericResult = genericIndicators.some(indicator => resultLower.includes(indicator) && !originalLower.includes(indicator) ); if (hasGenericResult) { validation.warnings.push('Result appears to be generic area, not specific address'); validation.confidence -= 25; validation.isMalformed = true; } // Final validation validation.isValid = validation.confidence >= 50; return validation; } /** * Multi-provider forward geocode with fallback support * @param {string} address - Address to geocode * @returns {Promise} Geocoding result with provider info */ async function forwardGeocode(address) { // Input validation if (!address || typeof address !== 'string') { logger.warn(`Invalid address provided for geocoding: ${address}`); throw new Error('Invalid address: address must be a non-empty string'); } address = address.trim(); if (address.length === 0) { throw new Error('Invalid address: address cannot be empty'); } // Create cache key const cacheKey = `addr:${address.toLowerCase()}`; // Check cache first const cached = geocodeCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { logger.debug(`Geocoding cache hit for ${cacheKey}`); return cached.data; } // Try different address format variations const addressVariations = [ address, // Original address.replace(/\s+NW\s*$/i, ' Northwest'), // Expand NW address.replace(/\s+Northwest\s*$/i, ' NW'), // Contract Northwest address.replace(/,\s*CA\s*$/i, ', Canada'), // Expand CA to Canada address.replace(/\s+Street\s+/i, ' St '), // Abbreviate Street address.replace(/\s+St\s+/i, ' Street '), // Expand St address.replace(/\s+Avenue\s+/i, ' Ave '), // Abbreviate Avenue address.replace(/\s+Ave\s+/i, ' Avenue '), // Expand Ave ]; // Provider functions mapping const providerFunctions = { 'Mapbox': geocodeWithMapbox, 'Nominatim': geocodeWithNominatim, 'Photon': geocodeWithPhoton, 'LocationIQ': geocodeWithLocationIQ, 'ArcGIS': geocodeWithArcGIS }; let bestResult = null; let bestConfidence = 0; const allErrors = []; // Try each provider with each address variation for (const provider of GEOCODING_PROVIDERS) { logger.info(`Trying provider: ${provider.name}`); for (let varIndex = 0; varIndex < addressVariations.length; varIndex++) { const addressVariation = addressVariations[varIndex]; try { logger.info(`${provider.name} - attempt ${varIndex + 1}/${addressVariations.length}: ${addressVariation}`); const providerResult = await providerFunctions[provider.name](addressVariation, provider.options); if (!providerResult) { continue; // No results from this provider/variation } // Convert to standard format const result = { fullAddress: providerResult.formattedAddress, formattedAddress: providerResult.formattedAddress, components: providerResult.components, coordinates: { lat: providerResult.latitude, lng: providerResult.longitude }, latitude: providerResult.latitude, longitude: providerResult.longitude, provider: providerResult.provider, providerConfidence: providerResult.confidence, addressVariation: addressVariation, variationIndex: varIndex, boundingBox: providerResult.boundingBox || null, raw: providerResult.raw }; // Validate the result const validation = validateGeocodeResult(address, result); result.validation = validation; // Calculate combined confidence (provider confidence + validation confidence) const combinedConfidence = Math.round((providerResult.confidence + validation.confidence) / 2); result.combinedConfidence = combinedConfidence; logger.info(`${provider.name} result - Provider: ${providerResult.confidence}%, Validation: ${validation.confidence}%, Combined: ${combinedConfidence}%`); // If this is a very high confidence result, use it immediately if (combinedConfidence >= 90 && validation.confidence >= 80) { logger.info(`High confidence result found with ${provider.name}, using immediately`); // Cache and return the result geocodeCache.set(cacheKey, { data: result, timestamp: Date.now() }); return result; } // Keep track of the best result so far if (combinedConfidence > bestConfidence) { bestResult = result; bestConfidence = combinedConfidence; } // If we have a decent result from the first variation, don't try more variations for this provider if (varIndex === 0 && combinedConfidence >= 70) { logger.info(`Good result from ${provider.name} with original address, skipping variations`); break; } } catch (error) { logger.error(`${provider.name} error with "${addressVariation}": ${error.message}`); allErrors.push(`${provider.name}: ${error.message}`); // If rate limited, wait extra time if (error.response?.status === 429) { await new Promise(resolve => setTimeout(resolve, 5000)); } continue; // Try next variation or provider } } // If we found a good result with this provider, we can stop trying other providers if (bestConfidence >= 75) { logger.info(`Acceptable result found with ${provider.name} (${bestConfidence}%), stopping provider search`); break; } } // If we have any result, return the best one if (bestResult) { logger.info(`Returning best result from ${bestResult.provider} with ${bestConfidence}% confidence`); // Cache the result geocodeCache.set(cacheKey, { data: bestResult, timestamp: Date.now() }); return bestResult; } // All providers failed const errorMessage = `All geocoding providers failed: ${allErrors.join('; ')}`; logger.error(errorMessage); throw new Error('Geocoding failed: No providers could locate this address'); } /** * Process geocoding response into standardized format * @param {Object} data - Raw geocoding response * @returns {Object} Processed geocoding data */ function processGeocodeResponse(data) { // Extract address components const addressComponents = { house_number: data.address?.house_number || '', road: data.address?.road || '', suburb: data.address?.suburb || data.address?.neighbourhood || '', city: data.address?.city || data.address?.town || data.address?.village || '', state: data.address?.state || data.address?.province || '', postcode: data.address?.postcode || '', country: data.address?.country || '' }; // Create formatted address string let formattedAddress = ''; if (addressComponents.house_number) formattedAddress += addressComponents.house_number + ' '; if (addressComponents.road) formattedAddress += addressComponents.road + ', '; if (addressComponents.suburb) formattedAddress += addressComponents.suburb + ', '; if (addressComponents.city) formattedAddress += addressComponents.city + ', '; if (addressComponents.state) formattedAddress += addressComponents.state + ' '; if (addressComponents.postcode) formattedAddress += addressComponents.postcode; // Clean up formatting formattedAddress = formattedAddress.trim().replace(/,$/, ''); return { fullAddress: data.display_name || '', formattedAddress: formattedAddress, components: addressComponents, coordinates: { lat: parseFloat(data.lat), lng: parseFloat(data.lon) }, // Backward compatibility latitude: parseFloat(data.lat), longitude: parseFloat(data.lon), boundingBox: data.boundingbox || null, placeId: data.place_id || null, osmType: data.osm_type || null, osmId: data.osm_id || null }; } /** * Get cache statistics * @returns {Object} Cache statistics */ function getCacheStats() { return { size: geocodeCache.size, maxSize: 1000, // Could be made configurable ttl: CACHE_TTL }; } /** * Clear the geocoding cache */ function clearCache() { geocodeCache.clear(); logger.info('Geocoding cache cleared'); } module.exports = { reverseGeocode, forwardGeocode, forwardGeocodeSearch, getCacheStats, clearCache };