From b709309ead1d4c49d26a6ac5aeb77580f9d26fa4 Mon Sep 17 00:00:00 2001 From: Daniil Poletaev Date: Fri, 24 Apr 2026 11:35:55 +0200 Subject: [PATCH 1/7] feat: add subdivisionCode to proxy configuration --- src/proxy_configuration.ts | 55 +++++++++++++++++++++++--- test/apify/proxy_configuration.test.ts | 54 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/proxy_configuration.ts b/src/proxy_configuration.ts index 68a8360f32..72ea7a3908 100644 --- a/src/proxy_configuration.ts +++ b/src/proxy_configuration.ts @@ -56,6 +56,19 @@ export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions */ apifyProxyCountry?: string; + /** + * If set, all proxied requests will use IP addresses geolocated to the specified subdivision (e.g. US state). + * Requires `countryCode` to be set. The value must follow the ISO 3166-2 subdivision code format, + * e.g. `'CA'` for California when `countryCode` is `'US'`. + */ + subdivisionCode?: string; + + /** + * Same option as `subdivisionCode` which can be used to + * configurate the proxy by UI input schema. You should use the `subdivisionCode` option in your crawler code. + */ + apifyProxySubdivision?: string; + /** * Multiple different ProxyConfigurationOptions stratified into tiers. Crawlee crawlers will switch between those tiers * based on the blocked request statistics. @@ -123,6 +136,12 @@ export interface ProxyInfo extends CoreProxyInfo { */ countryCode?: string; + /** + * If set, all proxied requests use IP addresses geolocated to the specified subdivision (e.g. US state). + * ISO 3166-2 subdivision code, e.g. `'CA'` when `countryCode` is `'US'`. + */ + subdivisionCode?: string; + /** * User's password for the proxy. By default, it is taken from the `APIFY_PROXY_PASSWORD` * environment variable, which is automatically set by the system when running the Actors @@ -167,6 +186,7 @@ export interface ProxyInfo extends CoreProxyInfo { export class ProxyConfiguration extends CoreProxyConfiguration { private groups: string[]; private countryCode?: string; + private subdivisionCode?: string; private password?: string; private hostname: string; private port?: number; @@ -187,7 +207,7 @@ export class ProxyConfiguration extends CoreProxyConfiguration { }); ow( rest, - ow.object.exactShape({ + ow.object.partialShape({ groups: ow.optional.array.ofType( ow.string.matches(APIFY_PROXY_VALUE_REGEX), ), @@ -197,6 +217,8 @@ export class ProxyConfiguration extends CoreProxyConfiguration { countryCode: ow.optional.string.matches(COUNTRY_CODE_REGEX), apifyProxyCountry: ow.optional.string.matches(COUNTRY_CODE_REGEX), + subdivisionCode: ow.optional.string, + apifyProxySubdivision: ow.optional.string, password: ow.optional.string, tieredProxyUrls: ow.optional.array.ofType( ow.array.ofType(ow.string), @@ -210,6 +232,8 @@ export class ProxyConfiguration extends CoreProxyConfiguration { apifyProxyGroups = [], countryCode, apifyProxyCountry, + subdivisionCode, + apifyProxySubdivision, password = config.get('proxyPassword'), tieredProxyConfig, tieredProxyUrls, @@ -226,13 +250,20 @@ export class ProxyConfiguration extends CoreProxyConfiguration { const groupsToUse = groups.length ? groups : apifyProxyGroups; const countryCodeToUse = countryCode || apifyProxyCountry; + const subdivisionCodeToUse = subdivisionCode || apifyProxySubdivision; const hostname = config.get('proxyHostname'); const port = config.get('proxyPort'); + if (subdivisionCodeToUse && !countryCodeToUse) { + throw new Error( + '"subdivisionCode" requires "countryCode" to be set.', + ); + } + // Validation if ( (proxyUrls || newUrlFunction) && - (groupsToUse.length || countryCodeToUse) + (groupsToUse.length || countryCodeToUse || subdivisionCodeToUse) ) { this._throwCannotCombineCustomWithApify(); } @@ -241,6 +272,7 @@ export class ProxyConfiguration extends CoreProxyConfiguration { this.groups = groupsToUse; this.countryCode = countryCodeToUse; + this.subdivisionCode = subdivisionCodeToUse; this.password = password; this.hostname = hostname!; this.port = port; @@ -328,7 +360,14 @@ export class ProxyConfiguration extends CoreProxyConfiguration { const proxyInfo = await super.newProxyInfo(sessionId, options); if (!proxyInfo) return proxyInfo; - const { groups, countryCode, password, port, hostname } = ( + const { + groups, + countryCode, + subdivisionCode, + password, + port, + hostname, + } = ( this.usesApifyProxy ? this : new URL(proxyInfo.url) ) as ProxyConfiguration; @@ -337,6 +376,7 @@ export class ProxyConfiguration extends CoreProxyConfiguration { sessionId, groups, countryCode, + subdivisionCode, // this.password is not encoded, but the password from the URL will be, we need to normalize password: this.usesApifyProxy ? (password ?? '') @@ -413,7 +453,7 @@ export class ProxyConfiguration extends CoreProxyConfiguration { */ protected _getUsername(sessionId?: string): string { let username; - const { groups, countryCode } = this; + const { groups, countryCode, subdivisionCode } = this; const parts: string[] = []; if (groups && groups.length) { @@ -422,7 +462,9 @@ export class ProxyConfiguration extends CoreProxyConfiguration { if (sessionId) { parts.push(`session-${sessionId}`); } - if (countryCode) { + if (subdivisionCode) { + parts.push(`country-${countryCode}-${subdivisionCode}`); + } else if (countryCode) { parts.push(`country-${countryCode}`); } @@ -545,7 +587,8 @@ export class ProxyConfiguration extends CoreProxyConfiguration { throw new Error( 'Cannot combine custom proxies with Apify Proxy! ' + 'It is not allowed to set "options.proxyUrls" or "options.newUrlFunction" combined with ' + - '"options.groups" or "options.apifyProxyGroups" and "options.countryCode" or "options.apifyProxyCountry".', + '"options.groups", "options.apifyProxyGroups", "options.countryCode", "options.apifyProxyCountry", ' + + '"options.subdivisionCode" or "options.apifyProxySubdivision".', ); } } diff --git a/test/apify/proxy_configuration.test.ts b/test/apify/proxy_configuration.test.ts index 3418699ed1..0ff34893ab 100644 --- a/test/apify/proxy_configuration.test.ts +++ b/test/apify/proxy_configuration.test.ts @@ -120,6 +120,60 @@ describe('ProxyConfiguration', () => { expect(proxyConfiguration.countryCode).toStrictEqual(apifyProxyCountry); }); + test('subdivisionCode should produce country-US-XX in proxy URL', async () => { + const proxyConfiguration = new ProxyConfiguration({ + groups, + countryCode: 'US', + subdivisionCode: 'CA', + password, + }); + + expect(await proxyConfiguration.newUrl(sessionId)).toBe( + 'http://groups-GROUP1+GROUP2,session-538909250932,country-US-CA:test12345@proxy.apify.com:8000', + ); + }); + + test('subdivisionCode without session should produce correct URL', async () => { + const proxyConfiguration = new ProxyConfiguration({ + groups, + countryCode: 'US', + subdivisionCode: 'NY', + password, + }); + + expect(await proxyConfiguration.newUrl()).toBe( + 'http://groups-GROUP1+GROUP2,country-US-NY:test12345@proxy.apify.com:8000', + ); + }); + + test('apifyProxySubdivision UI input schema should work', async () => { + const proxyConfiguration = new ProxyConfiguration({ + apifyProxyGroups: groups, + apifyProxyCountry: 'US', + apifyProxySubdivision: 'TX', + password, + }); + + // @ts-expect-error private property + expect(proxyConfiguration.subdivisionCode).toBe('TX'); + expect(await proxyConfiguration.newUrl()).toBe( + 'http://groups-GROUP1+GROUP2,country-US-TX:test12345@proxy.apify.com:8000', + ); + }); + + test('subdivisionCode without countryCode should throw', () => { + expect( + () => new ProxyConfiguration({ subdivisionCode: 'CA', password }), + ).toThrow('"subdivisionCode" requires "countryCode" to be set.'); + expect( + () => + new ProxyConfiguration({ + apifyProxySubdivision: 'CA', + password, + }), + ).toThrow('"subdivisionCode" requires "countryCode" to be set.'); + }); + test('should throw on invalid arguments structure', () => { // Group value const invalidGroups = ['GROUP1*']; From 6eadd0c4a2e40f978ed62c1a078cf9eaee9ec599 Mon Sep 17 00:00:00 2001 From: Daniil Poletaev Date: Fri, 24 Apr 2026 11:58:59 +0200 Subject: [PATCH 2/7] refactor: clean up --- src/proxy_configuration.ts | 11 ++++++++--- test/apify/proxy_configuration.test.ts | 27 ++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/proxy_configuration.ts b/src/proxy_configuration.ts index 72ea7a3908..c1a710ebe7 100644 --- a/src/proxy_configuration.ts +++ b/src/proxy_configuration.ts @@ -17,6 +17,7 @@ const MAX_SESSION_ID_LENGTH = 50; const CHECK_ACCESS_REQUEST_TIMEOUT_MILLIS = 4_000; const CHECK_ACCESS_MAX_ATTEMPTS = 2; const COUNTRY_CODE_REGEX = /^[A-Z]{2}$/; +const SUBDIVISION_CODE_REGEX = /^[A-Z0-9]{2}$/; export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions { /** @@ -217,8 +218,12 @@ export class ProxyConfiguration extends CoreProxyConfiguration { countryCode: ow.optional.string.matches(COUNTRY_CODE_REGEX), apifyProxyCountry: ow.optional.string.matches(COUNTRY_CODE_REGEX), - subdivisionCode: ow.optional.string, - apifyProxySubdivision: ow.optional.string, + subdivisionCode: ow.optional.string.matches( + SUBDIVISION_CODE_REGEX, + ), + apifyProxySubdivision: ow.optional.string.matches( + SUBDIVISION_CODE_REGEX, + ), password: ow.optional.string, tieredProxyUrls: ow.optional.array.ofType( ow.array.ofType(ow.string), @@ -463,7 +468,7 @@ export class ProxyConfiguration extends CoreProxyConfiguration { parts.push(`session-${sessionId}`); } if (subdivisionCode) { - parts.push(`country-${countryCode}-${subdivisionCode}`); + parts.push(`country-${countryCode}_${subdivisionCode}`); } else if (countryCode) { parts.push(`country-${countryCode}`); } diff --git a/test/apify/proxy_configuration.test.ts b/test/apify/proxy_configuration.test.ts index 0ff34893ab..a62d8f93c0 100644 --- a/test/apify/proxy_configuration.test.ts +++ b/test/apify/proxy_configuration.test.ts @@ -120,7 +120,7 @@ describe('ProxyConfiguration', () => { expect(proxyConfiguration.countryCode).toStrictEqual(apifyProxyCountry); }); - test('subdivisionCode should produce country-US-XX in proxy URL', async () => { + test('subdivisionCode should produce country-US_XX in proxy URL', async () => { const proxyConfiguration = new ProxyConfiguration({ groups, countryCode: 'US', @@ -129,7 +129,7 @@ describe('ProxyConfiguration', () => { }); expect(await proxyConfiguration.newUrl(sessionId)).toBe( - 'http://groups-GROUP1+GROUP2,session-538909250932,country-US-CA:test12345@proxy.apify.com:8000', + 'http://groups-GROUP1+GROUP2,session-538909250932,country-US_CA:test12345@proxy.apify.com:8000', ); }); @@ -142,7 +142,7 @@ describe('ProxyConfiguration', () => { }); expect(await proxyConfiguration.newUrl()).toBe( - 'http://groups-GROUP1+GROUP2,country-US-NY:test12345@proxy.apify.com:8000', + 'http://groups-GROUP1+GROUP2,country-US_NY:test12345@proxy.apify.com:8000', ); }); @@ -157,7 +157,7 @@ describe('ProxyConfiguration', () => { // @ts-expect-error private property expect(proxyConfiguration.subdivisionCode).toBe('TX'); expect(await proxyConfiguration.newUrl()).toBe( - 'http://groups-GROUP1+GROUP2,country-US-TX:test12345@proxy.apify.com:8000', + 'http://groups-GROUP1+GROUP2,country-US_TX:test12345@proxy.apify.com:8000', ); }); @@ -174,6 +174,25 @@ describe('ProxyConfiguration', () => { ).toThrow('"subdivisionCode" requires "countryCode" to be set.'); }); + test('should throw on invalid subdivisionCode', () => { + expect( + () => + new ProxyConfiguration({ + countryCode: 'US', + subdivisionCode: 'ca', + password, + }), + ).toThrow(); + expect( + () => + new ProxyConfiguration({ + countryCode: 'US', + subdivisionCode: 'California', + password, + }), + ).toThrow(); + }); + test('should throw on invalid arguments structure', () => { // Group value const invalidGroups = ['GROUP1*']; From c65d39f0cb0eff8e6ddcafb7747bff1ea0f72594 Mon Sep 17 00:00:00 2001 From: Daniil Poletaev Date: Mon, 27 Apr 2026 10:45:49 +0200 Subject: [PATCH 3/7] refactor: clean up --- src/proxy_configuration.ts | 4 ++-- test/apify/proxy_configuration.test.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/proxy_configuration.ts b/src/proxy_configuration.ts index c1a710ebe7..137cfd59d0 100644 --- a/src/proxy_configuration.ts +++ b/src/proxy_configuration.ts @@ -17,7 +17,7 @@ const MAX_SESSION_ID_LENGTH = 50; const CHECK_ACCESS_REQUEST_TIMEOUT_MILLIS = 4_000; const CHECK_ACCESS_MAX_ATTEMPTS = 2; const COUNTRY_CODE_REGEX = /^[A-Z]{2}$/; -const SUBDIVISION_CODE_REGEX = /^[A-Z0-9]{2}$/; +const SUBDIVISION_CODE_REGEX = /^[A-Z0-9]{1,3}$/; export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions { /** @@ -261,7 +261,7 @@ export class ProxyConfiguration extends CoreProxyConfiguration { if (subdivisionCodeToUse && !countryCodeToUse) { throw new Error( - '"subdivisionCode" requires "countryCode" to be set.', + 'ProxyConfiguration: "subdivisionCode" requires "countryCode" to be set.', ); } diff --git a/test/apify/proxy_configuration.test.ts b/test/apify/proxy_configuration.test.ts index a62d8f93c0..ec5a4fc5df 100644 --- a/test/apify/proxy_configuration.test.ts +++ b/test/apify/proxy_configuration.test.ts @@ -164,14 +164,18 @@ describe('ProxyConfiguration', () => { test('subdivisionCode without countryCode should throw', () => { expect( () => new ProxyConfiguration({ subdivisionCode: 'CA', password }), - ).toThrow('"subdivisionCode" requires "countryCode" to be set.'); + ).toThrow( + 'ProxyConfiguration: "subdivisionCode" requires "countryCode" to be set.', + ); expect( () => new ProxyConfiguration({ apifyProxySubdivision: 'CA', password, }), - ).toThrow('"subdivisionCode" requires "countryCode" to be set.'); + ).toThrow( + 'ProxyConfiguration: "subdivisionCode" requires "countryCode" to be set.', + ); }); test('should throw on invalid subdivisionCode', () => { From dd32cc208f714f0390da1f179bc96230dadb23d0 Mon Sep 17 00:00:00 2001 From: Daniil Poletaev Date: Mon, 27 Apr 2026 11:27:34 +0200 Subject: [PATCH 4/7] refactor: clean up --- src/proxy_configuration.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/proxy_configuration.ts b/src/proxy_configuration.ts index 137cfd59d0..d805d1db40 100644 --- a/src/proxy_configuration.ts +++ b/src/proxy_configuration.ts @@ -47,13 +47,13 @@ export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions /** * Same option as `groups` which can be used to - * configurate the proxy by UI input schema. You should use the `groups` option in your crawler code. + * configure the proxy by UI input schema. You should use the `groups` option in your crawler code. */ apifyProxyGroups?: string[]; /** * Same option as `countryCode` which can be used to - * configurate the proxy by UI input schema. You should use the `countryCode` option in your crawler code. + * configure the proxy by UI input schema. You should use the `countryCode` option in your crawler code. */ apifyProxyCountry?: string; @@ -66,7 +66,7 @@ export interface ProxyConfigurationOptions extends CoreProxyConfigurationOptions /** * Same option as `subdivisionCode` which can be used to - * configurate the proxy by UI input schema. You should use the `subdivisionCode` option in your crawler code. + * configure the proxy by UI input schema. You should use the `subdivisionCode` option in your crawler code. */ apifyProxySubdivision?: string; From 4668adbfb6f7aa64f4684bc00ecd96ab249b9563 Mon Sep 17 00:00:00 2001 From: Daniil Poletaev Date: Mon, 27 Apr 2026 14:39:54 +0200 Subject: [PATCH 5/7] chore: update docs --- docs/02_concepts/04_proxy_management.mdx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/02_concepts/04_proxy_management.mdx b/docs/02_concepts/04_proxy_management.mdx index 8f35ccf692..2f28afbdfa 100644 --- a/docs/02_concepts/04_proxy_management.mdx +++ b/docs/02_concepts/04_proxy_management.mdx @@ -157,6 +157,21 @@ Now your crawlers will use only Residential proxies from the US. Note that you m to a proxy group before you are able to use it. You can find your available proxy groups in the [proxy dashboard](https://console.apify.com/proxy). +### Subdivision-level geotargeting + +For even more precise geotargeting, you can specify a US state using the `subdivisionCode` option alongside `countryCode`. This feature is currently only supported for the United States (`countryCode: 'US'`). The value must be the two-letter [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2) subdivision code — for example `'CA'` for California or `'NY'` for New York. + +```javascript +const proxyConfiguration = await Actor.createProxyConfiguration({ + groups: ['RESIDENTIAL'], + countryCode: 'US', + subdivisionCode: 'CA', +}); +const proxyUrl = proxyConfiguration.newUrl(); +``` + +`subdivisionCode` requires `countryCode` to be set. + ## Inspecting current proxy in Crawlers `CheerioCrawler` and `PuppeteerCrawler` grant access to information about the currently used proxy From afe4b0e5592d518dd133481108630775befb200c Mon Sep 17 00:00:00 2001 From: Daniil Poletaev Date: Mon, 27 Apr 2026 14:47:59 +0200 Subject: [PATCH 6/7] chore: update docs --- docs/02_concepts/04_proxy_management.mdx | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/docs/02_concepts/04_proxy_management.mdx b/docs/02_concepts/04_proxy_management.mdx index 2f28afbdfa..8a666cbadd 100644 --- a/docs/02_concepts/04_proxy_management.mdx +++ b/docs/02_concepts/04_proxy_management.mdx @@ -142,24 +142,7 @@ The difference is easy to remember.