UNPKG

cilok.js

Version:

AI-powered location toolkit CLI agent with intelligent search and free map alternatives

437 lines (380 loc) 16 kB
const axios = require('axios'); const chalk = require('chalk'); class LocationService { constructor() { this.googleMapsKey = process.env.GOOGLE_MAPS_API_KEY; this.mapboxKey = process.env.MAPBOX_API_KEY; this.useFreeMaps = !this.googleMapsKey && !this.mapboxKey; // Nominatim (OpenStreetMap) settings this.nominatimBaseUrl = 'https://nominatim.openstreetmap.org'; this.overpassBaseUrl = 'https://overpass-api.de/api/interpreter'; } async initialize() { if (this.useFreeMaps) { console.log('🗺️ Using free OpenStreetMap services'); } } async searchLocation(locationName) { if (this.googleMapsKey) { return await this.searchLocationGoogle(locationName); } else { return await this.searchLocationNominatim(locationName); } } async searchLocationGoogle(locationName) { try { const response = await axios.get('https://maps.googleapis.com/maps/api/place/textsearch/json', { params: { query: locationName, key: this.googleMapsKey, language: 'id', region: 'id' } }); if (response.data.results.length === 0) { throw new Error('Location not found'); } const place = response.data.results[0]; const details = await this.getPlaceDetails(place.place_id); const nearby = await this.getNearbyPlaces( place.geometry.location.lat, place.geometry.location.lng ); return { name: place.name, formatted_address: place.formatted_address, coordinates: { lat: place.geometry.location.lat, lng: place.geometry.location.lng }, types: place.types, rating: place.rating, details: details, nearby: nearby.slice(0, 5) }; } catch (error) { throw new Error(`Location search failed: ${error.message}`); } } async searchLocationNominatim(locationName) { try { console.log(chalk.gray(`[DEBUG] Searching: "${locationName}" via Nominatim`)); const response = await axios.get(`${this.nominatimBaseUrl}/search`, { params: { q: locationName, format: 'json', addressdetails: 1, limit: 5, // Increase limit for better results countrycodes: 'id', 'accept-language': 'id' }, headers: { 'User-Agent': 'Cilok Location Toolkit (https://github.com/Cloud-Dark/cilok)' }, timeout: 10000 // 10 second timeout }); console.log(chalk.gray(`[DEBUG] Nominatim returned ${response.data.length} results`)); if (response.data.length === 0) { // Try searching without country restriction console.log(chalk.yellow('[DEBUG] Retrying without country restriction...')); const globalSearch = await axios.get(`${this.nominatimBaseUrl}/search`, { params: { q: locationName, format: 'json', addressdetails: 1, limit: 5, 'accept-language': 'id' }, headers: { 'User-Agent': 'Cilok Location Toolkit (https://github.com/Cloud-Dark/cilok)' }, timeout: 10000 }); if (globalSearch.data.length === 0) { throw new Error(`Location "${locationName}" not found`); } response.data = globalSearch.data; } const place = response.data[0]; const lat = parseFloat(place.lat); const lng = parseFloat(place.lon); console.log(chalk.gray(`[DEBUG] Found: ${place.display_name} at ${lat}, ${lng}`)); // Get nearby places using Overpass API let nearby = []; try { nearby = await this.getNearbyPlacesOverpass(lat, lng); console.log(chalk.gray(`[DEBUG] Found ${nearby.length} nearby places`)); } catch (nearbyError) { console.log(chalk.yellow(`[DEBUG] Nearby search failed: ${nearbyError.message}`)); } return { name: place.display_name.split(',')[0], formatted_address: place.display_name, coordinates: { lat, lng }, types: [place.type, place.class].filter(Boolean), osm_id: place.osm_id, osm_type: place.osm_type, importance: place.importance, nearby: nearby.slice(0, 5) }; } catch (error) { console.error(chalk.red('[DEBUG] Nominatim search error:'), error.message); throw new Error(`Location search failed: ${error.message}`); } } async getCoordinates(address) { if (this.googleMapsKey) { return await this.getCoordinatesGoogle(address); } else { return await this.getCoordinatesNominatim(address); } } async getCoordinatesGoogle(address) { try { const response = await axios.get('https://maps.googleapis.com/maps/api/geocode/json', { params: { address: address, key: this.googleMapsKey, language: 'id', region: 'id' } }); if (response.data.results.length === 0) { throw new Error('Address not found'); } const result = response.data.results[0]; return { lat: result.geometry.location.lat, lng: result.geometry.location.lng, formatted_address: result.formatted_address, accuracy: result.geometry.location_type }; } catch (error) { throw new Error(`Geocoding failed: ${error.message}`); } } async getCoordinatesNominatim(address) { try { const response = await axios.get(`${this.nominatimBaseUrl}/search`, { params: { q: address, format: 'json', addressdetails: 1, limit: 1, countrycodes: 'id' }, headers: { 'User-Agent': 'Cilok Location Toolkit (https://github.com/Cloud-Dark/cilok)' } }); if (response.data.length === 0) { throw new Error('Address not found'); } const result = response.data[0]; return { lat: parseFloat(result.lat), lng: parseFloat(result.lon), formatted_address: result.display_name, accuracy: result.importance }; } catch (error) { throw new Error(`Geocoding failed: ${error.message}`); } } async reverseGeocode(lat, lng) { if (this.googleMapsKey) { return await this.reverseGeocodeGoogle(lat, lng); } else { return await this.reverseGeocodeNominatim(lat, lng); } } async reverseGeocodeGoogle(lat, lng) { try { const response = await axios.get('https://maps.googleapis.com/maps/api/geocode/json', { params: { latlng: `${lat},${lng}`, key: this.googleMapsKey, language: 'id' } }); if (response.data.results.length === 0) { throw new Error('No location found for these coordinates'); } const result = response.data.results[0]; const nearby = await this.getNearbyPlaces(lat, lng); return { name: result.address_components[0]?.long_name || 'Unknown Location', formatted_address: result.formatted_address, coordinates: { lat, lng }, types: result.types, nearby: nearby.slice(0, 5) }; } catch (error) { throw new Error(`Reverse geocoding failed: ${error.message}`); } } async reverseGeocodeNominatim(lat, lng) { try { const response = await axios.get(`${this.nominatimBaseUrl}/reverse`, { params: { lat: lat, lon: lng, format: 'json', addressdetails: 1, zoom: 18 }, headers: { 'User-Agent': 'Cilok Location Toolkit (https://github.com/Cloud-Dark/cilok)' } }); if (!response.data || response.data.error) { throw new Error('No location found for these coordinates'); } const result = response.data; const nearby = await this.getNearbyPlacesOverpass(lat, lng); return { name: result.name || result.display_name.split(',')[0], formatted_address: result.display_name, coordinates: { lat, lng }, types: [result.type, result.class].filter(Boolean), nearby: nearby.slice(0, 5) }; } catch (error) { throw new Error(`Reverse geocoding failed: ${error.message}`); } } async searchNearby(location, type = 'restaurant') { // First get coordinates of the location const coords = await this.getCoordinates(location); if (this.googleMapsKey) { return await this.getNearbyPlaces(coords.lat, coords.lng, 1000, type); } else { return await this.getNearbyPlacesOverpass(coords.lat, coords.lng, type); } } async getNearbyPlaces(lat, lng, radius = 500, type = null) { try { const params = { location: `${lat},${lng}`, radius: radius, key: this.googleMapsKey, language: 'id' }; if (type) { params.type = type; } const response = await axios.get('https://maps.googleapis.com/maps/api/place/nearbysearch/json', { params }); return response.data.results.map(place => ({ name: place.name, types: place.types, rating: place.rating, address: place.vicinity, distance: this.calculateDistance(lat, lng, place.geometry.location.lat, place.geometry.location.lng) })); } catch (error) { return []; } } async getNearbyPlacesOverpass(lat, lng, type = null) { try { // Convert type to OSM amenity const osmType = this.convertTypeToOSM(type); const query = ` [out:json][timeout:25]; ( node["amenity"~"${osmType}"](around:1000,${lat},${lng}); way["amenity"~"${osmType}"](around:1000,${lat},${lng}); relation["amenity"~"${osmType}"](around:1000,${lat},${lng}); ); out center meta; `; const response = await axios.post(this.overpassBaseUrl, query, { headers: { 'Content-Type': 'text/plain', 'User-Agent': 'Cilok Location Toolkit (https://github.com/Cloud-Dark/cilok)' } }); const elements = response.data.elements || []; return elements .filter(element => element.tags && element.tags.name) .map(element => { const elementLat = element.lat || (element.center && element.center.lat); const elementLng = element.lon || (element.center && element.center.lon); return { name: element.tags.name, types: [element.tags.amenity, element.tags.cuisine].filter(Boolean), address: this.buildAddressFromTags(element.tags), distance: elementLat && elementLng ? this.calculateDistance(lat, lng, elementLat, elementLng) : null, osm_id: element.id, osm_type: element.type }; }) .filter(place => place.distance) .sort((a, b) => { const distA = parseFloat(a.distance.replace('m', '').replace('km', '000')); const distB = parseFloat(b.distance.replace('m', '').replace('km', '000')); return distA - distB; }); } catch (error) { console.error('Overpass API error:', error.message); return []; } } convertTypeToOSM(type) { const typeMapping = { 'restaurant': 'restaurant|cafe|fast_food|food_court', 'food': 'restaurant|cafe|fast_food|food_court', 'hospital': 'hospital|clinic|pharmacy', 'school': 'school|university|college', 'bank': 'bank|atm', 'gas_station': 'fuel', 'shopping': 'marketplace|mall|shop' }; return typeMapping[type] || 'restaurant|cafe|fast_food|bank|hospital|school|fuel'; } buildAddressFromTags(tags) { const parts = []; if (tags['addr:street']) parts.push(tags['addr:street']); if (tags['addr:city']) parts.push(tags['addr:city']); if (tags['addr:state']) parts.push(tags['addr:state']); return parts.length > 0 ? parts.join(', ') : ''; } async getPlaceDetails(placeId) { try { const response = await axios.get('https://maps.googleapis.com/maps/api/place/details/json', { params: { place_id: placeId, key: this.googleMapsKey, fields: 'name,formatted_address,formatted_phone_number,website,opening_hours,rating,reviews', language: 'id' } }); return response.data.result; } catch (error) { return null; } } calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371; // Radius of the Earth in kilometers const dLat = this.deg2rad(lat2 - lat1); const dLon = this.deg2rad(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.deg2rad(lat1)) * Math.cos(this.deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const d = R * c; // Distance in kilometers if (d < 1) { return `${Math.round(d * 1000)}m`; } else { return `${d.toFixed(1)}km`; } } deg2rad(deg) { return deg * (Math.PI / 180); } } module.exports = LocationService;