scrapely 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -1
- package/dist/actor.d.ts +703 -0
- package/dist/actor.js +1441 -0
- package/dist/browser-configuration.d.ts +58 -0
- package/dist/browser-configuration.js +128 -0
- package/dist/browser-locations.d.ts +5 -0
- package/dist/browser-locations.js +5469 -0
- package/dist/charging.d.ts +153 -0
- package/dist/charging.js +782 -0
- package/dist/charging_client.d.ts +18 -0
- package/dist/charging_client.js +49 -0
- package/dist/configuration.d.ts +121 -0
- package/dist/configuration.js +128 -0
- package/dist/crypto/secrets.d.ts +44 -0
- package/dist/crypto/secrets.js +164 -0
- package/dist/events/events-client.d.ts +59 -0
- package/dist/events/events-client.js +120 -0
- package/dist/events/scrapely-event-manager.d.ts +13 -0
- package/dist/events/scrapely-event-manager.js +61 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +95 -0
- package/dist/log-env.d.ts +1 -0
- package/dist/log-env.js +12 -0
- package/dist/manifest.d.ts +30 -0
- package/dist/manifest.js +47 -0
- package/dist/patch-browser-pool.d.ts +66 -0
- package/dist/patch-browser-pool.js +827 -0
- package/dist/proxy-configuration.d.ts +118 -0
- package/dist/proxy-configuration.js +88 -0
- package/dist/request-list.d.ts +57 -0
- package/dist/request-list.js +79 -0
- package/dist/runner/docker-manager.d.ts +88 -0
- package/dist/runner/docker-manager.js +283 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.js +7 -0
- package/dist/runner/timeout-manager.d.ts +43 -0
- package/dist/runner/timeout-manager.js +90 -0
- package/dist/scrapely-proxy-configuration.d.ts +99 -0
- package/dist/scrapely-proxy-configuration.js +559 -0
- package/dist/storage/api-storage-client.d.ts +199 -0
- package/dist/storage/api-storage-client.js +910 -0
- package/dist/storage/billing-tracker.d.ts +163 -0
- package/dist/storage/billing-tracker.js +260 -0
- package/dist/storage/dataset-client.d.ts +43 -0
- package/dist/storage/dataset-client.js +232 -0
- package/dist/storage/dataset-collection-client.d.ts +8 -0
- package/dist/storage/dataset-collection-client.js +75 -0
- package/dist/storage/dataset-push-script.d.ts +18 -0
- package/dist/storage/dataset-push-script.js +38 -0
- package/dist/storage/dragonfly-client.d.ts +94 -0
- package/dist/storage/dragonfly-client.js +167 -0
- package/dist/storage/index.d.ts +11 -0
- package/dist/storage/index.js +25 -0
- package/dist/storage/key-value-store-client.d.ts +61 -0
- package/dist/storage/key-value-store-client.js +306 -0
- package/dist/storage/kv-store-collection-client.d.ts +8 -0
- package/dist/storage/kv-store-collection-client.js +76 -0
- package/dist/storage/kvs-iteration.d.ts +17 -0
- package/dist/storage/kvs-iteration.js +126 -0
- package/dist/storage/log-manager.d.ts +114 -0
- package/dist/storage/log-manager.js +244 -0
- package/dist/storage/patch-storage.d.ts +11 -0
- package/dist/storage/patch-storage.js +177 -0
- package/dist/storage/request-queue-client.d.ts +93 -0
- package/dist/storage/request-queue-client.js +522 -0
- package/dist/storage/rq-collection-client.d.ts +8 -0
- package/dist/storage/rq-collection-client.js +89 -0
- package/dist/storage/rq-list-requests.d.ts +59 -0
- package/dist/storage/rq-list-requests.js +114 -0
- package/dist/storage/rq-lock-script.d.ts +56 -0
- package/dist/storage/rq-lock-script.js +127 -0
- package/dist/storage/run-metadata.d.ts +98 -0
- package/dist/storage/run-metadata.js +256 -0
- package/dist/storage/s3-client.d.ts +103 -0
- package/dist/storage/s3-client.js +302 -0
- package/dist/storage/ttl-manager.d.ts +70 -0
- package/dist/storage/ttl-manager.js +115 -0
- package/dist/storage/utils.d.ts +3 -0
- package/dist/storage/utils.js +19 -0
- package/dist/storage-identifiers.d.ts +20 -0
- package/dist/storage-identifiers.js +6 -0
- package/dist/utils.d.ts +54 -0
- package/dist/utils.js +63 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +158 -0
- package/package.json +90 -9
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ProxyConfiguration = void 0;
|
|
7
|
+
exports.isPlatformProxySentinel = isPlatformProxySentinel;
|
|
8
|
+
exports.validateProxySessionId = validateProxySessionId;
|
|
9
|
+
exports.resolveCountryOptions = resolveCountryOptions;
|
|
10
|
+
exports.buildPlatformProxyUrl = buildPlatformProxyUrl;
|
|
11
|
+
exports.hasPlatformSentinel = hasPlatformSentinel;
|
|
12
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
13
|
+
const node_tls_1 = __importDefault(require("node:tls"));
|
|
14
|
+
const core_1 = require("@crawlee/core");
|
|
15
|
+
const configuration_js_1 = require("./configuration.js");
|
|
16
|
+
const proxy_configuration_js_1 = require("./proxy-configuration.js");
|
|
17
|
+
/** Max length of a proxy session ID (Apify parity). */
|
|
18
|
+
const MAX_SESSION_ID_LENGTH = 50;
|
|
19
|
+
/** Allowed session ID characters (Apify parity): 0-9 a-z A-Z . _ ~ */
|
|
20
|
+
const SESSION_ID_REGEX = /^[a-zA-Z0-9._~]+$/;
|
|
21
|
+
/** Two-letter ISO country code (Apify parity). */
|
|
22
|
+
const COUNTRY_CODE_REGEX = /^[A-Z]{2}$/;
|
|
23
|
+
/**
|
|
24
|
+
* ISO 3166-2 subdivision codes are 1-3 uppercase alphanumeric characters,
|
|
25
|
+
* e.g. 'CA' (California), 'NSW' (New South Wales), '9' (Wien, AT-9)
|
|
26
|
+
* (Apify parity).
|
|
27
|
+
*/
|
|
28
|
+
const SUBDIVISION_CODE_REGEX = /^[A-Z0-9]{1,3}$/;
|
|
29
|
+
/** Status-check timeout / attempts (Apify parity). */
|
|
30
|
+
const CHECK_ACCESS_REQUEST_TIMEOUT_MILLIS = 4_000;
|
|
31
|
+
const CHECK_ACCESS_MAX_ATTEMPTS = 2;
|
|
32
|
+
/**
|
|
33
|
+
* Sentinel values that reserve a slot for the platform proxy inside
|
|
34
|
+
* `proxyUrls` / `tieredProxyUrls` / `tieredProxyConfig[].proxyUrls`,
|
|
35
|
+
* allowing BYOP and platform proxies to be mixed in one configuration
|
|
36
|
+
* (Apify-compatible: both `'auto'` and `'http://auto'` are accepted).
|
|
37
|
+
*/
|
|
38
|
+
const PLATFORM_PROXY_SENTINELS = new Set(['auto', 'http://auto']);
|
|
39
|
+
/**
|
|
40
|
+
* Internal placeholder that occupies a rotation slot on the Crawlee side and is
|
|
41
|
+
* translated to a real platform proxy URL lazily in `newUrl()`. It must be a
|
|
42
|
+
* valid URL so Crawlee's validation accepts it, and it must never collide with
|
|
43
|
+
* a real upstream (the `.invalid` TLD is reserved by RFC 2606).
|
|
44
|
+
*/
|
|
45
|
+
const PLATFORM_PROXY_PLACEHOLDER_HOST = 'scrapely-platform.invalid';
|
|
46
|
+
const PLATFORM_PROXY_PLACEHOLDER = `http://${PLATFORM_PROXY_PLACEHOLDER_HOST}`;
|
|
47
|
+
/**
|
|
48
|
+
* Per-tier placeholder variant so a sentinel inside `tieredProxyConfig` /
|
|
49
|
+
* `tieredProxyUrls` translates with that tier's own groups/countryCode.
|
|
50
|
+
*/
|
|
51
|
+
function platformPlaceholderForTier(tierIndex) {
|
|
52
|
+
return `http://t${tierIndex}.${PLATFORM_PROXY_PLACEHOLDER_HOST}`;
|
|
53
|
+
}
|
|
54
|
+
/** True when a resolved URL is an internal platform-proxy placeholder. */
|
|
55
|
+
function isPlatformPlaceholder(url) {
|
|
56
|
+
return (url === PLATFORM_PROXY_PLACEHOLDER || (url != null && url.endsWith(`.${PLATFORM_PROXY_PLACEHOLDER_HOST}`)));
|
|
57
|
+
}
|
|
58
|
+
/** Returns true for platform-proxy sentinel values ('auto', 'http://auto'). */
|
|
59
|
+
function isPlatformProxySentinel(url) {
|
|
60
|
+
return url != null && PLATFORM_PROXY_SENTINELS.has(url);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validates a proxy session ID the same way the Apify SDK does: at most 50
|
|
64
|
+
* characters, and only `0-9`, `a-z`, `A-Z`, `.`, `_` and `~`. Throws otherwise.
|
|
65
|
+
*/
|
|
66
|
+
function validateProxySessionId(sessionId) {
|
|
67
|
+
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
68
|
+
throw new Error(`Session ID must not be longer than ${MAX_SESSION_ID_LENGTH} characters.`);
|
|
69
|
+
}
|
|
70
|
+
if (!SESSION_ID_REGEX.test(sessionId)) {
|
|
71
|
+
throw new Error(`Session ID "${sessionId}" contains unsupported characters. ` +
|
|
72
|
+
'Only "a-z", "A-Z", "0-9", ".", "_" and "~" are allowed.');
|
|
73
|
+
}
|
|
74
|
+
return sessionId;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolves and validates the country/subdivision options (Apify parity).
|
|
78
|
+
* Country must be a two-letter ISO code; subdivision an ISO 3166-2 code
|
|
79
|
+
* (1-3 uppercase alphanumeric characters) and requires a country. Returns
|
|
80
|
+
* the normalized values used by the username grammar.
|
|
81
|
+
*/
|
|
82
|
+
function resolveCountryOptions(options) {
|
|
83
|
+
const countryCode = options.countryCode ?? options.apifyProxyCountry;
|
|
84
|
+
const subdivisionCode = options.subdivisionCode ?? options.apifyProxySubdivision;
|
|
85
|
+
if (countryCode != null && !COUNTRY_CODE_REGEX.test(countryCode)) {
|
|
86
|
+
throw new Error(`ProxyConfiguration: "countryCode" must be a two-letter ISO country code (got "${countryCode}").`);
|
|
87
|
+
}
|
|
88
|
+
if (subdivisionCode != null) {
|
|
89
|
+
if (!SUBDIVISION_CODE_REGEX.test(subdivisionCode)) {
|
|
90
|
+
throw new Error(`ProxyConfiguration: "subdivisionCode" must be a 1-3 character ISO 3166-2 subdivision code ` +
|
|
91
|
+
`(got "${subdivisionCode}").`);
|
|
92
|
+
}
|
|
93
|
+
if (!countryCode) {
|
|
94
|
+
throw new Error('ProxyConfiguration: "subdivisionCode" requires "countryCode" to be set.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { countryCode, subdivisionCode };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Strips URL-breaking characters from a username fragment. The gateway grammar
|
|
101
|
+
* is comma-based, so fragments themselves must never contain `,@:/?#` or
|
|
102
|
+
* whitespace (Apify behavior: the username is NOT percent-encoded).
|
|
103
|
+
*/
|
|
104
|
+
function sanitizeUsernameFragment(value) {
|
|
105
|
+
return value.replace(/[:@/?#\s]/g, '');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Canonical percent-encoding for proxy URL userinfo (username/password).
|
|
109
|
+
* Matches Python's urllib.parse.quote(safe='') exactly: escapes everything
|
|
110
|
+
* except A-Z a-z 0-9 - _ . ~ — including !'()* which encodeURIComponent
|
|
111
|
+
* leaves unencoded. Both encodings are RFC 3986-valid, but SDKs must emit
|
|
112
|
+
* byte-identical URLs.
|
|
113
|
+
*/
|
|
114
|
+
function encodeUserInfoPart(value) {
|
|
115
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Gateway upstream error codes (590-599, proxy-chain semantics). The gateway
|
|
119
|
+
* uses these instead of a bare 502 to describe upstream failures — the same
|
|
120
|
+
* table Apify Proxy uses. See docs/evomi-integration.md for the contract.
|
|
121
|
+
*/
|
|
122
|
+
const PROXY_STATUS_CODE_DESCRIPTIONS = {
|
|
123
|
+
590: 'Non Successful: upstream responded with a non-200 status code',
|
|
124
|
+
591: 'reserved for further use',
|
|
125
|
+
592: 'Status Code Out Of Range: upstream responded with status code different than 100-999',
|
|
126
|
+
593: 'Not Found: DNS lookup failed (EAI_NODATA or EAI_NONAME)',
|
|
127
|
+
594: 'Connection Refused: upstream refused connection',
|
|
128
|
+
595: 'Connection Reset: connection reset due to loss of connection or timeout',
|
|
129
|
+
596: 'Broken Pipe: trying to write on a closed socket',
|
|
130
|
+
597: 'Auth Failed: incorrect upstream credentials',
|
|
131
|
+
598: 'reserved for further use',
|
|
132
|
+
599: 'Upstream Error: generic upstream error',
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Performs a GET through an HTTP forward proxy (the platform gateway).
|
|
136
|
+
* - http targets: absolute-form request line + `Proxy-Authorization` header
|
|
137
|
+
* - https targets: CONNECT tunnel + TLS + a raw HTTP/1.1 request
|
|
138
|
+
* Network errors reject; HTTP status codes always resolve.
|
|
139
|
+
*/
|
|
140
|
+
async function requestThroughProxy(targetUrl, proxyUrl, timeoutMs) {
|
|
141
|
+
const target = new URL(targetUrl);
|
|
142
|
+
const proxy = new URL(proxyUrl);
|
|
143
|
+
const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
|
|
144
|
+
const proxyAuthorization = `Basic ${Buffer.from(credentials).toString('base64')}`;
|
|
145
|
+
if (target.protocol === 'http:') {
|
|
146
|
+
return await new Promise((resolve, reject) => {
|
|
147
|
+
const req = node_http_1.default.request({
|
|
148
|
+
host: proxy.hostname,
|
|
149
|
+
port: Number(proxy.port) || 80,
|
|
150
|
+
method: 'GET',
|
|
151
|
+
path: targetUrl,
|
|
152
|
+
headers: {
|
|
153
|
+
Host: target.host,
|
|
154
|
+
'Proxy-Authorization': proxyAuthorization,
|
|
155
|
+
Accept: 'application/json',
|
|
156
|
+
},
|
|
157
|
+
setHost: false,
|
|
158
|
+
}, (res) => {
|
|
159
|
+
let body = '';
|
|
160
|
+
res.setEncoding('utf8');
|
|
161
|
+
res.on('data', (chunk) => {
|
|
162
|
+
body += chunk;
|
|
163
|
+
});
|
|
164
|
+
res.on('end', () => resolve({ statusCode: res.statusCode ?? 0, body }));
|
|
165
|
+
});
|
|
166
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`request timed out after ${timeoutMs}ms`)));
|
|
167
|
+
req.on('error', reject);
|
|
168
|
+
req.end();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// HTTPS target: CONNECT tunnel through the gateway, then TLS + raw HTTP/1.1.
|
|
172
|
+
return await new Promise((resolve, reject) => {
|
|
173
|
+
const connectReq = node_http_1.default.request({
|
|
174
|
+
host: proxy.hostname,
|
|
175
|
+
port: Number(proxy.port) || 80,
|
|
176
|
+
method: 'CONNECT',
|
|
177
|
+
path: `${target.hostname}:${target.port || 443}`,
|
|
178
|
+
headers: {
|
|
179
|
+
Host: `${target.hostname}:${target.port || 443}`,
|
|
180
|
+
'Proxy-Authorization': proxyAuthorization,
|
|
181
|
+
},
|
|
182
|
+
setHost: false,
|
|
183
|
+
});
|
|
184
|
+
connectReq.setTimeout(timeoutMs, () => connectReq.destroy(new Error(`CONNECT timed out after ${timeoutMs}ms`)));
|
|
185
|
+
connectReq.on('error', reject);
|
|
186
|
+
connectReq.on('connect', (res, socket) => {
|
|
187
|
+
if (res.statusCode !== 200) {
|
|
188
|
+
socket.destroy();
|
|
189
|
+
resolve({ statusCode: res.statusCode ?? 0, body: '' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const tlsSocket = node_tls_1.default.connect({ socket, servername: target.hostname }, () => {
|
|
193
|
+
tlsSocket.write(`GET ${target.pathname}${target.search} HTTP/1.1\r\n` +
|
|
194
|
+
`Host: ${target.host}\r\n` +
|
|
195
|
+
'Accept: application/json\r\n' +
|
|
196
|
+
'Connection: close\r\n\r\n');
|
|
197
|
+
});
|
|
198
|
+
let raw = '';
|
|
199
|
+
tlsSocket.setEncoding('utf8');
|
|
200
|
+
tlsSocket.on('data', (chunk) => {
|
|
201
|
+
raw += chunk;
|
|
202
|
+
});
|
|
203
|
+
tlsSocket.on('end', () => {
|
|
204
|
+
const separatorIndex = raw.indexOf('\r\n\r\n');
|
|
205
|
+
const head = separatorIndex >= 0 ? raw.slice(0, separatorIndex) : raw;
|
|
206
|
+
const responseBody = separatorIndex >= 0 ? raw.slice(separatorIndex + 4) : '';
|
|
207
|
+
const statusCode = Number.parseInt(head.split(' ')[1] ?? '', 10);
|
|
208
|
+
resolve({ statusCode: Number.isFinite(statusCode) ? statusCode : 0, body: responseBody });
|
|
209
|
+
});
|
|
210
|
+
tlsSocket.on('error', reject);
|
|
211
|
+
});
|
|
212
|
+
connectReq.end();
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Build a platform proxy URL using the Apify username grammar:
|
|
217
|
+
* http://GROUPS-G1+G2,country-US,session-a1b2c3:PASSWORD@HOST:PORT
|
|
218
|
+
*
|
|
219
|
+
* `groups` (joined with `+`), `country` and `session` fragments are
|
|
220
|
+
* comma-joined. When the server injected a subaccount username
|
|
221
|
+
* (SCRAPELY_PROXY_USERNAME / APIFY_PROXY_USERNAME) it is prepended so the
|
|
222
|
+
* gateway can attribute traffic to the right Scrapely tenant; without it the
|
|
223
|
+
* username falls back to Apify's `auto` when no fragments apply. The password
|
|
224
|
+
* and gateway host/port come from the environment credentials injected by the
|
|
225
|
+
* server.
|
|
226
|
+
*/
|
|
227
|
+
function buildPlatformProxyUrl(options, env, sessionId) {
|
|
228
|
+
const groups = options.groups?.length ? options.groups : options.apifyProxyGroups;
|
|
229
|
+
const { countryCode, subdivisionCode } = resolveCountryOptions(options);
|
|
230
|
+
const password = options.password ?? env.password;
|
|
231
|
+
// Apify username grammar: groups, then session, then country — with the
|
|
232
|
+
// subdivision merged into the country fragment (ISO 3166-2: `country-US_CA`).
|
|
233
|
+
const usernameParts = [];
|
|
234
|
+
if (groups?.length) {
|
|
235
|
+
usernameParts.push(`groups-${groups.map(sanitizeUsernameFragment).join('+')}`);
|
|
236
|
+
}
|
|
237
|
+
if (sessionId) {
|
|
238
|
+
usernameParts.push(`session-${sanitizeUsernameFragment(validateProxySessionId(sessionId))}`);
|
|
239
|
+
}
|
|
240
|
+
if (subdivisionCode) {
|
|
241
|
+
usernameParts.push(`country-${sanitizeUsernameFragment(countryCode)}_${sanitizeUsernameFragment(subdivisionCode)}`);
|
|
242
|
+
}
|
|
243
|
+
else if (countryCode) {
|
|
244
|
+
usernameParts.push(`country-${sanitizeUsernameFragment(countryCode)}`);
|
|
245
|
+
}
|
|
246
|
+
const fragments = usernameParts.join(',');
|
|
247
|
+
const username = env.username
|
|
248
|
+
? sanitizeUsernameFragment(env.username) + (fragments ? `,${fragments}` : '')
|
|
249
|
+
: fragments || 'auto';
|
|
250
|
+
return `http://${username}:${encodeUserInfoPart(password)}@${env.hostname}:${env.port}`;
|
|
251
|
+
}
|
|
252
|
+
function urlListHasSentinel(urls) {
|
|
253
|
+
return !!urls?.some((url) => isPlatformProxySentinel(url));
|
|
254
|
+
}
|
|
255
|
+
function replaceSentinelUrls(urls, placeholder = PLATFORM_PROXY_PLACEHOLDER) {
|
|
256
|
+
return urls.map((url) => (isPlatformProxySentinel(url) ? placeholder : url));
|
|
257
|
+
}
|
|
258
|
+
/** True when any proxy URL list reserves a platform slot via the 'auto' sentinel. */
|
|
259
|
+
function hasPlatformSentinel(options) {
|
|
260
|
+
return (urlListHasSentinel(options.proxyUrls) ||
|
|
261
|
+
urlListHasSentinel(options.tieredProxyUrls?.flat()) ||
|
|
262
|
+
!!options.tieredProxyConfig?.some((tier) => urlListHasSentinel(tier.proxyUrls)));
|
|
263
|
+
}
|
|
264
|
+
function urlListHasRealUrl(urls) {
|
|
265
|
+
return !!urls?.some((url) => url != null && !isPlatformProxySentinel(url));
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* True when the configuration contains real (non-sentinel) custom proxies:
|
|
269
|
+
* a BYOP entry in any list or a custom newUrlFunction. Sentinel-only lists do
|
|
270
|
+
* not count — they are platform slots and may legally combine with
|
|
271
|
+
* groups/countryCode.
|
|
272
|
+
*/
|
|
273
|
+
function hasRealCustomProxyOptions(options) {
|
|
274
|
+
return (urlListHasRealUrl(options.proxyUrls) ||
|
|
275
|
+
typeof options.newUrlFunction === 'function' ||
|
|
276
|
+
!!options.tieredProxyUrls?.some((tier) => urlListHasRealUrl(tier)) ||
|
|
277
|
+
!!options.tieredProxyConfig?.some((tier) => urlListHasRealUrl(tier.proxyUrls)));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Maps declarative tiers to URL lists handed to Crawlee. Tiers with explicit
|
|
281
|
+
* `proxyUrls` pass through (sentinels become per-tier placeholders); tiers
|
|
282
|
+
* without any URLs become per-tier placeholders too, so their platform
|
|
283
|
+
* options (groups/countryCode/password) and the session ID are applied
|
|
284
|
+
* lazily in `newUrl()` — mirroring Apify's `_generateTieredProxyUrls` while
|
|
285
|
+
* preserving session stickiness.
|
|
286
|
+
*/
|
|
287
|
+
function mapTieredProxyConfig(tieredProxyConfig, globalOptions, env, placeholderOptions) {
|
|
288
|
+
return tieredProxyConfig.map((tier, index) => {
|
|
289
|
+
if (tier.newUrlFunction) {
|
|
290
|
+
throw new Error('[Scrapely] tieredProxyConfig tiers with newUrlFunction are not supported. Use tieredProxyUrls instead.');
|
|
291
|
+
}
|
|
292
|
+
if (tier.proxyUrls) {
|
|
293
|
+
if (tier.proxyUrls.some((url) => isPlatformProxySentinel(url))) {
|
|
294
|
+
// Register the tier's own platform options so the placeholder
|
|
295
|
+
// translates with this tier's groups/countryCode, not globals.
|
|
296
|
+
placeholderOptions.set(platformPlaceholderForTier(index), { ...globalOptions, ...tier });
|
|
297
|
+
}
|
|
298
|
+
return replaceSentinelUrls(tier.proxyUrls, platformPlaceholderForTier(index));
|
|
299
|
+
}
|
|
300
|
+
if (!env) {
|
|
301
|
+
// Defensive backstop: the constructor already rejects this case with
|
|
302
|
+
// a more detailed message; keep it in case of direct invocation.
|
|
303
|
+
throw new Error('[Scrapely] tieredProxyConfig tiers without proxyUrls require platform proxy credentials ' +
|
|
304
|
+
'(SCRAPELY_PROXY_* or APIFY_PROXY_* env), which were not injected into this environment.');
|
|
305
|
+
}
|
|
306
|
+
// URL-less tiers also become per-tier placeholders so newUrl(sessionId)
|
|
307
|
+
// resolves them lazily with the tier's groups/countryCode AND the
|
|
308
|
+
// session ID — building a concrete URL here would lose session
|
|
309
|
+
// stickiness (the gateway would rotate IPs for repeated sessions).
|
|
310
|
+
placeholderOptions.set(platformPlaceholderForTier(index), { ...globalOptions, ...tier });
|
|
311
|
+
return [platformPlaceholderForTier(index)];
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function toCrawleeOptions(options, env, placeholderOptions) {
|
|
315
|
+
const crawleeOptions = {};
|
|
316
|
+
if (options.proxyUrls) {
|
|
317
|
+
crawleeOptions.proxyUrls = replaceSentinelUrls(options.proxyUrls);
|
|
318
|
+
}
|
|
319
|
+
if (options.newUrlFunction) {
|
|
320
|
+
crawleeOptions.newUrlFunction = options.newUrlFunction;
|
|
321
|
+
}
|
|
322
|
+
if (options.tieredProxyUrls) {
|
|
323
|
+
crawleeOptions.tieredProxyUrls = options.tieredProxyUrls.map((tier, index) => {
|
|
324
|
+
const hasSentinel = tier.some((url) => isPlatformProxySentinel(url));
|
|
325
|
+
if (hasSentinel) {
|
|
326
|
+
placeholderOptions.set(platformPlaceholderForTier(index), options);
|
|
327
|
+
}
|
|
328
|
+
return replaceSentinelUrls(tier, platformPlaceholderForTier(index));
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
else if (options.tieredProxyConfig) {
|
|
332
|
+
crawleeOptions.tieredProxyUrls = mapTieredProxyConfig(options.tieredProxyConfig, options, env, placeholderOptions);
|
|
333
|
+
}
|
|
334
|
+
return crawleeOptions;
|
|
335
|
+
}
|
|
336
|
+
function hasApifyOnlyOptions(options) {
|
|
337
|
+
const groups = options.groups?.length ? options.groups : options.apifyProxyGroups;
|
|
338
|
+
const { countryCode, subdivisionCode } = resolveCountryOptions(options);
|
|
339
|
+
return !!(groups?.length || countryCode || subdivisionCode || options.password);
|
|
340
|
+
}
|
|
341
|
+
function hasCustomProxyOptions(options) {
|
|
342
|
+
return !!((options.proxyUrls && options.proxyUrls.length > 0) ||
|
|
343
|
+
typeof options.newUrlFunction === 'function' ||
|
|
344
|
+
(options.tieredProxyUrls && options.tieredProxyUrls.length > 0) ||
|
|
345
|
+
(options.tieredProxyConfig && options.tieredProxyConfig.length > 0));
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* True when platform URLs will be generated either way (pure platform mode,
|
|
349
|
+
* a sentinel slot, or a declarative tier that expands to platform URLs).
|
|
350
|
+
*/
|
|
351
|
+
function needsPlatformProxyUrls(options) {
|
|
352
|
+
return (!hasCustomProxyOptions(options) ||
|
|
353
|
+
hasPlatformSentinel(options) ||
|
|
354
|
+
!!options.tieredProxyConfig?.some((tier) => !tier.proxyUrls));
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Apify-compatible ProxyConfiguration.
|
|
358
|
+
*
|
|
359
|
+
* Modes:
|
|
360
|
+
* - Platform proxy: when the server injected APIFY_PROXY_* or SCRAPELY_PROXY_*
|
|
361
|
+
* credentials (Evomi-backed) and no custom proxies were requested, the
|
|
362
|
+
* configuration routes through the platform gateway using the Apify
|
|
363
|
+
* username grammar (groups/country/session fragments).
|
|
364
|
+
* - BYOP: custom proxyUrls / newUrlFunction / tieredProxyUrls are passed
|
|
365
|
+
* through to Crawlee unchanged.
|
|
366
|
+
* - Mixed: the 'auto' (or 'http://auto') sentinel inside proxyUrls /
|
|
367
|
+
* tieredProxyUrls / tieredProxyConfig[].proxyUrls reserves a slot for the
|
|
368
|
+
* platform proxy alongside BYOP entries. The sentinel is translated to a
|
|
369
|
+
* concrete platform URL lazily on every newUrl() call, so Crawlee's
|
|
370
|
+
* rotation and session-stickiness semantics apply to the mix unchanged.
|
|
371
|
+
*/
|
|
372
|
+
class ProxyConfiguration extends core_1.ProxyConfiguration {
|
|
373
|
+
config;
|
|
374
|
+
platformEnv;
|
|
375
|
+
platformOptions;
|
|
376
|
+
/** Platform options to use when translating each per-tier placeholder. */
|
|
377
|
+
tierPlaceholderOptions;
|
|
378
|
+
constructor(options = {}, config = configuration_js_1.Configuration.getGlobalConfig()) {
|
|
379
|
+
const { proxyUrls, newUrlFunction } = options;
|
|
380
|
+
const platformEnv = (0, proxy_configuration_js_1.getPlatformProxyEnv)();
|
|
381
|
+
// Validate country/subdivision options eagerly (Apify parity: bad
|
|
382
|
+
// values throw at construction, not on the first newUrl() call).
|
|
383
|
+
resolveCountryOptions(options);
|
|
384
|
+
if (hasRealCustomProxyOptions(options) && hasApifyOnlyOptions(options)) {
|
|
385
|
+
throw new Error('Cannot combine custom proxies with Apify Proxy! ' +
|
|
386
|
+
'It is not allowed to set "options.proxyUrls" or "options.newUrlFunction" combined with ' +
|
|
387
|
+
'"options.groups" or "options.apifyProxyGroups" and "options.countryCode" or ' +
|
|
388
|
+
'"options.apifyProxyCountry", "options.subdivisionCode" or "options.apifyProxySubdivision".');
|
|
389
|
+
}
|
|
390
|
+
if (needsPlatformProxyUrls(options) && !platformEnv) {
|
|
391
|
+
throw new Error('[Scrapely] The platform proxy was requested (via the "auto" sentinel, tieredProxyConfig tiers without ' +
|
|
392
|
+
'proxyUrls, or by relying on the default platform configuration), but no platform proxy credentials ' +
|
|
393
|
+
'(SCRAPELY_PROXY_* or APIFY_PROXY_*) were injected into this environment. ' +
|
|
394
|
+
'The platform proxy is unavailable in local runs — remove the platform proxy entries to run locally, ' +
|
|
395
|
+
'or run the actor on Scrapely with proxy access enabled.');
|
|
396
|
+
}
|
|
397
|
+
// Pure platform proxy mode activates only when credentials were
|
|
398
|
+
// injected AND the user did not bring any proxy list/function at all.
|
|
399
|
+
const purePlatformMode = !!platformEnv && !hasCustomProxyOptions(options);
|
|
400
|
+
const tierPlaceholderOptions = new Map();
|
|
401
|
+
super({
|
|
402
|
+
...toCrawleeOptions(options, platformEnv, tierPlaceholderOptions),
|
|
403
|
+
...(purePlatformMode && platformEnv
|
|
404
|
+
? {
|
|
405
|
+
newUrlFunction: (sessionId) => buildPlatformProxyUrl(options, platformEnv, sessionId != null ? validateProxySessionId(String(sessionId)) : undefined),
|
|
406
|
+
}
|
|
407
|
+
: {}),
|
|
408
|
+
['validateRequired']: false,
|
|
409
|
+
});
|
|
410
|
+
this.config = config;
|
|
411
|
+
this.platformEnv = needsPlatformProxyUrls(options) ? platformEnv : null;
|
|
412
|
+
this.platformOptions = options;
|
|
413
|
+
this.tierPlaceholderOptions = tierPlaceholderOptions;
|
|
414
|
+
if (proxyUrls && newUrlFunction) {
|
|
415
|
+
this._throwCannotCombineCustomMethods();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Returns a new proxy URL. Translates the internal platform-proxy
|
|
420
|
+
* placeholder to a concrete platform URL (applying groups/countryCode and
|
|
421
|
+
* session stickiness) whenever Crawlee's rotation lands on it; BYOP
|
|
422
|
+
* entries pass through unchanged. Session IDs are validated like in the
|
|
423
|
+
* Apify SDK (max 50 chars, only `0-9 a-z A-Z . _ ~`).
|
|
424
|
+
*/
|
|
425
|
+
async newUrl(sessionId, options) {
|
|
426
|
+
const sid = sessionId != null ? validateProxySessionId(String(sessionId)) : undefined;
|
|
427
|
+
const url = await super.newUrl(sid, options);
|
|
428
|
+
const platformEnv = this.platformEnv;
|
|
429
|
+
if (url != null && isPlatformPlaceholder(url) && platformEnv) {
|
|
430
|
+
// Per-tier placeholders translate with the tier's own options;
|
|
431
|
+
// the generic one falls back to the top-level configuration.
|
|
432
|
+
const platformOptions = url === PLATFORM_PROXY_PLACEHOLDER
|
|
433
|
+
? this.platformOptions
|
|
434
|
+
: this.tierPlaceholderOptions.get(url) ?? this.platformOptions;
|
|
435
|
+
return buildPlatformProxyUrl(platformOptions, platformEnv, sid);
|
|
436
|
+
}
|
|
437
|
+
return url;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Returns a new ProxyInfo. When the resolved URL points at the platform
|
|
441
|
+
* gateway, `groups`, `countryCode` and `subdivisionCode` are attached
|
|
442
|
+
* (Apify parity).
|
|
443
|
+
*/
|
|
444
|
+
async newProxyInfo(sessionId, options) {
|
|
445
|
+
const sid = sessionId != null ? validateProxySessionId(String(sessionId)) : undefined;
|
|
446
|
+
const info = await super.newProxyInfo(sid, options);
|
|
447
|
+
if (info && this.platformEnv && info.hostname === this.platformEnv.hostname) {
|
|
448
|
+
const groups = this.platformOptions.groups?.length
|
|
449
|
+
? this.platformOptions.groups
|
|
450
|
+
: this.platformOptions.apifyProxyGroups;
|
|
451
|
+
const { countryCode, subdivisionCode } = resolveCountryOptions(this.platformOptions);
|
|
452
|
+
return {
|
|
453
|
+
...info,
|
|
454
|
+
...(groups?.length ? { groups } : {}),
|
|
455
|
+
...(countryCode ? { countryCode } : {}),
|
|
456
|
+
...(subdivisionCode ? { subdivisionCode } : {}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return info;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Fetches the gateway status payload (Apify format) by sending
|
|
463
|
+
* `GET {statusUrl}/?format=json` *through the platform gateway*, so the
|
|
464
|
+
* proxy handshake itself authenticates the run's credentials. Retries
|
|
465
|
+
* connection errors (Apify parity: 4s timeout, 2 attempts); returns
|
|
466
|
+
* undefined when the endpoint stays unreachable.
|
|
467
|
+
*/
|
|
468
|
+
async fetchProxyStatus() {
|
|
469
|
+
const env = this.platformEnv;
|
|
470
|
+
if (!env?.statusUrl)
|
|
471
|
+
return undefined;
|
|
472
|
+
const statusUrl = env.statusUrl;
|
|
473
|
+
const url = new URL(statusUrl);
|
|
474
|
+
url.searchParams.set('format', 'json');
|
|
475
|
+
const targetUrl = url.toString();
|
|
476
|
+
const proxyUrl = buildPlatformProxyUrl(this.platformOptions, env);
|
|
477
|
+
for (let attempt = 1; attempt <= CHECK_ACCESS_MAX_ATTEMPTS; attempt++) {
|
|
478
|
+
try {
|
|
479
|
+
const { statusCode, body } = await requestThroughProxy(targetUrl, proxyUrl, CHECK_ACCESS_REQUEST_TIMEOUT_MILLIS);
|
|
480
|
+
if (statusCode === 407) {
|
|
481
|
+
return {
|
|
482
|
+
connected: false,
|
|
483
|
+
connectionError: 'Platform proxy authentication failed (HTTP 407). Check the injected ' +
|
|
484
|
+
'SCRAPELY_PROXY_*/APIFY_PROXY_* credentials.',
|
|
485
|
+
isManInTheMiddle: false,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (statusCode >= 590 && statusCode <= 599) {
|
|
489
|
+
const description = PROXY_STATUS_CODE_DESCRIPTIONS[statusCode] ?? 'upstream error';
|
|
490
|
+
return {
|
|
491
|
+
connected: false,
|
|
492
|
+
connectionError: `Platform proxy upstream error (HTTP ${statusCode}: ${description}).`,
|
|
493
|
+
isManInTheMiddle: false,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
497
|
+
try {
|
|
498
|
+
const parsed = JSON.parse(body);
|
|
499
|
+
return {
|
|
500
|
+
connected: parsed.connected === true,
|
|
501
|
+
connectionError: parsed.connectionError ?? '',
|
|
502
|
+
isManInTheMiddle: parsed.isManInTheMiddle === true,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Malformed JSON — fall through and retry.
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// Retry connection errors (Apify behavior).
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Verifies the proxy configuration. For platform proxy mode with an
|
|
518
|
+
* injected status URL, performs the Apify-format access check through the
|
|
519
|
+
* gateway (`GET {statusUrl}/?format=json`, best-effort, never fatal);
|
|
520
|
+
* BYOP mode only verifies that a test URL can be generated.
|
|
521
|
+
*/
|
|
522
|
+
async initialize(options = {}) {
|
|
523
|
+
const { checkAccess = true } = options;
|
|
524
|
+
if (!checkAccess) {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
// Apify-parity access check: GET {statusUrl}/?format=json *through the
|
|
528
|
+
// platform gateway* so the proxy handshake itself authenticates the
|
|
529
|
+
// run's credentials. Best-effort — never hard fails the run.
|
|
530
|
+
if (checkAccess && this.platformEnv?.statusUrl) {
|
|
531
|
+
const status = await this.fetchProxyStatus();
|
|
532
|
+
if (!status) {
|
|
533
|
+
console.warn('[Scrapely] Platform proxy access check timed out. Watch out for errors with status code 407. ' +
|
|
534
|
+
"If you see some, it most likely means you don't have access to either all or some of the proxies you're trying to use.");
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
this.isManInTheMiddle = status.isManInTheMiddle;
|
|
538
|
+
if (!status.connected) {
|
|
539
|
+
console.warn(`[Scrapely] Platform proxy access check failed: ${status.connectionError}`);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
console.log('[Scrapely] Platform proxy access check succeeded.');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const testUrl = await this.newUrl('test.session');
|
|
548
|
+
if (testUrl) {
|
|
549
|
+
console.log('[Scrapely] Proxy configuration initialized. Test proxy URL available.');
|
|
550
|
+
}
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
console.warn(`[Scrapely] Proxy configuration verification failed: ${err.message}`);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
exports.ProxyConfiguration = ProxyConfiguration;
|