Skip to content

Commit ca9d09a

Browse files
committed
Add is_satellite, is_anycast and blocklist date fields
The API now reports two more top-level booleans next to is_eu, is_satellite and is_anycast, each included only when it is true, and each threat.blocklists entry may carry first_seen and last_seen (YYYY-MM-DD strings, UTC) plus days_seen. Some feeds omit the three blocklist fields, so all of them are optional. LookupResponse declares the new keys as optional (undefined reads as false for the flags), the dates stay strings, and both flag names are accepted by the selectField and fields validation of lookup() and bulkLookup(). Responses are parsed JSON, so unknown keys already pass through unchanged. Tests add fixtures built from the API samples (a satellite ISP, an anycast address with dated blocklist entries, a VPN exit with an undated entry) for single and bulk lookups, and the README documents the five names. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1c6ae49 commit ca9d09a

3 files changed

Lines changed: 297 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,16 +195,28 @@ A request that fails because the server closed a pooled connection before any re
195195

196196
The following fields are available for use with `selectField` and `fields`:
197197

198-
`ip`, `is_eu`, `city`, `region`, `region_code`, `country_name`, `country_code`, `continent_name`, `continent_code`, `latitude`, `longitude`, `asn`, `company`, `organisation`, `postal`, `calling_code`, `flag`, `emoji_flag`, `emoji_unicode`, `carrier`, `languages`, `currency`, `time_zone`, `threat`, `count`
198+
`ip`, `is_eu`, `is_satellite`, `is_anycast`, `city`, `region`, `region_code`, `country_name`, `country_code`, `continent_name`, `continent_code`, `latitude`, `longitude`, `asn`, `company`, `organisation`, `postal`, `calling_code`, `flag`, `emoji_flag`, `emoji_unicode`, `carrier`, `languages`, `currency`, `time_zone`, `threat`, `count`
199199

200200
**`asn`** — `{ asn, name, domain, route, type }`
201201

202202
**`company`** — `{ name, domain, network, type }`
203203

204204
**`carrier`** — `{ name, mcc, mnc }`
205205

206+
**`is_satellite`** — `true` when the address belongs to a satellite internet provider. The API includes the key only when it is `true` and omits it otherwise, so treat a missing key as `false` (`info.is_satellite ?? false`).
207+
208+
**`is_anycast`** — `true` when the address is anycast. Also included only when `true`.
209+
206210
**`threat`** — `{ is_tor, is_icloud_relay, is_proxy, is_datacenter, is_anonymous, is_known_attacker, is_known_abuser, is_threat, is_bogon, blocklists }`
207211

212+
Each entry of `threat.blocklists` is `{ name, site, type, first_seen, last_seen, days_seen }`:
213+
214+
- **`first_seen`** — first day the address appeared on that blocklist, as a `YYYY-MM-DD` string (UTC)
215+
- **`last_seen`** — most recent day the address appeared on that blocklist, same format
216+
- **`days_seen`** — number of days the address has appeared on that blocklist (integer)
217+
218+
Some feeds, for example ipdata's own `vpn` list, do not report these three, so an entry may contain only `name`, `site` and `type`. The dates are passed through as strings.
219+
208220
## TypeScript
209221

210222
The library is written in TypeScript and exports the following types:

‎src/ipdata.test.ts‎

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,159 @@ const MOCK_DEFAULT_IP_DATA = {
7171
ip: '203.0.113.1',
7272
};
7373

74+
// Satellite ISP: is_satellite is present, is_anycast is not.
75+
const SAMPLE_SATELLITE_DATA = {
76+
ip: '98.97.34.0',
77+
is_satellite: true,
78+
is_eu: false,
79+
city: 'Seattle',
80+
country_code: 'US',
81+
asn: {
82+
asn: 'AS14593',
83+
name: 'Space Exploration Technologies Corporation',
84+
domain: 'spacex.com',
85+
route: '98.97.32.0/21',
86+
type: 'business',
87+
},
88+
threat: {
89+
is_tor: false,
90+
is_vpn: false,
91+
is_icloud_relay: false,
92+
is_proxy: false,
93+
is_datacenter: false,
94+
is_anonymous: false,
95+
is_known_attacker: false,
96+
is_known_abuser: false,
97+
is_threat: false,
98+
is_bogon: false,
99+
blocklists: [],
100+
scores: { vpn_score: 0, proxy_score: 1, threat_score: 8, trust_score: 97 },
101+
},
102+
count: '0',
103+
};
104+
105+
// Anycast address with dated blocklist entries: is_anycast is present, is_satellite is not.
106+
const SAMPLE_ANYCAST_DATA = {
107+
ip: '8.8.8.8',
108+
is_anycast: true,
109+
is_eu: false,
110+
city: null,
111+
region: null,
112+
region_code: null,
113+
region_type: null,
114+
country_name: 'United States',
115+
country_code: 'US',
116+
continent_name: 'North America',
117+
continent_code: 'NA',
118+
latitude: 39.7599983215332,
119+
longitude: -98.5,
120+
postal: null,
121+
calling_code: '1',
122+
flag: 'https://ipdata.co/flags/us.png',
123+
emoji_flag: '\u{1F1FA}\u{1F1F8}',
124+
emoji_unicode: 'U+1F1FA U+1F1F8',
125+
asn: { asn: 'AS15169', name: 'Google LLC', domain: 'about.google', route: '8.8.8.0/24', type: 'hosting' },
126+
company: { name: 'Google LLC', domain: 'about.google', network: '8.8.8.0/24', type: 'hosting' },
127+
languages: [{ name: 'English', native: 'English', code: 'en' }],
128+
currency: { name: 'US Dollar', code: 'USD', symbol: '$', native: '$', plural: 'US dollars' },
129+
time_zone: { name: null, abbr: null, offset: null, is_dst: null, current_time: null },
130+
threat: {
131+
is_tor: false,
132+
is_vpn: false,
133+
is_icloud_relay: false,
134+
is_proxy: false,
135+
is_datacenter: true,
136+
is_anonymous: false,
137+
is_known_attacker: false,
138+
is_known_abuser: false,
139+
is_threat: false,
140+
is_bogon: false,
141+
blocklists: [
142+
{
143+
days_seen: 1,
144+
first_seen: '2026-11-02',
145+
last_seen: '2026-11-02',
146+
name: 'ipdata',
147+
site: 'https://ipdata.co',
148+
type: 'cloud',
149+
},
150+
{
151+
days_seen: 1,
152+
first_seen: '2026-11-02',
153+
last_seen: '2026-11-02',
154+
name: 'ipdata',
155+
site: 'https://ipdata.co',
156+
type: 'open_resolvers',
157+
},
158+
],
159+
scores: { vpn_score: 100, proxy_score: 93, threat_score: 100, trust_score: 2 },
160+
},
161+
count: '0',
162+
};
163+
164+
// VPN exit: neither top-level flag is present and the third blocklist entry has no dates.
165+
const SAMPLE_VPN_DATA = {
166+
ip: '187.13.250.1',
167+
is_eu: false,
168+
threat: {
169+
is_tor: false,
170+
is_vpn: true,
171+
is_icloud_relay: false,
172+
is_proxy: false,
173+
is_datacenter: true,
174+
is_anonymous: true,
175+
is_known_attacker: true,
176+
is_known_abuser: true,
177+
is_threat: true,
178+
is_bogon: false,
179+
blocklists: [
180+
{
181+
days_seen: 2,
182+
first_seen: '2026-10-31',
183+
last_seen: '2026-11-02',
184+
name: 'Gluetun',
185+
site: 'https://github.com/qdm12/gluetun',
186+
type: 'vpn',
187+
},
188+
{
189+
days_seen: 1,
190+
first_seen: '2026-11-02',
191+
last_seen: '2026-11-02',
192+
name: 'ipdata',
193+
site: 'https://ipdata.co',
194+
type: 'cloud',
195+
},
196+
{ name: 'ipdata', site: 'https://ipdata.co', type: 'vpn' },
197+
{
198+
days_seen: 2,
199+
first_seen: '2026-10-31',
200+
last_seen: '2026-11-02',
201+
name: 'NordVPN',
202+
site: 'https://nordvpn.com',
203+
type: 'vpn',
204+
},
205+
{
206+
days_seen: 2,
207+
first_seen: '2026-10-31',
208+
last_seen: '2026-11-02',
209+
name: 'Wikimedia Global Blocklist',
210+
site: 'https://wikimedia.org',
211+
type: 'general',
212+
},
213+
{
214+
days_seen: 2,
215+
first_seen: '2026-10-31',
216+
last_seen: '2026-11-02',
217+
name: 'X4BNet',
218+
site: 'https://github.com/X4BNet/lists_vpn',
219+
type: 'vpn',
220+
},
221+
],
222+
scores: { vpn_score: 100, proxy_score: 99, threat_score: 100, trust_score: 0 },
223+
},
224+
count: '0',
225+
};
226+
74227
// Every request in this file goes through an undici MockAgent injected via httpConfig.dispatcher, or through a
75228
// loopback server; nothing reaches the network.
76229
const mockAgent = new MockAgent();
@@ -563,6 +716,80 @@ describe('lookup()', () => {
563716
expect(info.languages[0]).toHaveProperty('code');
564717
});
565718
});
719+
720+
describe('is_satellite, is_anycast and blocklist dates', () => {
721+
it('should expose is_satellite for a satellite ISP and leave is_anycast undefined', async () => {
722+
mockLookup('/98.97.34.0', SAMPLE_SATELLITE_DATA);
723+
const info = await ipdata.lookup('98.97.34.0');
724+
expect(info.is_satellite).toBe(true);
725+
expect(info.is_anycast).toBeUndefined();
726+
expect(info.is_anycast ?? false).toBe(false);
727+
expect(info.threat.blocklists).toEqual([]);
728+
});
729+
730+
it('should expose is_anycast and dated blocklist entries for an anycast address', async () => {
731+
mockLookup('/8.8.8.8', SAMPLE_ANYCAST_DATA);
732+
const info = await ipdata.lookup('8.8.8.8');
733+
expect(info.is_anycast).toBe(true);
734+
expect(info.is_satellite).toBeUndefined();
735+
expect(info.is_satellite ?? false).toBe(false);
736+
expect(info.threat.blocklists).toHaveLength(2);
737+
expect(info.threat.blocklists![0]).toEqual({
738+
days_seen: 1,
739+
first_seen: '2026-11-02',
740+
last_seen: '2026-11-02',
741+
name: 'ipdata',
742+
site: 'https://ipdata.co',
743+
type: 'cloud',
744+
});
745+
expect(typeof info.threat.blocklists![1].first_seen).toBe('string');
746+
expect(typeof info.threat.blocklists![1].days_seen).toBe('number');
747+
});
748+
749+
it('should leave both flags undefined and tolerate a blocklist entry without dates', async () => {
750+
mockLookup('/187.13.250.1', SAMPLE_VPN_DATA);
751+
const info = await ipdata.lookup('187.13.250.1');
752+
expect(info.is_satellite).toBeUndefined();
753+
expect(info.is_anycast).toBeUndefined();
754+
expect(info.threat.blocklists).toHaveLength(6);
755+
const undated = info.threat.blocklists![2];
756+
expect(undated).toEqual({ name: 'ipdata', site: 'https://ipdata.co', type: 'vpn' });
757+
expect(undated.first_seen).toBeUndefined();
758+
expect(undated.last_seen).toBeUndefined();
759+
expect(undated.days_seen).toBeUndefined();
760+
expect(info.threat.blocklists![0]).toMatchObject({
761+
name: 'Gluetun',
762+
days_seen: 2,
763+
first_seen: '2026-10-31',
764+
last_seen: '2026-11-02',
765+
});
766+
});
767+
768+
it('should pass unknown keys through unchanged', async () => {
769+
mockLookup('/98.97.34.0', { ...SAMPLE_SATELLITE_DATA, future_field: 'kept' });
770+
const info = await ipdata.lookup('98.97.34.0');
771+
expect(info).toHaveProperty('future_field', 'kept');
772+
expect(info).toHaveProperty('is_satellite', true);
773+
});
774+
775+
it('should accept is_satellite and is_anycast as select fields', async () => {
776+
mockLookup('/98.97.34.0/is_satellite', true);
777+
const satellite = await ipdata.lookup('98.97.34.0', 'is_satellite');
778+
expect(satellite).toHaveProperty('is_satellite', true);
779+
mockLookup('/8.8.8.8/is_anycast', true);
780+
const anycast = await ipdata.lookup({ ip: '8.8.8.8', selectField: 'is_anycast' });
781+
expect(anycast).toHaveProperty('is_anycast', true);
782+
});
783+
784+
it('should accept is_satellite and is_anycast in the fields array', async () => {
785+
const fields = ['ip', 'is_satellite', 'is_anycast'];
786+
mockLookup('/8.8.8.8', { ip: '8.8.8.8', is_anycast: true }, { params: { fields: fields.join(',') } });
787+
const info = await ipdata.lookup('8.8.8.8', undefined, fields);
788+
expect(info).toHaveProperty('ip', '8.8.8.8');
789+
expect(info).toHaveProperty('is_anycast', true);
790+
expect(info.is_satellite).toBeUndefined();
791+
});
792+
});
566793
});
567794

568795
describe('bulkLookup()', () => {
@@ -651,4 +878,44 @@ describe('bulkLookup()', () => {
651878
expect(info[IP2]).toHaveProperty('status');
652879
});
653880
});
881+
882+
describe('is_satellite, is_anycast and blocklist dates', () => {
883+
const SATELLITE_IP = '98.97.34.0';
884+
const ANYCAST_IP = '8.8.8.8';
885+
886+
it('should return the new fields per item', async () => {
887+
const ips = [SATELLITE_IP, ANYCAST_IP];
888+
mockBulkLookup(ips, [SAMPLE_SATELLITE_DATA, SAMPLE_ANYCAST_DATA]);
889+
const info = keyBy(await ipdata.bulkLookup(ips));
890+
expect(info[SATELLITE_IP].is_satellite).toBe(true);
891+
expect(info[SATELLITE_IP].is_anycast).toBeUndefined();
892+
expect(info[SATELLITE_IP].threat.blocklists).toEqual([]);
893+
expect(info[ANYCAST_IP].is_anycast).toBe(true);
894+
expect(info[ANYCAST_IP].is_satellite).toBeUndefined();
895+
expect(info[ANYCAST_IP].threat.blocklists![1]).toMatchObject({
896+
type: 'open_resolvers',
897+
days_seen: 1,
898+
first_seen: '2026-11-02',
899+
last_seen: '2026-11-02',
900+
});
901+
});
902+
903+
it('should accept is_satellite and is_anycast in the fields array', async () => {
904+
const ips = [SATELLITE_IP, ANYCAST_IP];
905+
const fields = ['ip', 'is_satellite', 'is_anycast'];
906+
mockBulkLookup(
907+
ips,
908+
[
909+
{ ip: SATELLITE_IP, is_satellite: true },
910+
{ ip: ANYCAST_IP, is_anycast: true },
911+
],
912+
{ params: { fields: fields.join(',') } },
913+
);
914+
const info = keyBy(await ipdata.bulkLookup(ips, fields));
915+
expect(info[SATELLITE_IP]).toHaveProperty('is_satellite', true);
916+
expect(info[SATELLITE_IP].is_anycast).toBeUndefined();
917+
expect(info[ANYCAST_IP]).toHaveProperty('is_anycast', true);
918+
expect(info[ANYCAST_IP].is_satellite).toBeUndefined();
919+
});
920+
});
654921
});

‎src/ipdata.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const DEFAULT_IP = 'DEFAULT_IP';
1111
const VALID_FIELDS = [
1212
'ip',
1313
'is_eu',
14+
'is_satellite',
15+
'is_anycast',
1416
'city',
1517
'region',
1618
'region_code',
@@ -137,6 +139,10 @@ export interface LookupParams {
137139
export interface LookupResponse {
138140
ip: string;
139141
is_eu: boolean;
142+
/** true for satellite ISP addresses. The API includes the key only when it is true and omits it otherwise. */
143+
is_satellite?: boolean;
144+
/** true for anycast addresses. The API includes the key only when it is true and omits it otherwise. */
145+
is_anycast?: boolean;
140146
city?: string;
141147
region?: string;
142148
region_code?: string;
@@ -194,7 +200,17 @@ export interface LookupResponse {
194200
is_bogon: boolean;
195201
is_icloud_relay?: boolean;
196202
is_datacenter?: boolean;
197-
blocklists?: { name: string; site: string; type: string }[];
203+
blocklists?: {
204+
name: string;
205+
site: string;
206+
type: string;
207+
/** First day the address was seen on this list, as a YYYY-MM-DD string (UTC). Some feeds omit the dates. */
208+
first_seen?: string;
209+
/** Most recent day the address was seen on this list, as a YYYY-MM-DD string (UTC). */
210+
last_seen?: string;
211+
/** Number of days the address has been seen on this list. Omitted together with the dates. */
212+
days_seen?: number;
213+
}[];
198214
};
199215
count: number;
200216
status: number;

0 commit comments

Comments
 (0)