Skip to content

Commit fac0aab

Browse files
authored
Merge pull request #207 from maxmind/greg/stf-997-add-residential-to-anonymizer
Add residential proxy data to anonymizer object
2 parents 537148d + f9d3fbc commit fac0aab

7 files changed

Lines changed: 170 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 1.6.0
4+
5+
* A new `residential` object has been added to the `anonymizer` object on
6+
`MaxMind::GeoIP2::Model::Insights`. This object contains residential
7+
proxy data for the network, including a confidence score, the provider
8+
name, and the date the network was last seen. This is only available
9+
from the GeoIP2 Insights web service.
10+
311
## 1.5.1 (2026-01-19)
412

513
* Re-release with a fix to the release process. This includes a bump of the

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def network_last_seen
115115
date_string = get('network_last_seen')
116116

117117
if !date_string
118+
@network_last_seen = nil
118119
return nil
119120
end
120121

@@ -125,6 +126,7 @@ end
125126
- Use `defined?(@variable)` to check if already parsed
126127
- Parse only once and cache in instance variable
127128
- Handle nil cases before parsing
129+
- Memoize nil results too, so repeated calls do not re-run the lookup
128130

129131
#### 5. **Web Service Only vs Database Models**
130132

@@ -233,6 +235,7 @@ For database models (like AnonymousPlus):
233235
date_string = get('network_last_seen')
234236

235237
if !date_string
238+
@network_last_seen = nil
236239
return nil
237240
end
238241

lib/maxmind/geoip2/model/anonymous_plus.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def network_last_seen
2727
date_string = get('network_last_seen')
2828

2929
if !date_string
30+
@network_last_seen = nil
3031
return nil
3132
end
3233

lib/maxmind/geoip2/record/anonymizer.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'date'
44
require 'maxmind/geoip2/record/abstract'
5+
require 'maxmind/geoip2/record/anonymizer_feed'
56

67
module MaxMind
78
module GeoIP2
@@ -11,6 +12,20 @@ module Record
1112
#
1213
# This record is returned by the Insights web service.
1314
class Anonymizer < Abstract
15+
# Residential proxy data for the network. This may be populated even
16+
# when no other anonymizer attributes are set. This property is only
17+
# available from Insights.
18+
#
19+
# @return [MaxMind::GeoIP2::Record::AnonymizerFeed]
20+
attr_reader :residential
21+
22+
# @!visibility private
23+
def initialize(record)
24+
super
25+
26+
@residential = AnonymizerFeed.new(record.nil? ? nil : record['residential'])
27+
end
28+
1429
# A score ranging from 1 to 99 that represents our percent confidence
1530
# that the network is currently part of an actively used VPN service.
1631
# This property is only available from Insights.
@@ -85,6 +100,7 @@ def network_last_seen
85100
date_string = get('network_last_seen')
86101

87102
if !date_string
103+
@network_last_seen = nil
88104
return nil
89105
end
90106

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
require 'date'
4+
require 'maxmind/geoip2/record/abstract'
5+
6+
module MaxMind
7+
module GeoIP2
8+
module Record
9+
# Contains data for one type of anonymizer detection, currently
10+
# residential proxies. Additional feeds may be added in the future.
11+
#
12+
# This record is returned by the anonymizer object of the Insights web
13+
# service.
14+
class AnonymizerFeed < Abstract
15+
# A score ranging from 1 to 99 that represents our percent confidence
16+
# that the network is currently part of this anonymizer feed. This
17+
# property is only available from Insights.
18+
#
19+
# @return [Integer, nil]
20+
def confidence
21+
get('confidence')
22+
end
23+
24+
# The last day that the network was sighted in our analysis of this
25+
# anonymizer feed. This value is parsed lazily. This property is only
26+
# available from Insights.
27+
#
28+
# @return [Date, nil] A Date object representing the last seen date,
29+
# or nil if the date is not available.
30+
def network_last_seen
31+
return @network_last_seen if defined?(@network_last_seen)
32+
33+
date_string = get('network_last_seen')
34+
35+
if !date_string
36+
@network_last_seen = nil
37+
return nil
38+
end
39+
40+
@network_last_seen = Date.parse(date_string)
41+
end
42+
43+
# The name of the provider associated with the network in this
44+
# anonymizer feed. This property is only available from Insights.
45+
#
46+
# @return [String, nil]
47+
def provider_name
48+
get('provider_name')
49+
end
50+
end
51+
end
52+
end
53+
end

test/test_client.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ class ClientTest < Minitest::Test
3838
'is_tor_exit_node' => false,
3939
'network_last_seen' => '2025-10-15',
4040
'provider_name' => 'nordvpn',
41+
'residential' => {
42+
'confidence' => 82,
43+
'network_last_seen' => '2026-05-11',
44+
'provider_name' => 'quickshift',
45+
},
4146
},
4247
'continent' => {
4348
'code' => 'NA',
@@ -109,6 +114,11 @@ def test_insights
109114
assert_equal(Date.parse('2025-10-15'), record.anonymizer.network_last_seen)
110115
assert_equal('nordvpn', record.anonymizer.provider_name)
111116

117+
# Test anonymizer.residential object
118+
assert_equal(82, record.anonymizer.residential.confidence)
119+
assert_equal(Date.parse('2026-05-11'), record.anonymizer.residential.network_last_seen)
120+
assert_equal('quickshift', record.anonymizer.residential.provider_name)
121+
112122
# Test traits
113123
assert(record.traits.anycast?)
114124
assert(record.traits.residential_proxy?)

test/test_record.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
require 'date'
4+
require 'maxmind/geoip2'
5+
require 'minitest/autorun'
6+
7+
class RecordTest < Minitest::Test
8+
# This covers the common case where a web service response has no
9+
# "anonymizer" key at all. Insights#initialize always calls
10+
# Anonymizer.new(record['anonymizer']), so this exercises
11+
# Anonymizer.new(nil) and, in turn, AnonymizerFeed.new(nil).
12+
def test_insights_no_anonymizer
13+
model = MaxMind::GeoIP2::Model::Insights.new({}, ['en'])
14+
15+
assert_nil(model.anonymizer.confidence)
16+
refute(model.anonymizer.anonymous?)
17+
refute(model.anonymizer.anonymous_vpn?)
18+
refute(model.anonymizer.hosting_provider?)
19+
refute(model.anonymizer.public_proxy?)
20+
refute(model.anonymizer.residential_proxy?)
21+
refute(model.anonymizer.tor_exit_node?)
22+
assert_nil(model.anonymizer.network_last_seen)
23+
assert_nil(model.anonymizer.provider_name)
24+
25+
assert_nil(model.anonymizer.residential.confidence)
26+
assert_nil(model.anonymizer.residential.network_last_seen)
27+
assert_nil(model.anonymizer.residential.provider_name)
28+
end
29+
30+
# This covers an "anonymizer" object being present without a nested
31+
# "residential" key, which is the common case when the network is not
32+
# part of the residential proxy feed.
33+
def test_insights_anonymizer_without_residential
34+
model = MaxMind::GeoIP2::Model::Insights.new(
35+
{
36+
'anonymizer' => {
37+
'confidence' => 85,
38+
},
39+
},
40+
['en'],
41+
)
42+
43+
assert_equal(85, model.anonymizer.confidence)
44+
45+
assert_nil(model.anonymizer.residential.confidence)
46+
assert_nil(model.anonymizer.residential.network_last_seen)
47+
assert_nil(model.anonymizer.residential.provider_name)
48+
end
49+
50+
# network_last_seen is parsed lazily and guards against a missing date
51+
# string. This asserts the guard is in place rather than an unconditional
52+
# Date.parse(nil), which would raise.
53+
def test_anonymizer_feed_network_last_seen_absent
54+
feed = MaxMind::GeoIP2::Record::AnonymizerFeed.new(
55+
{ 'confidence' => 82 },
56+
)
57+
58+
assert_nil(feed.network_last_seen)
59+
end
60+
61+
def test_anonymizer_network_last_seen_absent
62+
anonymizer = MaxMind::GeoIP2::Record::Anonymizer.new(
63+
{ 'confidence' => 85 },
64+
)
65+
66+
assert_nil(anonymizer.network_last_seen)
67+
end
68+
69+
def test_anonymous_plus_network_last_seen_absent
70+
model = MaxMind::GeoIP2::Model::AnonymousPlus.new(
71+
{
72+
'ip_address' => '1.2.3.4',
73+
'prefix_length' => 24,
74+
},
75+
)
76+
77+
assert_nil(model.network_last_seen)
78+
end
79+
end

0 commit comments

Comments
 (0)