This commit is contained in:
2025-04-02 22:23:49 +08:00
parent 63f434fd93
commit 0b41f3ea42
5 changed files with 993 additions and 7 deletions

248
app/api/geo/batch/route.ts Normal file
View File

@@ -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<string, IpLocationData> = {};
// Cache for IPs that have repeatedly failed to resolve
const failedIPsCache: Record<string, { attempts: number, lastAttempt: number }> = {};
// 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<IpLocationData | null> {
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<string, string> = {
'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<string, IpLocationData | null> = {};
// 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<Record<string, IpLocationData | null>> = {
success: true,
data: results
};
return NextResponse.json(response);
} catch (error) {
console.error('Batch IP lookup error:', error);
const response: ApiResponse<null> = {
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()
};
}

View File

@@ -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<LocationCache>({});
const [isLoading, setIsLoading] = useState(false);
// Track IPs that failed to resolve
const [failedIPs, setFailedIPs] = useState<Set<string>>(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) {
</button>
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-center items-center py-2 mb-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
<span className="text-sm text-gray-500">Loading location data...</span>
</div>
)}
{/* Table with added area column */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
@@ -71,9 +212,9 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
viewMode === 'region' ? 'Region' : 'Continent'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{viewMode === 'country' ? 'Countries' :
viewMode === 'city' ? 'Cities' :
viewMode === 'region' ? 'Regions' : 'Continents'}
{viewMode === 'country' ? 'Continent' :
viewMode === 'city' ? 'Location' :
viewMode === 'region' ? 'Country' : 'Area'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Visits
@@ -91,10 +232,13 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
sortedData.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.location || 'Unknown'}
{getLocationValue(item)}
{item.location && (
<div className="text-xs text-gray-500 mt-1">{item.location}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.area || ''}
{getAreaValue(item)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visits)}

View File

@@ -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<LocationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">IP Location Test: {testIp}</h2>
{loading && (
<div className="flex items-center text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
Loading location data...
</div>
)}
{error && (
<div className="text-red-500">
Error: {error}
</div>
)}
{!loading && locationData && (
<div className="space-y-4">
<div>
<h3 className="font-medium">Location Data:</h3>
<pre className="mt-2 p-4 bg-gray-100 rounded overflow-auto">
{JSON.stringify(locationData, null, 2)}
</pre>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="border p-3 rounded">
<h4 className="font-medium">Country</h4>
<div>{locationData.country_name} ({locationData.country_code})</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">City</h4>
<div>{locationData.city || 'N/A'}</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">Region</h4>
<div>{locationData.region || 'N/A'}</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">Continent</h4>
<div>{locationData.continent_name} ({locationData.continent_code})</div>
</div>
<div className="border p-3 rounded col-span-2">
<h4 className="font-medium">Coordinates</h4>
<div>Latitude: {locationData.latitude}, Longitude: {locationData.longitude}</div>
</div>
</div>
</div>
)}
</div>
);
}

10
app/ip-test/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import IpLocationTest from '../components/ipLocationTest';
export default function IpTestPage() {
return (
<div className="container mx-auto p-4 max-w-4xl">
<h1 className="text-2xl font-bold mb-6">IP to Location Test</h1>
<IpLocationTest />
</div>
);
}

484
app/utils/ipLocation.ts Normal file
View File

@@ -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<string, IpLocationData> = {};
// Blacklist for IPs that failed to resolve multiple times
let failedIPs: Record<string, { attempts: number, lastAttempt: number }> = {};
// 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<string, IpLocationData>;
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<string, { attempts: number, lastAttempt: number }>;
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<IpLocationData | null> => {
// 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<Record<string, IpLocationData | null>> => {
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<string, IpLocationData | null> = {};
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<IpLocationData | null> {
// 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<Record<string, IpLocationData | null>> {
// 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<string, IpLocationData> = {};
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<string, IpLocationData | null> = { ...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<string, string> = {
'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';
}
}