1064 lines
37 KiB
JavaScript

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>} 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<Object>} 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
};