diff --git a/web/src/routes/ips/+page.svelte b/web/src/routes/ips/+page.svelte index 862b256..e06da38 100644 --- a/web/src/routes/ips/+page.svelte +++ b/web/src/routes/ips/+page.svelte @@ -26,6 +26,81 @@ let mapStatus = $state(null); let renderAttempts = $state(0); + type IpCategory = 'vpn' | 'hoster' | 'isp' | 'unknown'; + + function classifyByAsOrg(asOrg: string | null | undefined): IpCategory { + const s = (asOrg ?? '').toLowerCase(); + if (!s) return 'unknown'; + + // VPN heuristics from org name. + if ( + s.includes('vpn') || + s.includes('mullvad') || + s.includes('nordvpn') || + s.includes('proton') || + s.includes('expressvpn') || + s.includes('surfshark') || + s.includes('cyberghost') || + s.includes('strongvpn') || + s.includes('ivpn') || + s.includes('private internet access') || + s.includes('pia ') + ) { + return 'vpn'; + } + + // Hosting / cloud heuristics from org name. + if ( + s.includes('cloudflare') || + s.includes('akamai') || + s.includes('fastly') || + s.includes('ovh') || + s.includes('hetzner') || + s.includes('digitalocean') || + s.includes('linode') || + s.includes('vultr') || + s.includes('amazon') || + s.includes('aws') || + s.includes('google') || + s.includes('gcp') || + s.includes('microsoft') || + s.includes('azure') || + s.includes('oracle') || + s.includes('telegram') || + s.includes('hosting') || + s.includes('datacenter') || + s.includes('data center') || + s.includes('cdn') || + s.includes('edge') || + s.includes('compute') + ) { + return 'hoster'; + } + + // If it doesn't match above, assume ISP. + return 'isp'; + } + + function categoryPriority(c: IpCategory): number { + if (c === 'vpn') return 3; + if (c === 'hoster') return 2; + if (c === 'isp') return 1; + return 0; + } + + function categoryLabel(c: IpCategory): string { + if (c === 'vpn') return 'VPN'; + if (c === 'hoster') return 'Hoster'; + if (c === 'isp') return 'ISP'; + return 'Unknown'; + } + + function truncate(s: string, n: number): string { + const x = s ?? ''; + if (x.length <= n) return x; + return `${x.slice(0, n - 1)}…`; + } + async function ensureLeaflet() { if (leaflet) return; leaflet = await import('leaflet'); @@ -77,9 +152,11 @@ lat: number; lon: number; count: number; + category: IpCategory; country_code?: string | null; city_name?: string | null; asn?: number | null; + as_organization?: string | null; } >(); @@ -97,6 +174,8 @@ const lonG = Math.round(lon * 10) / 10; const country_code = ip.country_code ?? null; const city_name = ip.city_name ?? null; + const asOrg = ip.as_organization ?? null; + const category = classifyByAsOrg(asOrg); // Ключ включает country/city, чтобы не смешивать разные локации // при одинаковых координатах округления. @@ -104,6 +183,11 @@ const item = groups.get(key); if (item) { item.count++; + if (categoryPriority(category) > categoryPriority(item.category)) { + item.category = category; + item.asn = ip.asn ?? null; + item.as_organization = asOrg; + } continue; } @@ -111,9 +195,11 @@ lat: latG, lon: lonG, count: 1, + category, country_code, city_name, - asn: ip.asn ?? null + asn: ip.asn ?? null, + as_organization: asOrg }); } } @@ -141,12 +227,20 @@ for (const p of points) { const r = Math.min(28, 5 + Math.log10(p.count + 1) * 9); const color = - p.count >= 100 ? '#ef4444' : p.count >= 25 ? '#f97316' : p.count >= 5 ? '#22c55e' : '#60a5fa'; + p.category === 'vpn' + ? '#ef4444' + : p.category === 'hoster' + ? '#f97316' + : p.category === 'isp' + ? '#22c55e' + : '#60a5fa'; const tooltipParts = [ `${p.count} IP`, + categoryLabel(p.category), p.country_code ? p.country_code : '—', - p.city_name ? p.city_name : '' + p.city_name ? p.city_name : '', + p.asn != null ? `AS${p.asn} ${p.as_organization ? truncate(p.as_organization, 24) : ''}` : '' ].filter(Boolean); const marker = leaflet.circleMarker([p.lat, p.lon], {