diff --git a/app/api/geo/batch/route.ts b/app/api/geo/batch/route.ts new file mode 100644 index 0000000..aff9dfa --- /dev/null +++ b/app/api/geo/batch/route.ts @@ -0,0 +1,248 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ApiResponse } from '@/lib/types'; + +interface IpLocationData { + ip: string; + country_name: string; + country_code: string; + city: string; + region: string; + continent_code: string; + continent_name: string; + latitude: number; + longitude: number; + timestamp: number; +} + +// Simple in-memory cache on the server side to reduce API calls +const serverCache: Record = {}; + +// Cache for IPs that have repeatedly failed to resolve +const failedIPsCache: Record = {}; + +// Cache expiration time (30 days in milliseconds) +const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000; // 30 days + +// Max attempts to fetch an IP before considering it permanently failed +const MAX_RETRY_ATTEMPTS = 3; + +// Retry timeout - how long to wait before trying a failed IP again (24 hours) +const RETRY_TIMEOUT = 24 * 60 * 60 * 1000; + +/** + * Check if an IP has failed too many times and should be skipped + */ +function shouldSkipIP(ip: string): boolean { + if (!failedIPsCache[ip]) return false; + + const now = Date.now(); + + // Skip if max attempts reached + if (failedIPsCache[ip].attempts >= MAX_RETRY_ATTEMPTS) { + return true; + } + + // Skip if last attempt was recent + if (now - failedIPsCache[ip].lastAttempt < RETRY_TIMEOUT) { + return true; + } + + return false; +} + +/** + * Mark an IP as failed + */ +function markIPAsFailed(ip: string): void { + const now = Date.now(); + + if (failedIPsCache[ip]) { + failedIPsCache[ip] = { + attempts: failedIPsCache[ip].attempts + 1, + lastAttempt: now + }; + } else { + failedIPsCache[ip] = { + attempts: 1, + lastAttempt: now + }; + } +} + +/** + * Get location data for a single IP using ipapi.co + */ +async function fetchIpLocation(ip: string): Promise { + try { + // Skip this IP if it has failed too many times + if (shouldSkipIP(ip)) { + console.log(`[Server] Skipping blacklisted IP: ${ip}`); + return null; + } + + // Check server cache first + const now = Date.now(); + if (serverCache[ip] && (now - serverCache[ip].timestamp) < CACHE_EXPIRATION) { + return serverCache[ip]; + } + + // Add delay to avoid rate limiting (100 requests per minute max) + await new Promise(resolve => setTimeout(resolve, 600)); // ~100 req/min = 1 req per 600ms + + const response = await fetch(`https://ipapi.co/${ip}/json/`); + + if (!response.ok) { + console.error(`Error fetching location for IP ${ip}: ${response.statusText}`); + markIPAsFailed(ip); + return null; + } + + const data = await response.json(); + + if (data.error) { + console.error(`Error fetching location for IP ${ip}: ${data.reason}`); + markIPAsFailed(ip); + return null; + } + + // Reset failed status if successful + if (failedIPsCache[ip]) { + delete failedIPsCache[ip]; + } + + const locationData: IpLocationData = { + ip: data.ip, + country_name: data.country_name || 'Unknown', + country_code: data.country_code || 'UN', + city: data.city || 'Unknown', + region: data.region || 'Unknown', + continent_code: data.continent_code || 'UN', + continent_name: getContinentName(data.continent_code) || 'Unknown', + latitude: data.latitude || 0, + longitude: data.longitude || 0, + timestamp: Date.now() + }; + + // Cache the result + serverCache[ip] = locationData; + + return locationData; + } catch (error) { + console.error(`Error fetching location for IP ${ip}:`, error); + markIPAsFailed(ip); + return null; + } +} + +/** + * Get continent name from continent code + */ +function getContinentName(code?: string): string { + if (!code) return 'Unknown'; + + const continents: Record = { + 'AF': 'Africa', + 'AN': 'Antarctica', + 'AS': 'Asia', + 'EU': 'Europe', + 'NA': 'North America', + 'OC': 'Oceania', + 'SA': 'South America' + }; + + return continents[code] || 'Unknown'; +} + +/** + * API route handler for batch IP location lookups + */ +export async function POST(request: NextRequest) { + try { + const { ips } = await request.json(); + + if (!ips || !Array.isArray(ips) || ips.length === 0) { + return NextResponse.json({ + success: false, + error: 'Invalid or empty IP list' + }, { status: 400 }); + } + + // Limit batch size to 50 IPs to prevent abuse + const ipList = ips.slice(0, 50); + const results: Record = {}; + + // Filter out IPs that should be skipped + const validIPs = ipList.filter(ip => { + if (typeof ip !== 'string' || !ip.trim()) return false; + if (isPrivateIP(ip)) { + results[ip] = getPrivateIPData(ip); + return false; + } + if (shouldSkipIP(ip)) { + console.log(`[Server] Skipping blacklisted IP: ${ip}`); + results[ip] = null; + return false; + } + return true; + }); + + // Process remaining IPs sequentially to respect rate limits + for (const ip of validIPs) { + results[ip] = await fetchIpLocation(ip); + } + + const response: ApiResponse> = { + success: true, + data: results + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Batch IP lookup error:', error); + + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + + return NextResponse.json(response, { status: 500 }); + } +} + +/** + * Check if IP is a private/local address + */ +function isPrivateIP(ip: string): boolean { + return ( + ip.startsWith('10.') || + ip.startsWith('192.168.') || + ip.startsWith('172.16.') || + ip.startsWith('172.17.') || + ip.startsWith('172.18.') || + ip.startsWith('172.19.') || + ip.startsWith('172.20.') || + ip.startsWith('172.21.') || + ip.startsWith('172.22.') || + ip.startsWith('127.') || + ip === 'localhost' || + ip === '::1' + ); +} + +/** + * Generate location data for private IP addresses + */ +function getPrivateIPData(ip: string): IpLocationData { + return { + ip, + country_name: 'Local Network', + country_code: 'LO', + city: 'Local', + region: 'Local', + continent_code: 'LO', + continent_name: 'Local', + latitude: 0, + longitude: 0, + timestamp: Date.now() + }; +} \ No newline at end of file diff --git a/app/components/analytics/GeoAnalytics.tsx b/app/components/analytics/GeoAnalytics.tsx index ffa093a..2f0a4da 100644 --- a/app/components/analytics/GeoAnalytics.tsx +++ b/app/components/analytics/GeoAnalytics.tsx @@ -1,14 +1,32 @@ "use client"; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { GeoData } from '@/app/api/types'; +import { getLocationsFromIPs } from '@/app/utils/ipLocation'; interface GeoAnalyticsProps { data: GeoData[]; } +// Interface for IP location data in our cache +interface IpLocationDetail { + country: string; + city: string; + region: string; + continent: string; +} + +// Cache for IP location data +interface LocationCache { + [key: string]: IpLocationDetail; +} + export default function GeoAnalytics({ data }: GeoAnalyticsProps) { const [viewMode, setViewMode] = useState<'country' | 'city' | 'region' | 'continent'>('country'); + const [locationCache, setLocationCache] = useState({}); + const [isLoading, setIsLoading] = useState(false); + // Track IPs that failed to resolve + const [failedIPs, setFailedIPs] = useState>(new Set()); // 安全地格式化数字 const formatNumber = (value: number | undefined | null): string => { @@ -27,7 +45,122 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) { // Handle tab selection - only change local view mode const handleViewModeChange = (mode: 'country' | 'city' | 'region' | 'continent') => { setViewMode(mode); - // Only change the local view mode, no callback to parent + }; + + // Load location data for all IPs when the data changes + useEffect(() => { + const fetchLocations = async () => { + if (sortedData.length === 0) return; + + setIsLoading(true); + const tempCache: LocationCache = {...locationCache}; + const tempFailedIPs = new Set(failedIPs); + + // Get all unique IPs that aren't already in the cache and haven't failed + const uniqueIPs = [...new Set(sortedData.map(item => item.location))].filter(ip => + ip && + ip !== 'Unknown' && + !tempCache[ip] && + !tempFailedIPs.has(ip) + ); + + if (uniqueIPs.length === 0) { + setIsLoading(false); + return; + } + + try { + // Use batch lookup for better performance + const batchResults = await getLocationsFromIPs(uniqueIPs); + + // Convert results to our cache format + for (const [ip, data] of Object.entries(batchResults)) { + if (data) { + tempCache[ip] = { + country: data.country_name, + city: data.city, + region: data.region, + continent: data.continent_name + }; + } else { + // Mark as failed + tempFailedIPs.add(ip); + } + } + + setLocationCache(tempCache); + setFailedIPs(tempFailedIPs); + } catch (error) { + console.error('Error fetching location data:', error); + } finally { + setIsLoading(false); + } + }; + + fetchLocations(); + }, [data]); + + // Get the appropriate location value based on the current view mode + const getLocationValue = (item: GeoData): string => { + const ip = item.location || ''; + + // If there's no IP or it's "Unknown", return that value + if (!ip || ip === 'Unknown') return 'Unknown'; + + // If IP failed to resolve, return Unknown + if (failedIPs.has(ip)) { + return 'Unknown'; + } + + // Return from cache if available + if (locationCache[ip]) { + switch (viewMode) { + case 'country': + return locationCache[ip].country || 'Unknown'; + case 'city': + return locationCache[ip].city || 'Unknown'; + case 'region': + return locationCache[ip].region || 'Unknown'; + case 'continent': + return locationCache[ip].continent || 'Unknown'; + default: + return ip; + } + } + + // Return placeholder if not in cache yet + return `Loading...`; + }; + + // Get the appropriate area value based on the current view mode + const getAreaValue = (item: GeoData): string => { + const ip = item.location || ''; + + // If there's no IP or it's "Unknown", return empty string + if (!ip || ip === 'Unknown' || failedIPs.has(ip)) return ''; + + // Return from cache if available + if (locationCache[ip]) { + switch (viewMode) { + case 'country': + // For country view, show the continent as area + return locationCache[ip].continent || ''; + case 'city': + // For city view, show the country and region + return `${locationCache[ip].country}, ${locationCache[ip].region}`; + case 'region': + // For region view, show the country + return locationCache[ip].country || ''; + case 'continent': + // For continent view, no additional area needed + return ''; + default: + return ''; + } + } + + // Return empty if not in cache yet + return ''; }; return ( @@ -60,6 +193,14 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) { + {/* Loading indicator */} + {isLoading && ( +
+
+ Loading location data... +
+ )} + {/* Table with added area column */}
@@ -71,9 +212,9 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) { viewMode === 'region' ? 'Region' : 'Continent'}
- {viewMode === 'country' ? 'Countries' : - viewMode === 'city' ? 'Cities' : - viewMode === 'region' ? 'Regions' : 'Continents'} + {viewMode === 'country' ? 'Continent' : + viewMode === 'city' ? 'Location' : + viewMode === 'region' ? 'Country' : 'Area'} Visits @@ -91,10 +232,13 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) { sortedData.map((item, index) => (
- {item.location || 'Unknown'} + {getLocationValue(item)} + {item.location && ( +
{item.location}
+ )}
- {item.area || ''} + {getAreaValue(item)} {formatNumber(item.visits)} diff --git a/app/components/ipLocationTest.tsx b/app/components/ipLocationTest.tsx new file mode 100644 index 0000000..f359849 --- /dev/null +++ b/app/components/ipLocationTest.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { getLocationFromIP } from '@/app/utils/ipLocation'; + +interface LocationData { + ip: string; + country_name: string; + country_code: string; + city: string; + region: string; + continent_code: string; + continent_name: string; + latitude: number; + longitude: number; +} + +export default function IpLocationTest() { + const [locationData, setLocationData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const testIp = "120.244.39.90"; + + useEffect(() => { + async function fetchLocation() { + try { + setLoading(true); + setError(null); + + const data = await getLocationFromIP(testIp); + setLocationData(data); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error occurred'); + } finally { + setLoading(false); + } + } + + fetchLocation(); + }, []); + + return ( +
+

IP Location Test: {testIp}

+ + {loading && ( +
+
+ Loading location data... +
+ )} + + {error && ( +
+ Error: {error} +
+ )} + + {!loading && locationData && ( +
+
+

Location Data:

+
+              {JSON.stringify(locationData, null, 2)}
+            
+
+ +
+
+

Country

+
{locationData.country_name} ({locationData.country_code})
+
+ +
+

City

+
{locationData.city || 'N/A'}
+
+ +
+

Region

+
{locationData.region || 'N/A'}
+
+ +
+

Continent

+
{locationData.continent_name} ({locationData.continent_code})
+
+ +
+

Coordinates

+
Latitude: {locationData.latitude}, Longitude: {locationData.longitude}
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/ip-test/page.tsx b/app/ip-test/page.tsx new file mode 100644 index 0000000..abc43d5 --- /dev/null +++ b/app/ip-test/page.tsx @@ -0,0 +1,10 @@ +import IpLocationTest from '../components/ipLocationTest'; + +export default function IpTestPage() { + return ( +
+

IP to Location Test

+ +
+ ); +} \ No newline at end of file diff --git a/app/utils/ipLocation.ts b/app/utils/ipLocation.ts new file mode 100644 index 0000000..7388519 --- /dev/null +++ b/app/utils/ipLocation.ts @@ -0,0 +1,484 @@ +interface IpLocationData { + ip: string; + country_name: string; + country_code: string; + city: string; + region: string; + continent_code: string; + continent_name: string; + latitude: number; + longitude: number; + timestamp?: number; // When this data was fetched +} + +// In-memory cache +let locationCache: Record = {}; + +// Blacklist for IPs that failed to resolve multiple times +let failedIPs: Record = {}; + +// Cache expiration time (30 days in milliseconds) +const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000; + +// Max retries for a failed IP +const MAX_RETRY_ATTEMPTS = 3; + +// Retry timeout (24 hours in milliseconds) +const RETRY_TIMEOUT = 24 * 60 * 60 * 1000; + +// Max number of IPs to batch in a single request +const MAX_BATCH_SIZE = 10; + +/** + * Initialize cache from localStorage + */ +const initializeCache = () => { + if (typeof window === 'undefined') return; + + try { + // Load location cache + const cachedData = localStorage.getItem('ip-location-cache'); + if (cachedData) { + const parsedCache = JSON.parse(cachedData); + + // Filter out expired entries + const now = Date.now(); + const validEntries = Object.entries(parsedCache).filter(([, data]) => { + const entry = data as IpLocationData; + return entry.timestamp && now - entry.timestamp < CACHE_EXPIRATION; + }); + + locationCache = Object.fromEntries(validEntries) as Record; + console.log(`Loaded ${validEntries.length} IP locations from cache`); + } + + // Load failed IPs + const failedIPsData = localStorage.getItem('ip-location-blacklist'); + if (failedIPsData) { + const parsedFailedIPs = JSON.parse(failedIPsData); + + // Filter out expired blacklist entries + const now = Date.now(); + const validFailedEntries = Object.entries(parsedFailedIPs).filter(([, data]) => { + const entry = data as { attempts: number, lastAttempt: number }; + // Keep entries that have max attempts or haven't timed out yet + return entry.attempts >= MAX_RETRY_ATTEMPTS || + now - entry.lastAttempt < RETRY_TIMEOUT; + }); + + failedIPs = Object.fromEntries(validFailedEntries) as Record; + console.log(`Loaded ${validFailedEntries.length} blacklisted IPs`); + } + } catch (error) { + console.error('Failed to load IP location cache:', error); + // Reset cache if corrupted + localStorage.removeItem('ip-location-cache'); + localStorage.removeItem('ip-location-blacklist'); + locationCache = {}; + failedIPs = {}; + } +}; + +/** + * Save cache to localStorage + */ +const saveCache = () => { + if (typeof window === 'undefined') return; + + try { + localStorage.setItem('ip-location-cache', JSON.stringify(locationCache)); + } catch (error) { + console.error('Failed to save IP location cache:', error); + + // If localStorage is full, clear old entries + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + // Clear older entries - keep newest 100 + const entries = Object.entries(locationCache) + .sort((a, b) => { + const timestampA = (a[1].timestamp || 0); + const timestampB = (b[1].timestamp || 0); + return timestampB - timestampA; + }) + .slice(0, 100); + + locationCache = Object.fromEntries(entries); + localStorage.setItem('ip-location-cache', JSON.stringify(locationCache)); + } + } +}; + +/** + * Save failed IPs to localStorage + */ +const saveFailedIPs = () => { + if (typeof window === 'undefined') return; + + try { + localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs)); + } catch (error) { + console.error('Failed to save IP blacklist:', error); + + // If localStorage is full, limit the size + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + // Keep only IPs with max attempts + const entries = Object.entries(failedIPs) + .filter(([, data]) => data.attempts >= MAX_RETRY_ATTEMPTS); + + failedIPs = Object.fromEntries(entries); + localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs)); + } + } +}; + +/** + * Check if IP is a private/local address + */ +const isPrivateIP = (ip: string): boolean => { + return ( + ip.startsWith('10.') || + ip.startsWith('192.168.') || + ip.startsWith('172.16.') || + ip.startsWith('172.17.') || + ip.startsWith('172.18.') || + ip.startsWith('172.19.') || + ip.startsWith('172.20.') || + ip.startsWith('172.21.') || + ip.startsWith('172.22.') || + ip.startsWith('127.') || + ip === 'localhost' || + ip === '::1' + ); +}; + +/** + * Check if an IP should be skipped (blacklisted) + */ +const shouldSkipIP = (ip: string): boolean => { + // If not in failed list, don't skip + if (!failedIPs[ip]) return false; + + const now = Date.now(); + + // If reached max attempts, skip + if (failedIPs[ip].attempts >= MAX_RETRY_ATTEMPTS) { + return true; + } + + // If hasn't been long enough since last attempt, skip + if (now - failedIPs[ip].lastAttempt < RETRY_TIMEOUT) { + return true; + } + + // Otherwise, we can try again + return false; +}; + +/** + * Mark IP as failed + */ +const markIPAsFailed = (ip: string): void => { + const now = Date.now(); + + if (failedIPs[ip]) { + failedIPs[ip] = { + attempts: failedIPs[ip].attempts + 1, + lastAttempt: now + }; + } else { + failedIPs[ip] = { + attempts: 1, + lastAttempt: now + }; + } + + saveFailedIPs(); +}; + +/** + * Get location data for a single IP address + */ +const fetchSingleIP = async (ip: string): Promise => { + // Skip blacklisted IPs + if (shouldSkipIP(ip)) { + console.log(`Skipping blacklisted IP: ${ip}`); + return null; + } + + try { + const response = await fetch(`https://ipapi.co/${ip}/json/`); + + if (!response.ok) { + console.error(`Error fetching location for IP ${ip}: ${response.statusText}`); + markIPAsFailed(ip); + return null; + } + + const data = await response.json(); + + if (data.error) { + console.error(`Error fetching location for IP ${ip}: ${data.reason}`); + markIPAsFailed(ip); + return null; + } + + // Reset failed attempts if successful + if (failedIPs[ip]) { + delete failedIPs[ip]; + saveFailedIPs(); + } + + const locationData: IpLocationData = { + ip: data.ip, + country_name: data.country_name || 'Unknown', + country_code: data.country_code || 'UN', + city: data.city || 'Unknown', + region: data.region || 'Unknown', + continent_code: data.continent_code || 'UN', + continent_name: getContinentName(data.continent_code) || 'Unknown', + latitude: data.latitude || 0, + longitude: data.longitude || 0, + timestamp: Date.now() + }; + + return locationData; + } catch (error) { + console.error(`Error fetching location for IP ${ip}:`, error); + markIPAsFailed(ip); + return null; + } +}; + +/** + * Batch process multiple IPs at once using our own API endpoint + * This is a placeholder - we'll create a server API route for this + */ +const fetchBatchIPs = async (ips: string[]): Promise> => { + try { + // Filter out blacklisted IPs + const validIPs = ips.filter(ip => !shouldSkipIP(ip)); + + if (validIPs.length === 0) { + return {}; + } + + const response = await fetch('/api/geo/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ips: validIPs }), + }); + + if (!response.ok) { + throw new Error(`Batch request failed: ${response.statusText}`); + } + + const results = await response.json(); + + // Mark failed IPs from results + for (const [ip, data] of Object.entries(results.data)) { + if (!data) { + markIPAsFailed(ip); + } else if (failedIPs[ip]) { + // Reset failed attempts if successful + delete failedIPs[ip]; + } + } + + saveFailedIPs(); + return results.data; + } catch (error) { + console.error('Error in batch IP lookup:', error); + + // Fallback to individual requests + const results: Record = {}; + for (const ip of ips) { + // Skip blacklisted IPs + if (shouldSkipIP(ip)) { + results[ip] = null; + continue; + } + + // Add delays between requests to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 200)); + results[ip] = await fetchSingleIP(ip); + } + return results; + } +}; + +/** + * Handle private IP addresses + */ +const getPrivateIPData = (ip: string): IpLocationData => ({ + ip, + country_name: 'Local Network', + country_code: 'LO', + city: 'Local', + region: 'Local', + continent_code: 'LO', + continent_name: 'Local', + latitude: 0, + longitude: 0, + timestamp: Date.now() +}); + +/** + * Convert an IP address to location information + * Individual lookup for a single IP + */ +export async function getLocationFromIP(ip: string): Promise { + // Initialize cache from localStorage if needed + if (Object.keys(locationCache).length === 0) { + initializeCache(); + } + + // Handle private IP addresses + if (isPrivateIP(ip)) { + const privateIPData = getPrivateIPData(ip); + locationCache[ip] = privateIPData; + return privateIPData; + } + + // Skip blacklisted IPs + if (shouldSkipIP(ip)) { + console.log(`Skipping blacklisted IP: ${ip}`); + return null; + } + + // Return from cache if available and not expired + if (locationCache[ip]) { + const cachedData = locationCache[ip]; + const now = Date.now(); + + // Return cached data if not expired + if (cachedData.timestamp && now - cachedData.timestamp < CACHE_EXPIRATION) { + return cachedData; + } + } + + // Fetch new data + const locationData = await fetchSingleIP(ip); + + // Save to cache if successful + if (locationData) { + locationCache[ip] = locationData; + saveCache(); + } + + return locationData; +} + +/** + * Batch lookup for multiple IPs at once + * More efficient than calling getLocationFromIP multiple times + */ +export async function getLocationsFromIPs(ips: string[]): Promise> { + // Initialize cache from localStorage if needed + if (Object.keys(locationCache).length === 0) { + initializeCache(); + } + + // Filter out IPs that are already in cache and not expired + const now = Date.now(); + const cachedResults: Record = {}; + const ipsToFetch: string[] = []; + + for (const ip of ips) { + // Handle private IPs + if (isPrivateIP(ip)) { + cachedResults[ip] = getPrivateIPData(ip); + continue; + } + + // Skip blacklisted IPs + if (shouldSkipIP(ip)) { + console.log(`Skipping blacklisted IP: ${ip}`); + continue; + } + + // Check cache + if (locationCache[ip] && locationCache[ip].timestamp && + now - locationCache[ip].timestamp < CACHE_EXPIRATION) { + cachedResults[ip] = locationCache[ip]; + } else { + ipsToFetch.push(ip); + } + } + + // If all IPs were cached or blacklisted, return immediately + if (ipsToFetch.length === 0) { + return cachedResults; + } + + // Process IPs in batches to avoid overwhelming the API + const results: Record = { ...cachedResults }; + + // Process in smaller batches (e.g., 10 IPs at a time) + for (let i = 0; i < ipsToFetch.length; i += MAX_BATCH_SIZE) { + const batchIPs = ipsToFetch.slice(i, i + MAX_BATCH_SIZE); + + // Batch request + const batchResults = await fetchBatchIPs(batchIPs); + + // Update results and cache + for (const [ip, data] of Object.entries(batchResults)) { + results[ip] = data; + if (data) { + locationCache[ip] = data; + } + } + + // Save updated cache + saveCache(); + + // Add delay between batches + if (i + MAX_BATCH_SIZE < ipsToFetch.length) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + return results; +} + +/** + * Get continent name from continent code + */ +function getContinentName(code?: string): string { + if (!code) return 'Unknown'; + + const continents: Record = { + 'AF': 'Africa', + 'AN': 'Antarctica', + 'AS': 'Asia', + 'EU': 'Europe', + 'NA': 'North America', + 'OC': 'Oceania', + 'SA': 'South America' + }; + + return continents[code] || 'Unknown'; +} + +/** + * Get location information based on view mode + */ +export function getLocationByType( + locationData: IpLocationData | null, + viewMode: 'country' | 'city' | 'region' | 'continent' +): string { + if (!locationData) return 'Unknown'; + + switch (viewMode) { + case 'country': + return locationData.country_name || 'Unknown'; + case 'city': + return locationData.city || 'Unknown'; + case 'region': + return locationData.region || 'Unknown'; + case 'continent': + return locationData.continent_name || 'Unknown'; + default: + return 'Unknown'; + } +} \ No newline at end of file