diff --git a/CHANGELOG.md b/CHANGELOG.md index 93aa2c3..2dfd07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.6.0 + +* A new `residential` object has been added to the `anonymizer` object on + `MaxMind::GeoIP2::Model::Insights`. This object contains residential + proxy data for the network, including a confidence score, the provider + name, and the date the network was last seen. This is only available + from the GeoIP2 Insights web service. + ## 1.5.1 (2026-01-19) * Re-release with a fix to the release process. This includes a bump of the diff --git a/CLAUDE.md b/CLAUDE.md index 954fc14..3b2921f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,7 @@ def network_last_seen date_string = get('network_last_seen') if !date_string + @network_last_seen = nil return nil end @@ -125,6 +126,7 @@ end - Use `defined?(@variable)` to check if already parsed - Parse only once and cache in instance variable - Handle nil cases before parsing +- Memoize nil results too, so repeated calls do not re-run the lookup #### 5. **Web Service Only vs Database Models** @@ -233,6 +235,7 @@ For database models (like AnonymousPlus): date_string = get('network_last_seen') if !date_string + @network_last_seen = nil return nil end diff --git a/lib/maxmind/geoip2/model/anonymous_plus.rb b/lib/maxmind/geoip2/model/anonymous_plus.rb index 109c9a5..9377514 100644 --- a/lib/maxmind/geoip2/model/anonymous_plus.rb +++ b/lib/maxmind/geoip2/model/anonymous_plus.rb @@ -27,6 +27,7 @@ def network_last_seen date_string = get('network_last_seen') if !date_string + @network_last_seen = nil return nil end diff --git a/lib/maxmind/geoip2/record/anonymizer.rb b/lib/maxmind/geoip2/record/anonymizer.rb index 073b992..337b4b4 100644 --- a/lib/maxmind/geoip2/record/anonymizer.rb +++ b/lib/maxmind/geoip2/record/anonymizer.rb @@ -2,6 +2,7 @@ require 'date' require 'maxmind/geoip2/record/abstract' +require 'maxmind/geoip2/record/anonymizer_feed' module MaxMind module GeoIP2 @@ -11,6 +12,20 @@ module Record # # This record is returned by the Insights web service. class Anonymizer < Abstract + # Residential proxy data for the network. This may be populated even + # when no other anonymizer attributes are set. This property is only + # available from Insights. + # + # @return [MaxMind::GeoIP2::Record::AnonymizerFeed] + attr_reader :residential + + # @!visibility private + def initialize(record) + super + + @residential = AnonymizerFeed.new(record.nil? ? nil : record['residential']) + end + # A score ranging from 1 to 99 that represents our percent confidence # that the network is currently part of an actively used VPN service. # This property is only available from Insights. @@ -85,6 +100,7 @@ def network_last_seen date_string = get('network_last_seen') if !date_string + @network_last_seen = nil return nil end diff --git a/lib/maxmind/geoip2/record/anonymizer_feed.rb b/lib/maxmind/geoip2/record/anonymizer_feed.rb new file mode 100644 index 0000000..9adafe4 --- /dev/null +++ b/lib/maxmind/geoip2/record/anonymizer_feed.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'date' +require 'maxmind/geoip2/record/abstract' + +module MaxMind + module GeoIP2 + module Record + # Contains data for one type of anonymizer detection, currently + # residential proxies. Additional feeds may be added in the future. + # + # This record is returned by the anonymizer object of the Insights web + # service. + class AnonymizerFeed < Abstract + # A score ranging from 1 to 99 that represents our percent confidence + # that the network is currently part of this anonymizer feed. This + # property is only available from Insights. + # + # @return [Integer, nil] + def confidence + get('confidence') + end + + # The last day that the network was sighted in our analysis of this + # anonymizer feed. This value is parsed lazily. This property is only + # available from Insights. + # + # @return [Date, nil] A Date object representing the last seen date, + # or nil if the date is not available. + def network_last_seen + return @network_last_seen if defined?(@network_last_seen) + + date_string = get('network_last_seen') + + if !date_string + @network_last_seen = nil + return nil + end + + @network_last_seen = Date.parse(date_string) + end + + # The name of the provider associated with the network in this + # anonymizer feed. This property is only available from Insights. + # + # @return [String, nil] + def provider_name + get('provider_name') + end + end + end + end +end diff --git a/test/test_client.rb b/test/test_client.rb index c372011..1505e30 100644 --- a/test/test_client.rb +++ b/test/test_client.rb @@ -38,6 +38,11 @@ class ClientTest < Minitest::Test 'is_tor_exit_node' => false, 'network_last_seen' => '2025-10-15', 'provider_name' => 'nordvpn', + 'residential' => { + 'confidence' => 82, + 'network_last_seen' => '2026-05-11', + 'provider_name' => 'quickshift', + }, }, 'continent' => { 'code' => 'NA', @@ -109,6 +114,11 @@ def test_insights assert_equal(Date.parse('2025-10-15'), record.anonymizer.network_last_seen) assert_equal('nordvpn', record.anonymizer.provider_name) + # Test anonymizer.residential object + assert_equal(82, record.anonymizer.residential.confidence) + assert_equal(Date.parse('2026-05-11'), record.anonymizer.residential.network_last_seen) + assert_equal('quickshift', record.anonymizer.residential.provider_name) + # Test traits assert(record.traits.anycast?) assert(record.traits.residential_proxy?) diff --git a/test/test_record.rb b/test/test_record.rb new file mode 100644 index 0000000..9a71dcf --- /dev/null +++ b/test/test_record.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'date' +require 'maxmind/geoip2' +require 'minitest/autorun' + +class RecordTest < Minitest::Test + # This covers the common case where a web service response has no + # "anonymizer" key at all. Insights#initialize always calls + # Anonymizer.new(record['anonymizer']), so this exercises + # Anonymizer.new(nil) and, in turn, AnonymizerFeed.new(nil). + def test_insights_no_anonymizer + model = MaxMind::GeoIP2::Model::Insights.new({}, ['en']) + + assert_nil(model.anonymizer.confidence) + refute(model.anonymizer.anonymous?) + refute(model.anonymizer.anonymous_vpn?) + refute(model.anonymizer.hosting_provider?) + refute(model.anonymizer.public_proxy?) + refute(model.anonymizer.residential_proxy?) + refute(model.anonymizer.tor_exit_node?) + assert_nil(model.anonymizer.network_last_seen) + assert_nil(model.anonymizer.provider_name) + + assert_nil(model.anonymizer.residential.confidence) + assert_nil(model.anonymizer.residential.network_last_seen) + assert_nil(model.anonymizer.residential.provider_name) + end + + # This covers an "anonymizer" object being present without a nested + # "residential" key, which is the common case when the network is not + # part of the residential proxy feed. + def test_insights_anonymizer_without_residential + model = MaxMind::GeoIP2::Model::Insights.new( + { + 'anonymizer' => { + 'confidence' => 85, + }, + }, + ['en'], + ) + + assert_equal(85, model.anonymizer.confidence) + + assert_nil(model.anonymizer.residential.confidence) + assert_nil(model.anonymizer.residential.network_last_seen) + assert_nil(model.anonymizer.residential.provider_name) + end + + # network_last_seen is parsed lazily and guards against a missing date + # string. This asserts the guard is in place rather than an unconditional + # Date.parse(nil), which would raise. + def test_anonymizer_feed_network_last_seen_absent + feed = MaxMind::GeoIP2::Record::AnonymizerFeed.new( + { 'confidence' => 82 }, + ) + + assert_nil(feed.network_last_seen) + end + + def test_anonymizer_network_last_seen_absent + anonymizer = MaxMind::GeoIP2::Record::Anonymizer.new( + { 'confidence' => 85 }, + ) + + assert_nil(anonymizer.network_last_seen) + end + + def test_anonymous_plus_network_last_seen_absent + model = MaxMind::GeoIP2::Model::AnonymousPlus.new( + { + 'ip_address' => '1.2.3.4', + 'prefix_length' => 24, + }, + ) + + assert_nil(model.network_last_seen) + end +end