scrapely 1.2.18 → 1.2.20
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/dist/actor.d.ts +20 -0
- package/dist/actor.js +40 -2
- package/dist/charging.d.ts +3 -1
- package/dist/charging.js +8 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +17 -2
- package/dist/solver.d.ts +122 -0
- package/dist/solver.js +279 -0
- package/dist/storage/api-storage-client.d.ts +22 -7
- package/dist/storage/api-storage-client.js +125 -25
- package/package.json +4 -2
package/dist/actor.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { type ApiStorageConfig } from './storage/api-storage-client.js';
|
|
|
21
21
|
import { ChargingManager, type ChargeOptions as ChargingChargeOptions, type ChargeResult as ChargingChargeResult } from './charging.js';
|
|
22
22
|
import { createProxyConfiguration, type CreateProxyConfigurationOptions } from './proxy-configuration.js';
|
|
23
23
|
import { type BrowserConfiguration, type BrowserConfigurationOptions } from './browser-configuration.js';
|
|
24
|
+
import { type Clearance, type SolveOptions, type SolverVendorStatus } from './solver.js';
|
|
24
25
|
import type { Dataset, KeyValueStore, RequestQueue } from 'crawlee';
|
|
25
26
|
/** User function passed to {@link Actor.main} (Apify-compatible). */
|
|
26
27
|
export type UserFunc<T = unknown> = () => T | Promise<T>;
|
|
@@ -530,6 +531,25 @@ export declare class Actor {
|
|
|
530
531
|
* @returns The updated ActorRun object, or an empty object if running locally
|
|
531
532
|
*/
|
|
532
533
|
static setStatusMessage(message: string, options?: SetStatusMessageOptions): Promise<ActorRun>;
|
|
534
|
+
/**
|
|
535
|
+
* Anti-bot solver status for a vendor (free, cached for 15 s, never throws).
|
|
536
|
+
* `usable: false` means the vendor cannot be solved right now: take your
|
|
537
|
+
* fallback (for example the hosted browser), or fail the items that need a
|
|
538
|
+
* solve at once and call `setSolverDegradedStatus()`.
|
|
539
|
+
*/
|
|
540
|
+
static solverStatus(vendor: string): Promise<SolverVendorStatus>;
|
|
541
|
+
/**
|
|
542
|
+
* Solves one anti-bot challenge on the Actor's own session through the
|
|
543
|
+
* platform solver. See `solve` in `scrapely` (solver.ts) for the options.
|
|
544
|
+
*/
|
|
545
|
+
static solve(options: SolveOptions): Promise<Clearance>;
|
|
546
|
+
/**
|
|
547
|
+
* Sets the standard status message for a run whose site protection cannot
|
|
548
|
+
* be solved right now ("Site protection temporarily unavailable, results
|
|
549
|
+
* may be incomplete", plus "(N of M items skipped)" when both counts are
|
|
550
|
+
* given). Never throws: the run goes on.
|
|
551
|
+
*/
|
|
552
|
+
static setSolverDegradedStatus(skipped?: number, total?: number): Promise<void>;
|
|
533
553
|
/**
|
|
534
554
|
* Creates a proxy configuration and returns a promise resolving to an instance
|
|
535
555
|
* of the ProxyConfiguration class that is already initialized.
|
package/dist/actor.js
CHANGED
|
@@ -33,6 +33,7 @@ const charging_client_js_1 = require("./charging_client.js");
|
|
|
33
33
|
const proxy_configuration_js_1 = require("./proxy-configuration.js");
|
|
34
34
|
const browser_configuration_js_1 = require("./browser-configuration.js");
|
|
35
35
|
const utils_js_1 = require("./utils.js");
|
|
36
|
+
const solver_js_1 = require("./solver.js");
|
|
36
37
|
/** "429 Run API rate limit exceeded ..." for an API error, the message otherwise. */
|
|
37
38
|
function describeRunApiError(err) {
|
|
38
39
|
const status = err.statusCode;
|
|
@@ -1062,9 +1063,46 @@ class Actor {
|
|
|
1062
1063
|
label: 'PUT status message',
|
|
1063
1064
|
retryWindowMs: options?.retryWindowMs,
|
|
1064
1065
|
}, async (response) => (await response.json().catch(() => ({})))).catch((err) => {
|
|
1065
|
-
|
|
1066
|
+
const error = new Error(`Failed to update status message: ${describeRunApiError(err)}`);
|
|
1067
|
+
// A progress update is best effort: if the API still fails after the
|
|
1068
|
+
// retry window (e.g. a long 503), log it and carry on instead of
|
|
1069
|
+
// crashing the run. Terminal updates keep throwing.
|
|
1070
|
+
if (isStatusMessageTerminal)
|
|
1071
|
+
throw error;
|
|
1072
|
+
console.warn(`[Scrapely] ${error.message} (continuing)`);
|
|
1073
|
+
return undefined;
|
|
1066
1074
|
});
|
|
1067
|
-
return (body
|
|
1075
|
+
return (body?.data ?? {});
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Anti-bot solver status for a vendor (free, cached for 15 s, never throws).
|
|
1079
|
+
* `usable: false` means the vendor cannot be solved right now: take your
|
|
1080
|
+
* fallback (for example the hosted browser), or fail the items that need a
|
|
1081
|
+
* solve at once and call `setSolverDegradedStatus()`.
|
|
1082
|
+
*/
|
|
1083
|
+
static async solverStatus(vendor) {
|
|
1084
|
+
return (0, solver_js_1.solverStatus)(vendor);
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Solves one anti-bot challenge on the Actor's own session through the
|
|
1088
|
+
* platform solver. See `solve` in `scrapely` (solver.ts) for the options.
|
|
1089
|
+
*/
|
|
1090
|
+
static async solve(options) {
|
|
1091
|
+
return (0, solver_js_1.solve)(options);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Sets the standard status message for a run whose site protection cannot
|
|
1095
|
+
* be solved right now ("Site protection temporarily unavailable, results
|
|
1096
|
+
* may be incomplete", plus "(N of M items skipped)" when both counts are
|
|
1097
|
+
* given). Never throws: the run goes on.
|
|
1098
|
+
*/
|
|
1099
|
+
static async setSolverDegradedStatus(skipped, total) {
|
|
1100
|
+
try {
|
|
1101
|
+
await Actor.setStatusMessage((0, solver_js_1.degradedStatusMessage)(skipped, total));
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
console.warn(`Could not set the solver status message: ${err.message}`);
|
|
1105
|
+
}
|
|
1068
1106
|
}
|
|
1069
1107
|
/**
|
|
1070
1108
|
* Creates a proxy configuration and returns a promise resolving to an instance
|
package/dist/charging.d.ts
CHANGED
|
@@ -234,6 +234,8 @@ export declare function pushDataAndCharge<T>({ chargingManager, items, eventName
|
|
|
234
234
|
/**
|
|
235
235
|
* @internal True when a dataset push certainly stored nothing: the storage API
|
|
236
236
|
* answered with an error status, or the request never reached it. Timeouts and
|
|
237
|
-
* resets after the request was sent are not proof (the write may have landed)
|
|
237
|
+
* resets after the request was sent are not proof (the write may have landed),
|
|
238
|
+
* and neither is any error of a push one of whose earlier attempts may have
|
|
239
|
+
* been applied (`outcomeUnknown`, set by the storage client's retries).
|
|
238
240
|
*/
|
|
239
241
|
export declare function pushCertainlyFailed(err: unknown): boolean;
|
package/dist/charging.js
CHANGED
|
@@ -25,7 +25,7 @@ const PENDING_MAX_ENTRIES = 1024;
|
|
|
25
25
|
// so the retries here can honour deadlines: 429, 408, 5xx, timeouts and
|
|
26
26
|
// network errors are re-sent with the same idempotency key, with exponential
|
|
27
27
|
// backoff and jitter, never sooner than the server's Retry-After, within the API
|
|
28
|
-
// retry window (
|
|
28
|
+
// retry window (5 minutes, SCRAPELY_API_RETRY_WINDOW_SECS) or the caller's
|
|
29
29
|
// deadline. Then the charge stays pending / the void is re-queued and retried
|
|
30
30
|
// before the next push and at exit, the same as the Python SDK.
|
|
31
31
|
const POST_ATTEMPTS = 64;
|
|
@@ -1182,9 +1182,15 @@ const NOT_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOST
|
|
|
1182
1182
|
/**
|
|
1183
1183
|
* @internal True when a dataset push certainly stored nothing: the storage API
|
|
1184
1184
|
* answered with an error status, or the request never reached it. Timeouts and
|
|
1185
|
-
* resets after the request was sent are not proof (the write may have landed)
|
|
1185
|
+
* resets after the request was sent are not proof (the write may have landed),
|
|
1186
|
+
* and neither is any error of a push one of whose earlier attempts may have
|
|
1187
|
+
* been applied (`outcomeUnknown`, set by the storage client's retries).
|
|
1186
1188
|
*/
|
|
1187
1189
|
function pushCertainlyFailed(err) {
|
|
1190
|
+
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
|
|
1191
|
+
if (e.outcomeUnknown === true)
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1188
1194
|
if ((0, api_storage_client_js_1.isConnectFailure)(err))
|
|
1189
1195
|
return true;
|
|
1190
1196
|
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { Actor, ACTOR_PERMISSION_LEVEL, type ActorInput, type InitOptions, type
|
|
|
4
4
|
export type { StorageAlias, StorageId, StorageName, StorageIdentifier, StorageIdentifierWithoutAlias, } from './storage-identifiers.js';
|
|
5
5
|
export { ApifyClient, type ApifyClientOptions, type ActorRun } from 'scrapely-client';
|
|
6
6
|
export { Configuration, type ConfigurationOptions } from './configuration.js';
|
|
7
|
+
export { DEGRADED_STATUS_MESSAGE, IpBanned, SolveAttemptError, SolverAuthError, SolverRateLimited, VendorUnavailable, degradedStatusMessage, detectVendor, isBlocked, reportSolveOutcome, solve, solverConfigured, solverStatus, stepAllowed, type Clearance, type SolveCookie, type SolveOptions, type SolverStepRequest, type SolverVendorStatus, } from './solver.js';
|
|
7
8
|
export { ScrapelyEventManager as PlatformEventManager } from './events/scrapely-event-manager.js';
|
|
8
9
|
export { ChargingManager, ChargeUncertainError, ChargeRejectedError, DEFAULT_DATASET_ITEM_EVENT, APIFY_DEFAULT_DATASET_ITEM_EVENT, SCRAPELY_DEFAULT_DATASET_ITEM_EVENT, hasDefaultDatasetItemEventInPricing, isDefaultDatasetItemEvent, resolveDefaultDatasetItemEventName, mergeChargeResults, pushDataAndCharge, type ChargeOptions, type ChargeResult, type ActorPricingInfo, } from './charging.js';
|
|
9
10
|
export { createChargingClient, pushDataChargingContext, USES_PUSH_DATA_INTERCEPTION, type PpeAwarePushDataContext, } from './charging_client.js';
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.puppeteerUtils = exports.playwrightUtils = void 0;
|
|
3
|
+
exports.LogLevel = exports.Log = exports.log = exports.RequestQueue = exports.KeyValueStore = exports.Dataset = exports.createBrowserConfiguration = exports.BrowserConfiguration = exports.getPlatformProxyEnv = exports.createProxyConfiguration = exports.validateProxySessionId = exports.isPlatformProxySentinel = exports.ProxyConfiguration = exports.ApiStorageClientFactory = exports.BillingTracker = exports.USES_PUSH_DATA_INTERCEPTION = exports.pushDataChargingContext = exports.createChargingClient = exports.pushDataAndCharge = exports.mergeChargeResults = exports.resolveDefaultDatasetItemEventName = exports.isDefaultDatasetItemEvent = exports.hasDefaultDatasetItemEventInPricing = exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = exports.DEFAULT_DATASET_ITEM_EVENT = exports.ChargeRejectedError = exports.ChargeUncertainError = exports.ChargingManager = exports.PlatformEventManager = exports.stepAllowed = exports.solverStatus = exports.solverConfigured = exports.solve = exports.reportSolveOutcome = exports.isBlocked = exports.detectVendor = exports.degradedStatusMessage = exports.VendorUnavailable = exports.SolverRateLimited = exports.SolverAuthError = exports.SolveAttemptError = exports.IpBanned = exports.DEGRADED_STATUS_MESSAGE = exports.Configuration = exports.ApifyClient = exports.EXIT_CODES = exports.ACTOR_PERMISSION_LEVEL = exports.Actor = exports.setCdpUrl = void 0;
|
|
4
|
+
exports.puppeteerUtils = exports.playwrightUtils = exports.LinkeDOMCrawler = exports.JSDOMCrawler = exports.BrowserCrawler = exports.BasicCrawler = exports.HttpCrawler = exports.CheerioCrawler = exports.PuppeteerCrawler = exports.PlaywrightCrawler = exports.ScrapelyRequestList = exports.enqueueLinks = exports.utils = exports.LoggerText = exports.LoggerJson = exports.Logger = void 0;
|
|
5
5
|
require("./log-env.js");
|
|
6
6
|
var patch_browser_pool_js_1 = require("./patch-browser-pool.js");
|
|
7
7
|
Object.defineProperty(exports, "setCdpUrl", { enumerable: true, get: function () { return patch_browser_pool_js_1.setCdpUrl; } });
|
|
@@ -14,6 +14,21 @@ var scrapely_client_1 = require("scrapely-client");
|
|
|
14
14
|
Object.defineProperty(exports, "ApifyClient", { enumerable: true, get: function () { return scrapely_client_1.ApifyClient; } });
|
|
15
15
|
var configuration_js_1 = require("./configuration.js");
|
|
16
16
|
Object.defineProperty(exports, "Configuration", { enumerable: true, get: function () { return configuration_js_1.Configuration; } });
|
|
17
|
+
var solver_js_1 = require("./solver.js");
|
|
18
|
+
Object.defineProperty(exports, "DEGRADED_STATUS_MESSAGE", { enumerable: true, get: function () { return solver_js_1.DEGRADED_STATUS_MESSAGE; } });
|
|
19
|
+
Object.defineProperty(exports, "IpBanned", { enumerable: true, get: function () { return solver_js_1.IpBanned; } });
|
|
20
|
+
Object.defineProperty(exports, "SolveAttemptError", { enumerable: true, get: function () { return solver_js_1.SolveAttemptError; } });
|
|
21
|
+
Object.defineProperty(exports, "SolverAuthError", { enumerable: true, get: function () { return solver_js_1.SolverAuthError; } });
|
|
22
|
+
Object.defineProperty(exports, "SolverRateLimited", { enumerable: true, get: function () { return solver_js_1.SolverRateLimited; } });
|
|
23
|
+
Object.defineProperty(exports, "VendorUnavailable", { enumerable: true, get: function () { return solver_js_1.VendorUnavailable; } });
|
|
24
|
+
Object.defineProperty(exports, "degradedStatusMessage", { enumerable: true, get: function () { return solver_js_1.degradedStatusMessage; } });
|
|
25
|
+
Object.defineProperty(exports, "detectVendor", { enumerable: true, get: function () { return solver_js_1.detectVendor; } });
|
|
26
|
+
Object.defineProperty(exports, "isBlocked", { enumerable: true, get: function () { return solver_js_1.isBlocked; } });
|
|
27
|
+
Object.defineProperty(exports, "reportSolveOutcome", { enumerable: true, get: function () { return solver_js_1.reportSolveOutcome; } });
|
|
28
|
+
Object.defineProperty(exports, "solve", { enumerable: true, get: function () { return solver_js_1.solve; } });
|
|
29
|
+
Object.defineProperty(exports, "solverConfigured", { enumerable: true, get: function () { return solver_js_1.solverConfigured; } });
|
|
30
|
+
Object.defineProperty(exports, "solverStatus", { enumerable: true, get: function () { return solver_js_1.solverStatus; } });
|
|
31
|
+
Object.defineProperty(exports, "stepAllowed", { enumerable: true, get: function () { return solver_js_1.stepAllowed; } });
|
|
17
32
|
var scrapely_event_manager_js_1 = require("./events/scrapely-event-manager.js");
|
|
18
33
|
Object.defineProperty(exports, "PlatformEventManager", { enumerable: true, get: function () { return scrapely_event_manager_js_1.ScrapelyEventManager; } });
|
|
19
34
|
var charging_js_1 = require("./charging.js");
|
package/dist/solver.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-bot solver helpers (JS). Python parity: scrapely-python `scrapely.solver`.
|
|
3
|
+
*
|
|
4
|
+
* The platform runs the challenge solver behind /v2/solver; Actors never
|
|
5
|
+
* hold a solver key. Runs of Actors that may use it get SCRAPELY_SOLVER_URL
|
|
6
|
+
* and SCRAPELY_SOLVER_API_KEY (the run's own token).
|
|
7
|
+
*
|
|
8
|
+
* const st = await Actor.solverStatus('datadome'); // free, cached 15 s
|
|
9
|
+
* if (!st.usable) { ...fallback or fail the item fast... }
|
|
10
|
+
*
|
|
11
|
+
* const clearance = await Actor.solve({
|
|
12
|
+
* url, response: { status, body }, // the blocked response
|
|
13
|
+
* fetch: (req) => mySession(req), // send step requests on YOUR session
|
|
14
|
+
* sessionId: 's-42', identity: 'chrome-146-macos',
|
|
15
|
+
* });
|
|
16
|
+
* // clearance.cookies -> put them in your cookie jar (fetch already sent the rest)
|
|
17
|
+
*
|
|
18
|
+
* Errors: SolveAttemptError (retry on a fresh exit), IpBanned, VendorUnavailable
|
|
19
|
+
* (fail fast, `code`), SolverRateLimited, SolverAuthError.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEGRADED_STATUS_MESSAGE = "Site protection temporarily unavailable, results may be incomplete";
|
|
22
|
+
export declare class SolveAttemptError extends Error {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
export declare class IpBanned extends SolveAttemptError {
|
|
26
|
+
constructor(message: string);
|
|
27
|
+
}
|
|
28
|
+
export declare class SolverRateLimited extends SolveAttemptError {
|
|
29
|
+
readonly retryAfterSecs: number | null;
|
|
30
|
+
constructor(retryAfterSecs?: number | null);
|
|
31
|
+
}
|
|
32
|
+
export declare class SolverAuthError extends SolveAttemptError {
|
|
33
|
+
constructor(message: string);
|
|
34
|
+
}
|
|
35
|
+
/** The platform says this vendor cannot be solved right now. Fail fast; do not retry. */
|
|
36
|
+
export declare class VendorUnavailable extends Error {
|
|
37
|
+
readonly code: string;
|
|
38
|
+
readonly vendor: string | null;
|
|
39
|
+
readonly retryAfterSecs: number | null;
|
|
40
|
+
constructor(code: string, vendor?: string | null, retryAfterSecs?: number | null, message?: string);
|
|
41
|
+
}
|
|
42
|
+
export interface SolverVendorStatus {
|
|
43
|
+
status: 'healthy' | 'degraded' | 'down' | 'unsupported' | 'unknown';
|
|
44
|
+
usable: boolean;
|
|
45
|
+
code: string | null;
|
|
46
|
+
successRate30m: number | null;
|
|
47
|
+
since: string | null;
|
|
48
|
+
recheckAfterSecs: number;
|
|
49
|
+
}
|
|
50
|
+
export interface SolverStepRequest {
|
|
51
|
+
id: string;
|
|
52
|
+
method: 'GET' | 'POST';
|
|
53
|
+
url: string;
|
|
54
|
+
headers: Record<string, string>;
|
|
55
|
+
body?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface SolveCookie {
|
|
58
|
+
name: string;
|
|
59
|
+
value: string;
|
|
60
|
+
domain: string;
|
|
61
|
+
path: string;
|
|
62
|
+
}
|
|
63
|
+
export interface SolveOptions {
|
|
64
|
+
/** The page that was blocked. */
|
|
65
|
+
url: string;
|
|
66
|
+
/** The blocked response. */
|
|
67
|
+
response: {
|
|
68
|
+
status: number;
|
|
69
|
+
body: string;
|
|
70
|
+
};
|
|
71
|
+
/** Sends one step request on the Actor's own session (same exit IP); returns its status and body. */
|
|
72
|
+
fetch: (request: SolverStepRequest) => Promise<{
|
|
73
|
+
status: number;
|
|
74
|
+
body: string;
|
|
75
|
+
}>;
|
|
76
|
+
/** 'datadome', 'akamai-sbsd' or 'auto' (default). */
|
|
77
|
+
vendor?: string;
|
|
78
|
+
/** Proxy session id (enables the platform's clearance cache for that exit). */
|
|
79
|
+
sessionId?: string;
|
|
80
|
+
/** Browser profile the TLS impersonation claims (default chrome-146-macos), or an object with locale. */
|
|
81
|
+
identity?: string | {
|
|
82
|
+
profile?: string;
|
|
83
|
+
acceptLanguage?: string;
|
|
84
|
+
timezone?: string;
|
|
85
|
+
languages?: string;
|
|
86
|
+
};
|
|
87
|
+
/** Cookie header the session holds for the site (SBSD needs it). */
|
|
88
|
+
cookieHeader?: () => string | null | Promise<string | null>;
|
|
89
|
+
/** Verifies the clearance (cookies already applied by you or by `fetch`); reported to the platform. */
|
|
90
|
+
verify?: (cookies: SolveCookie[]) => Promise<boolean> | boolean;
|
|
91
|
+
timeoutMs?: number;
|
|
92
|
+
}
|
|
93
|
+
export interface Clearance {
|
|
94
|
+
solveId: string | null;
|
|
95
|
+
cookies: SolveCookie[];
|
|
96
|
+
ttlHintSecs: number;
|
|
97
|
+
cached: boolean;
|
|
98
|
+
verified: boolean | null;
|
|
99
|
+
}
|
|
100
|
+
/** True when the platform solver API is available to this run. */
|
|
101
|
+
export declare function solverConfigured(): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* The protection behind a *challenge* response. Ordinary pages that merely load a
|
|
104
|
+
* vendor script are not challenges: DataDome, PerimeterX and Kasada need a 4xx
|
|
105
|
+
* status; an SBSD bundle counts only on a short interstitial page.
|
|
106
|
+
*/
|
|
107
|
+
export declare function detectVendor(status: number, body: string): string | null;
|
|
108
|
+
/** A step may only go to the target page's own host or to captcha-delivery.com, over https. */
|
|
109
|
+
export declare function stepAllowed(stepUrl: string, targetUrl: string): boolean;
|
|
110
|
+
export declare function isBlocked(response: {
|
|
111
|
+
status: number;
|
|
112
|
+
body: string;
|
|
113
|
+
}, vendor?: string): boolean;
|
|
114
|
+
export declare function degradedStatusMessage(skipped?: number, total?: number): string;
|
|
115
|
+
/** Vendor status from the platform (free, cached for 15 s). Never throws. */
|
|
116
|
+
export declare function solverStatus(vendor: string): Promise<SolverVendorStatus>;
|
|
117
|
+
/** Solves one challenge on the Actor's session through the platform. */
|
|
118
|
+
export declare function solve(options: SolveOptions): Promise<Clearance>;
|
|
119
|
+
/** Reports your own verification of a clearance (when `verify` was not passed to solve). */
|
|
120
|
+
export declare function reportSolveOutcome(clearance: Clearance, verified: boolean): Promise<void>;
|
|
121
|
+
/** Test hook. */
|
|
122
|
+
export declare function clearSolverStatusCache(): void;
|
package/dist/solver.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Anti-bot solver helpers (JS). Python parity: scrapely-python `scrapely.solver`.
|
|
4
|
+
*
|
|
5
|
+
* The platform runs the challenge solver behind /v2/solver; Actors never
|
|
6
|
+
* hold a solver key. Runs of Actors that may use it get SCRAPELY_SOLVER_URL
|
|
7
|
+
* and SCRAPELY_SOLVER_API_KEY (the run's own token).
|
|
8
|
+
*
|
|
9
|
+
* const st = await Actor.solverStatus('datadome'); // free, cached 15 s
|
|
10
|
+
* if (!st.usable) { ...fallback or fail the item fast... }
|
|
11
|
+
*
|
|
12
|
+
* const clearance = await Actor.solve({
|
|
13
|
+
* url, response: { status, body }, // the blocked response
|
|
14
|
+
* fetch: (req) => mySession(req), // send step requests on YOUR session
|
|
15
|
+
* sessionId: 's-42', identity: 'chrome-146-macos',
|
|
16
|
+
* });
|
|
17
|
+
* // clearance.cookies -> put them in your cookie jar (fetch already sent the rest)
|
|
18
|
+
*
|
|
19
|
+
* Errors: SolveAttemptError (retry on a fresh exit), IpBanned, VendorUnavailable
|
|
20
|
+
* (fail fast, `code`), SolverRateLimited, SolverAuthError.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.VendorUnavailable = exports.SolverAuthError = exports.SolverRateLimited = exports.IpBanned = exports.SolveAttemptError = exports.DEGRADED_STATUS_MESSAGE = void 0;
|
|
24
|
+
exports.solverConfigured = solverConfigured;
|
|
25
|
+
exports.detectVendor = detectVendor;
|
|
26
|
+
exports.stepAllowed = stepAllowed;
|
|
27
|
+
exports.isBlocked = isBlocked;
|
|
28
|
+
exports.degradedStatusMessage = degradedStatusMessage;
|
|
29
|
+
exports.solverStatus = solverStatus;
|
|
30
|
+
exports.solve = solve;
|
|
31
|
+
exports.reportSolveOutcome = reportSolveOutcome;
|
|
32
|
+
exports.clearSolverStatusCache = clearSolverStatusCache;
|
|
33
|
+
exports.DEGRADED_STATUS_MESSAGE = 'Site protection temporarily unavailable, results may be incomplete';
|
|
34
|
+
const COMPAT_SUFFIX = '/compat/xhrdev';
|
|
35
|
+
const STATUS_TTL_MS = 15_000;
|
|
36
|
+
const MAX_BODY = 4 * 1024 * 1024;
|
|
37
|
+
const UNAVAILABLE_CODES = new Set(['vendor_degraded', 'vendor_down', 'provider_unavailable', 'vendor_unsupported', 'solver_not_enabled']);
|
|
38
|
+
class SolveAttemptError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'SolveAttemptError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.SolveAttemptError = SolveAttemptError;
|
|
45
|
+
class IpBanned extends SolveAttemptError {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'IpBanned';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
exports.IpBanned = IpBanned;
|
|
52
|
+
class SolverRateLimited extends SolveAttemptError {
|
|
53
|
+
retryAfterSecs;
|
|
54
|
+
constructor(retryAfterSecs = null) {
|
|
55
|
+
super(`solver rate limit hit (HTTP 429${retryAfterSecs !== null ? `, retry after ${retryAfterSecs}s` : ''})`);
|
|
56
|
+
this.retryAfterSecs = retryAfterSecs;
|
|
57
|
+
this.name = 'SolverRateLimited';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.SolverRateLimited = SolverRateLimited;
|
|
61
|
+
class SolverAuthError extends SolveAttemptError {
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'SolverAuthError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.SolverAuthError = SolverAuthError;
|
|
68
|
+
/** The platform says this vendor cannot be solved right now. Fail fast; do not retry. */
|
|
69
|
+
class VendorUnavailable extends Error {
|
|
70
|
+
code;
|
|
71
|
+
vendor;
|
|
72
|
+
retryAfterSecs;
|
|
73
|
+
constructor(code, vendor = null, retryAfterSecs = null, message) {
|
|
74
|
+
super(message ?? `anti-bot solver unavailable (${code}${vendor ? `, ${vendor}` : ''})`);
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.vendor = vendor;
|
|
77
|
+
this.retryAfterSecs = retryAfterSecs;
|
|
78
|
+
this.name = 'VendorUnavailable';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.VendorUnavailable = VendorUnavailable;
|
|
82
|
+
function solverEnv() {
|
|
83
|
+
const url = (process.env.SCRAPELY_SOLVER_URL ?? '').trim().replace(/\/+$/, '');
|
|
84
|
+
const key = (process.env.SCRAPELY_SOLVER_API_KEY ?? '').trim();
|
|
85
|
+
return url && key ? { url, key } : null;
|
|
86
|
+
}
|
|
87
|
+
function gateway() {
|
|
88
|
+
const env = solverEnv();
|
|
89
|
+
if (!env || !env.url.endsWith(COMPAT_SUFFIX))
|
|
90
|
+
return null;
|
|
91
|
+
return { base: env.url.slice(0, -COMPAT_SUFFIX.length), key: env.key };
|
|
92
|
+
}
|
|
93
|
+
/** True when the platform solver API is available to this run. */
|
|
94
|
+
function solverConfigured() {
|
|
95
|
+
return gateway() !== null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The protection behind a *challenge* response. Ordinary pages that merely load a
|
|
99
|
+
* vendor script are not challenges: DataDome, PerimeterX and Kasada need a 4xx
|
|
100
|
+
* status; an SBSD bundle counts only on a short interstitial page.
|
|
101
|
+
*/
|
|
102
|
+
function detectVendor(status, body) {
|
|
103
|
+
const all = body || '';
|
|
104
|
+
const sample = all.slice(0, 20000);
|
|
105
|
+
const head = sample.slice(0, 4000).toLowerCase();
|
|
106
|
+
const challenged = status >= 400;
|
|
107
|
+
if (challenged && (head.includes('captcha-delivery.com') || /var\s+dd\s*=\s*\{/.test(sample) || head.includes('datadome')))
|
|
108
|
+
return 'datadome';
|
|
109
|
+
if (all.length <= 20000 && /<script[^>]+src="([^"]+\?v=[0-9a-f-]{36}[^"]*)"/i.test(sample))
|
|
110
|
+
return 'akamai-sbsd';
|
|
111
|
+
if (challenged && /_pxappid|px-captcha|perimeterx|\/_px\d?\//i.test(sample))
|
|
112
|
+
return 'perimeterx';
|
|
113
|
+
if (challenged && /kpsdk/i.test(sample))
|
|
114
|
+
return 'kasada';
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** A step may only go to the target page's own host or to captcha-delivery.com, over https. */
|
|
118
|
+
function stepAllowed(stepUrl, targetUrl) {
|
|
119
|
+
try {
|
|
120
|
+
const step = new URL(stepUrl);
|
|
121
|
+
const target = new URL(targetUrl);
|
|
122
|
+
if (step.protocol !== 'https:')
|
|
123
|
+
return false;
|
|
124
|
+
const host = step.hostname.toLowerCase();
|
|
125
|
+
return host === target.hostname.toLowerCase() || host === 'captcha-delivery.com' || host.endsWith('.captcha-delivery.com');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function isBlocked(response, vendor) {
|
|
132
|
+
const found = detectVendor(response.status, response.body);
|
|
133
|
+
return !vendor || vendor === 'auto' ? found !== null : found === vendor;
|
|
134
|
+
}
|
|
135
|
+
function degradedStatusMessage(skipped, total) {
|
|
136
|
+
return skipped !== undefined && total !== undefined ? `${exports.DEGRADED_STATUS_MESSAGE} (${skipped} of ${total} items skipped)` : exports.DEGRADED_STATUS_MESSAGE;
|
|
137
|
+
}
|
|
138
|
+
const statusCache = new Map();
|
|
139
|
+
function unknownStatus(code = null) {
|
|
140
|
+
return { status: 'unknown', usable: code === null, code, successRate30m: null, since: null, recheckAfterSecs: 300 };
|
|
141
|
+
}
|
|
142
|
+
/** Vendor status from the platform (free, cached for 15 s). Never throws. */
|
|
143
|
+
async function solverStatus(vendor) {
|
|
144
|
+
const name = (vendor || '').trim().toLowerCase();
|
|
145
|
+
const hit = statusCache.get(name);
|
|
146
|
+
if (hit && Date.now() - hit.at < STATUS_TTL_MS)
|
|
147
|
+
return hit.value;
|
|
148
|
+
const gw = gateway();
|
|
149
|
+
if (!gw)
|
|
150
|
+
return unknownStatus(solverEnv() ? null : 'solver_not_enabled');
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(`${gw.base}/status?vendors=${encodeURIComponent(name)}`, {
|
|
153
|
+
headers: { authorization: `Bearer ${gw.key}` },
|
|
154
|
+
signal: AbortSignal.timeout(10_000),
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok)
|
|
157
|
+
return unknownStatus();
|
|
158
|
+
const data = (await res.json());
|
|
159
|
+
const value = data.vendors?.[name] ?? unknownStatus();
|
|
160
|
+
statusCache.set(name, { at: Date.now(), value });
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return unknownStatus();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function api(path, body, key, base, timeoutMs) {
|
|
168
|
+
let res;
|
|
169
|
+
try {
|
|
170
|
+
res = await fetch(`${base}${path}`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
173
|
+
body: JSON.stringify(body),
|
|
174
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
// Gateway unreachable or timed out: one failed try, retried on a fresh session.
|
|
179
|
+
throw new SolveAttemptError(`solver gateway unreachable: ${err.name}`);
|
|
180
|
+
}
|
|
181
|
+
let data = {};
|
|
182
|
+
try {
|
|
183
|
+
data = await res.json();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
data = {};
|
|
187
|
+
}
|
|
188
|
+
return { status: res.status, data, retryAfter: res.headers.get('retry-after') };
|
|
189
|
+
}
|
|
190
|
+
function handle(r, vendor) {
|
|
191
|
+
const { status, data } = r;
|
|
192
|
+
if (status === 503 && UNAVAILABLE_CODES.has(String(data.code ?? '')))
|
|
193
|
+
throw new VendorUnavailable(String(data.code), data.vendor ?? vendor, data.retryAfterSecs ?? null);
|
|
194
|
+
if (status === 429)
|
|
195
|
+
throw new SolverRateLimited(r.retryAfter ? Number(r.retryAfter) : (data.retryAfterSecs ?? null));
|
|
196
|
+
if (status === 401 || status === 403) {
|
|
197
|
+
if (data.code === 'solver_not_enabled')
|
|
198
|
+
throw new VendorUnavailable('solver_not_enabled', vendor, null, 'the anti-bot solver is not enabled for this Actor');
|
|
199
|
+
throw new SolverAuthError(`solver refused the run token (HTTP ${status})`);
|
|
200
|
+
}
|
|
201
|
+
if (status >= 400)
|
|
202
|
+
throw new SolveAttemptError(`solver HTTP ${status}: ${data.message ?? ''}`.trim());
|
|
203
|
+
return data;
|
|
204
|
+
}
|
|
205
|
+
/** Solves one challenge on the Actor's session through the platform. */
|
|
206
|
+
async function solve(options) {
|
|
207
|
+
const gw = gateway();
|
|
208
|
+
const vendor = options.vendor ?? 'auto';
|
|
209
|
+
if (!gw)
|
|
210
|
+
throw new VendorUnavailable('solver_not_enabled', vendor, null, 'the platform solver is not available to this run');
|
|
211
|
+
const timeoutMs = (options.timeoutMs ?? 60_000) + 30_000;
|
|
212
|
+
const identity = typeof options.identity === 'string' ? { profile: options.identity } : (options.identity ?? { profile: 'chrome-146-macos' });
|
|
213
|
+
const cookieHeader = async () => (options.cookieHeader ? await options.cookieHeader() : null);
|
|
214
|
+
let data = handle(await api('/solve', {
|
|
215
|
+
vendor,
|
|
216
|
+
url: options.url,
|
|
217
|
+
blocked: { status: options.response.status, body: (options.response.body ?? '').slice(0, MAX_BODY) },
|
|
218
|
+
identity,
|
|
219
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
220
|
+
session: { id: options.sessionId, cookieHeader: await cookieHeader() },
|
|
221
|
+
}, gw.key, gw.base, timeoutMs), vendor);
|
|
222
|
+
for (let step = 0; step < 8; step++) {
|
|
223
|
+
if (data.action === 'done') {
|
|
224
|
+
const clearance = {
|
|
225
|
+
solveId: data.solveId ?? null,
|
|
226
|
+
cookies: data.cookies ?? [],
|
|
227
|
+
ttlHintSecs: Number(data.ttlHintSecs) || 0,
|
|
228
|
+
cached: !!data.cached,
|
|
229
|
+
verified: null,
|
|
230
|
+
};
|
|
231
|
+
if (options.verify) {
|
|
232
|
+
const ok = !!(await options.verify(clearance.cookies));
|
|
233
|
+
clearance.verified = ok;
|
|
234
|
+
if (clearance.solveId) {
|
|
235
|
+
await api(`/solves/${clearance.solveId}/outcome`, { verified: ok }, gw.key, gw.base, 15_000).catch(() => undefined);
|
|
236
|
+
}
|
|
237
|
+
if (!ok)
|
|
238
|
+
throw new SolveAttemptError('still blocked after the clearance was applied');
|
|
239
|
+
}
|
|
240
|
+
return clearance;
|
|
241
|
+
}
|
|
242
|
+
if (data.action === 'failed') {
|
|
243
|
+
const message = String(data.message ?? data.reason ?? 'solve failed');
|
|
244
|
+
if (data.reason === 'ip_banned')
|
|
245
|
+
throw new IpBanned(message);
|
|
246
|
+
throw new SolveAttemptError(message);
|
|
247
|
+
}
|
|
248
|
+
if (data.action !== 'fetch' && data.action !== 'submit')
|
|
249
|
+
throw new SolveAttemptError(`unexpected solver answer: ${data.action}`);
|
|
250
|
+
const results = [];
|
|
251
|
+
for (const req of (data.requests ?? [])) {
|
|
252
|
+
if (!req || !stepAllowed(String(req.url ?? ''), options.url) || (req.method !== 'GET' && req.method !== 'POST')) {
|
|
253
|
+
throw new SolveAttemptError('the solver asked for a request outside the target site; refused');
|
|
254
|
+
}
|
|
255
|
+
let r;
|
|
256
|
+
try {
|
|
257
|
+
r = await options.fetch(req);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
throw new SolveAttemptError(`challenge request failed: ${err.name}`);
|
|
261
|
+
}
|
|
262
|
+
results.push({ id: req.id, status: r.status, body: (r.body ?? '').slice(0, MAX_BODY) });
|
|
263
|
+
}
|
|
264
|
+
data = handle(await api('/solve', { solveId: data.solveId, results, session: { id: options.sessionId, cookieHeader: await cookieHeader() } }, gw.key, gw.base, timeoutMs), vendor);
|
|
265
|
+
}
|
|
266
|
+
throw new SolveAttemptError('the solve did not finish');
|
|
267
|
+
}
|
|
268
|
+
/** Reports your own verification of a clearance (when `verify` was not passed to solve). */
|
|
269
|
+
async function reportSolveOutcome(clearance, verified) {
|
|
270
|
+
const gw = gateway();
|
|
271
|
+
if (!gw || !clearance.solveId)
|
|
272
|
+
return;
|
|
273
|
+
clearance.verified = verified;
|
|
274
|
+
await api(`/solves/${clearance.solveId}/outcome`, { verified }, gw.key, gw.base, 15_000).catch(() => undefined);
|
|
275
|
+
}
|
|
276
|
+
/** Test hook. */
|
|
277
|
+
function clearSolverStatusCache() {
|
|
278
|
+
statusCache.clear();
|
|
279
|
+
}
|
|
@@ -24,11 +24,15 @@ export declare class ApiStorageClientError extends Error {
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
|
|
26
26
|
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
27
|
-
export declare const DEFAULT_API_RETRY_WINDOW_MS =
|
|
27
|
+
export declare const DEFAULT_API_RETRY_WINDOW_MS = 300000;
|
|
28
|
+
/** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
|
|
29
|
+
export declare function apiDedupesKeyedWrites(url?: string): boolean;
|
|
30
|
+
/** Test hook: forget what was learned about the API, or force an answer for every origin. */
|
|
31
|
+
export declare function _resetKeyedWriteSupport(value?: boolean): void;
|
|
28
32
|
/**
|
|
29
33
|
* How long one API call keeps retrying before it gives up:
|
|
30
34
|
* SCRAPELY_API_RETRY_WINDOW_SECS when set, else {@link BaseApiClient.retryWindowMs}
|
|
31
|
-
* (
|
|
35
|
+
* (5 minutes by default).
|
|
32
36
|
*/
|
|
33
37
|
export declare function apiRetryWindowMs(): number;
|
|
34
38
|
/**
|
|
@@ -51,6 +55,13 @@ export interface RetryingFetchOptions {
|
|
|
51
55
|
retryWindowMs?: number;
|
|
52
56
|
/** Short label for log lines, e.g. "POST /datasets/x/items". */
|
|
53
57
|
label: string;
|
|
58
|
+
/**
|
|
59
|
+
* Idempotency-Key for a write (one per logical write, the same on every
|
|
60
|
+
* attempt). Once the API is known to de-duplicate keyed writes, the write
|
|
61
|
+
* is retried like an idempotent call: a re-send of an applied write returns
|
|
62
|
+
* the original answer instead of applying it again.
|
|
63
|
+
*/
|
|
64
|
+
idempotencyKey?: string;
|
|
54
65
|
}
|
|
55
66
|
export interface RetryingFetchInit {
|
|
56
67
|
method: string;
|
|
@@ -59,15 +70,17 @@ export interface RetryingFetchInit {
|
|
|
59
70
|
}
|
|
60
71
|
/**
|
|
61
72
|
* fetch() with the API retry policy. Retries, with exponential backoff and
|
|
62
|
-
* jitter that honours Retry-After, until the retry window (
|
|
73
|
+
* jitter that honours Retry-After, until the retry window (5 minutes by
|
|
63
74
|
* default) runs out:
|
|
64
75
|
* - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
|
|
65
76
|
* - 503 from the limiter or HAProxy: always; nothing was forwarded.
|
|
66
|
-
* - 500/502/503/504, timeouts and dropped connections: idempotent calls
|
|
77
|
+
* - 500/502/503/504, timeouts and dropped connections: idempotent calls, and
|
|
78
|
+
* writes with an Idempotency-Key once the API is known to de-duplicate them.
|
|
67
79
|
* - Refused/unresolvable connections: always; the request never left.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
80
|
+
* Any other write (KV PUT, metamorph, a keyed push to an API that did not say
|
|
81
|
+
* it de-duplicates) whose request may have reached the API is never re-sent:
|
|
82
|
+
* a re-send could apply it twice. Such failures surface to the caller instead,
|
|
83
|
+
* marked `outcomeUnknown` when an attempt may have been applied.
|
|
71
84
|
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
72
85
|
*/
|
|
73
86
|
export declare function fetchWithRetries<R>(url: string, init: RetryingFetchInit, options: RetryingFetchOptions, decode: (response: Response) => Promise<R>): Promise<R>;
|
|
@@ -81,6 +94,8 @@ interface ApiRequestOptions {
|
|
|
81
94
|
doNotRetryTimeouts?: boolean;
|
|
82
95
|
/** Override the method-based idempotency (e.g. get-or-create by name is safe to repeat). */
|
|
83
96
|
idempotent?: boolean;
|
|
97
|
+
/** Idempotency-Key for a write; see {@link RetryingFetchOptions.idempotencyKey}. */
|
|
98
|
+
idempotencyKey?: string;
|
|
84
99
|
}
|
|
85
100
|
/**
|
|
86
101
|
* Base class for API-based storage clients.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.BaseApiClient = exports.DEFAULT_API_RETRY_WINDOW_MS = exports.ApiStorageClientError = void 0;
|
|
4
4
|
exports.isRetryableResponse = isRetryableResponse;
|
|
5
|
+
exports.apiDedupesKeyedWrites = apiDedupesKeyedWrites;
|
|
6
|
+
exports._resetKeyedWriteSupport = _resetKeyedWriteSupport;
|
|
5
7
|
exports.apiRetryWindowMs = apiRetryWindowMs;
|
|
6
8
|
exports.serverRetryDelayMs = serverRetryDelayMs;
|
|
7
9
|
exports.isConnectFailure = isConnectFailure;
|
|
@@ -161,12 +163,72 @@ function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
|
161
163
|
return errorType === 'rate-limit-unavailable' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
|
|
162
164
|
}
|
|
163
165
|
/** Default time budget for retrying one API call (429, 503, network errors). */
|
|
164
|
-
exports.DEFAULT_API_RETRY_WINDOW_MS =
|
|
166
|
+
exports.DEFAULT_API_RETRY_WINDOW_MS = 300_000;
|
|
167
|
+
/**
|
|
168
|
+
* Response header the API sets on every dataset push that sent an
|
|
169
|
+
* Idempotency-Key, once it de-duplicates such pushes. An older API ignores the
|
|
170
|
+
* key, and re-sending a push there could store the rows twice, so ambiguous
|
|
171
|
+
* failures of keyed writes are retried only while the latest keyed answer from
|
|
172
|
+
* that API origin carried this header. A 2xx without it (an older instance,
|
|
173
|
+
* e.g. mid-deploy or after a rollback) switches the retries off again.
|
|
174
|
+
*/
|
|
175
|
+
const IDEMPOTENCY_SUPPORT_HEADER = 'x-scrapely-idempotency';
|
|
176
|
+
const keyedWritesDeduped = new Map();
|
|
177
|
+
/** Test override for every origin (undefined: learn from answers). */
|
|
178
|
+
let keyedWritesDedupedOverride;
|
|
179
|
+
function originOf(url) {
|
|
180
|
+
try {
|
|
181
|
+
return new URL(url).origin;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return url;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
|
|
188
|
+
function apiDedupesKeyedWrites(url) {
|
|
189
|
+
if (keyedWritesDedupedOverride !== undefined)
|
|
190
|
+
return keyedWritesDedupedOverride;
|
|
191
|
+
if (url !== undefined)
|
|
192
|
+
return keyedWritesDeduped.get(originOf(url)) === true;
|
|
193
|
+
return [...keyedWritesDeduped.values()].pop() === true;
|
|
194
|
+
}
|
|
195
|
+
/** Test hook: forget what was learned about the API, or force an answer for every origin. */
|
|
196
|
+
function _resetKeyedWriteSupport(value) {
|
|
197
|
+
keyedWritesDeduped.clear();
|
|
198
|
+
keyedWritesDedupedOverride = value === true ? true : undefined;
|
|
199
|
+
}
|
|
200
|
+
function noteKeyedAnswer(url, response) {
|
|
201
|
+
const origin = originOf(url);
|
|
202
|
+
if (response.headers.has(IDEMPOTENCY_SUPPORT_HEADER)) {
|
|
203
|
+
keyedWritesDeduped.delete(origin);
|
|
204
|
+
keyedWritesDeduped.set(origin, true);
|
|
205
|
+
}
|
|
206
|
+
else if (response.ok) {
|
|
207
|
+
// Stored by a handler that ignored the key: this API (instance) does not de-duplicate.
|
|
208
|
+
keyedWritesDeduped.delete(origin);
|
|
209
|
+
keyedWritesDeduped.set(origin, false);
|
|
210
|
+
if (keyedWritesDedupedOverride === true)
|
|
211
|
+
keyedWritesDedupedOverride = undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** API error types of a keyed write: still being stored (retry), or stored-or-not unknown (final). */
|
|
215
|
+
const KEY_IN_PROGRESS = 'idempotency-key-in-progress';
|
|
216
|
+
const OUTCOME_UNKNOWN = 'push-outcome-unknown';
|
|
217
|
+
/**
|
|
218
|
+
* Marks an error whose request may have been applied by an earlier attempt
|
|
219
|
+
* (timeout, dropped connection or 5xx before the final answer), so callers
|
|
220
|
+
* don't treat it as "nothing was stored".
|
|
221
|
+
*/
|
|
222
|
+
function markOutcomeUnknown(err) {
|
|
223
|
+
if (err && typeof err === 'object')
|
|
224
|
+
err.outcomeUnknown = true;
|
|
225
|
+
return err;
|
|
226
|
+
}
|
|
165
227
|
const MAX_BACKOFF_MS = 30_000;
|
|
166
228
|
/**
|
|
167
229
|
* How long one API call keeps retrying before it gives up:
|
|
168
230
|
* SCRAPELY_API_RETRY_WINDOW_SECS when set, else {@link BaseApiClient.retryWindowMs}
|
|
169
|
-
* (
|
|
231
|
+
* (5 minutes by default).
|
|
170
232
|
*/
|
|
171
233
|
function apiRetryWindowMs() {
|
|
172
234
|
const env = Number(process.env.SCRAPELY_API_RETRY_WINDOW_SECS);
|
|
@@ -231,22 +293,32 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
231
293
|
const sharedRateLimiter = new AdaptiveRateLimiter();
|
|
232
294
|
/**
|
|
233
295
|
* fetch() with the API retry policy. Retries, with exponential backoff and
|
|
234
|
-
* jitter that honours Retry-After, until the retry window (
|
|
296
|
+
* jitter that honours Retry-After, until the retry window (5 minutes by
|
|
235
297
|
* default) runs out:
|
|
236
298
|
* - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
|
|
237
299
|
* - 503 from the limiter or HAProxy: always; nothing was forwarded.
|
|
238
|
-
* - 500/502/503/504, timeouts and dropped connections: idempotent calls
|
|
300
|
+
* - 500/502/503/504, timeouts and dropped connections: idempotent calls, and
|
|
301
|
+
* writes with an Idempotency-Key once the API is known to de-duplicate them.
|
|
239
302
|
* - Refused/unresolvable connections: always; the request never left.
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
303
|
+
* Any other write (KV PUT, metamorph, a keyed push to an API that did not say
|
|
304
|
+
* it de-duplicates) whose request may have reached the API is never re-sent:
|
|
305
|
+
* a re-send could apply it twice. Such failures surface to the caller instead,
|
|
306
|
+
* marked `outcomeUnknown` when an attempt may have been applied.
|
|
243
307
|
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
244
308
|
*/
|
|
245
309
|
async function fetchWithRetries(url, init, options, decode) {
|
|
246
|
-
const {
|
|
247
|
-
const
|
|
310
|
+
const { timeoutMs, label, idempotencyKey } = options;
|
|
311
|
+
const headers = idempotencyKey ? { ...init.headers, 'Idempotency-Key': idempotencyKey } : init.headers;
|
|
312
|
+
// Re-sending is safe for idempotent calls, and for keyed writes once the
|
|
313
|
+
// API showed it de-duplicates them (learned from any keyed answer).
|
|
314
|
+
const safeToResend = () => options.idempotent || (idempotencyKey !== undefined && apiDedupesKeyedWrites(url));
|
|
315
|
+
const retryTimeouts = () => options.retryTimeouts !== false && safeToResend();
|
|
248
316
|
const deadline = Date.now() + Math.max(0, options.retryWindowMs ?? apiRetryWindowMs());
|
|
249
317
|
let attempt = 0;
|
|
318
|
+
// An attempt of a non-idempotent call may have been applied (timeout,
|
|
319
|
+
// dropped connection, 5xx): the final error must not claim "nothing applied".
|
|
320
|
+
let maybeApplied = false;
|
|
321
|
+
const fail = (err) => (maybeApplied && !options.idempotent ? markOutcomeUnknown(err) : err);
|
|
250
322
|
/** Sleeps before the next attempt; false once the retry window is used up. */
|
|
251
323
|
const backoff = async (why, retryAfter) => {
|
|
252
324
|
// Retrying sooner than asked would only be refused again: give up when
|
|
@@ -264,8 +336,10 @@ async function fetchWithRetries(url, init, options, decode) {
|
|
|
264
336
|
const controller = new AbortController();
|
|
265
337
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
266
338
|
try {
|
|
267
|
-
const response = await fetch(url, { method: init.method, headers
|
|
339
|
+
const response = await fetch(url, { method: init.method, headers, body: init.body, signal: controller.signal });
|
|
268
340
|
sharedRateLimiter.updateFromHeaders(response.headers);
|
|
341
|
+
if (idempotencyKey)
|
|
342
|
+
noteKeyedAnswer(url, response);
|
|
269
343
|
if (response.ok) {
|
|
270
344
|
try {
|
|
271
345
|
// Decoded inside the retry loop: a body cut off after
|
|
@@ -275,19 +349,20 @@ async function fetchWithRetries(url, init, options, decode) {
|
|
|
275
349
|
catch (bodyErr) {
|
|
276
350
|
// The request was applied (2xx); a write whose answer
|
|
277
351
|
// timed out while being read must not be sent again.
|
|
352
|
+
maybeApplied = true;
|
|
278
353
|
if (bodyErr.name === 'AbortError') {
|
|
279
|
-
if (!
|
|
280
|
-
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body${idempotent ? '' : '; the request was applied'})`);
|
|
354
|
+
if (!retryTimeouts()) {
|
|
355
|
+
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body${options.idempotent ? '' : '; the request was applied'})`);
|
|
281
356
|
timeoutError.isTimeout = true;
|
|
282
|
-
throw timeoutError;
|
|
357
|
+
throw fail(timeoutError);
|
|
283
358
|
}
|
|
284
359
|
if (await backoff(`response body timed out after ${timeoutMs}ms`, null))
|
|
285
360
|
continue;
|
|
286
|
-
throw bodyErr;
|
|
361
|
+
throw fail(bodyErr);
|
|
287
362
|
}
|
|
288
|
-
if (
|
|
363
|
+
if (safeToResend() && await backoff(`response body failed (${describeFetchError(bodyErr)})`, null))
|
|
289
364
|
continue;
|
|
290
|
-
throw bodyErr;
|
|
365
|
+
throw fail(bodyErr);
|
|
291
366
|
}
|
|
292
367
|
}
|
|
293
368
|
const text = await response.text().catch(() => '');
|
|
@@ -298,11 +373,27 @@ async function fetchWithRetries(url, init, options, decode) {
|
|
|
298
373
|
catch {
|
|
299
374
|
errorBody = undefined;
|
|
300
375
|
}
|
|
301
|
-
|
|
376
|
+
const errorType = errorBody?.error?.type;
|
|
377
|
+
const apiError = new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
|
|
378
|
+
if (idempotencyKey && errorType === OUTCOME_UNKNOWN) {
|
|
379
|
+
// Stored or not, nobody knows: the API refuses repeats of this key.
|
|
380
|
+
maybeApplied = true;
|
|
381
|
+
throw fail(apiError);
|
|
382
|
+
}
|
|
383
|
+
// A 5xx other than the limiter's / HAProxy's 503 may come after the write was applied.
|
|
384
|
+
if (!options.idempotent && response.status >= 500 && !isRetryableResponse(response.status, false, errorType, text)) {
|
|
385
|
+
maybeApplied = true;
|
|
386
|
+
}
|
|
387
|
+
const resend = safeToResend();
|
|
388
|
+
// 409 for a keyed write: an earlier attempt is still being stored.
|
|
389
|
+
const inProgress = idempotencyKey !== undefined && response.status === 409 && errorType === KEY_IN_PROGRESS;
|
|
390
|
+
if ((inProgress || isRetryableResponse(response.status, resend, errorType, text))
|
|
302
391
|
&& await backoff(`answered ${response.status}`, response.headers.get('retry-after'))) {
|
|
303
392
|
continue;
|
|
304
393
|
}
|
|
305
|
-
|
|
394
|
+
if (inProgress)
|
|
395
|
+
maybeApplied = true;
|
|
396
|
+
throw fail(apiError);
|
|
306
397
|
}
|
|
307
398
|
catch (err) {
|
|
308
399
|
// HTTP errors (already decided above) are final.
|
|
@@ -310,19 +401,25 @@ async function fetchWithRetries(url, init, options, decode) {
|
|
|
310
401
|
throw err;
|
|
311
402
|
if (err?.isTimeout)
|
|
312
403
|
throw err;
|
|
404
|
+
if (err?.outcomeUnknown)
|
|
405
|
+
throw err;
|
|
313
406
|
if (err.name === 'AbortError') {
|
|
407
|
+
maybeApplied = true;
|
|
314
408
|
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
|
|
315
409
|
timeoutError.isTimeout = true;
|
|
316
|
-
if (retryTimeouts && await backoff(`timed out after ${timeoutMs}ms`, null))
|
|
410
|
+
if (retryTimeouts() && await backoff(`timed out after ${timeoutMs}ms`, null))
|
|
317
411
|
continue;
|
|
318
|
-
throw timeoutError;
|
|
412
|
+
throw fail(timeoutError);
|
|
319
413
|
}
|
|
320
414
|
// fetch() failed without a response. A refused/unresolvable
|
|
321
415
|
// connection never reached the API, so it is safe for every
|
|
322
|
-
// method; anything else (reset mid-request) only
|
|
323
|
-
|
|
416
|
+
// method; anything else (reset mid-request) only when re-sending is safe.
|
|
417
|
+
const connectFailure = isConnectFailure(err);
|
|
418
|
+
if (!connectFailure)
|
|
419
|
+
maybeApplied = true;
|
|
420
|
+
if ((safeToResend() || connectFailure) && await backoff(`failed (${describeFetchError(err)})`, null))
|
|
324
421
|
continue;
|
|
325
|
-
throw err;
|
|
422
|
+
throw fail(err);
|
|
326
423
|
}
|
|
327
424
|
finally {
|
|
328
425
|
clearTimeout(timeoutId);
|
|
@@ -391,7 +488,7 @@ class BaseApiClient {
|
|
|
391
488
|
headers;
|
|
392
489
|
// How long one call keeps retrying 429/503/network errors before it gives
|
|
393
490
|
// up (SCRAPELY_API_RETRY_WINDOW_SECS overrides it). Long enough that a
|
|
394
|
-
// throttled run slows down instead of failing, and covers
|
|
491
|
+
// throttled run slows down instead of failing, and covers an API redeploy.
|
|
395
492
|
static retryWindowMs = exports.DEFAULT_API_RETRY_WINDOW_MS;
|
|
396
493
|
// Default timeout for requests (30 seconds)
|
|
397
494
|
static defaultTimeoutMs = 30000;
|
|
@@ -427,6 +524,7 @@ class BaseApiClient {
|
|
|
427
524
|
timeoutMs,
|
|
428
525
|
retryTimeouts: options?.doNotRetryTimeouts !== true,
|
|
429
526
|
label: `${method} ${path}`,
|
|
527
|
+
idempotencyKey: options?.idempotencyKey,
|
|
430
528
|
}, decode);
|
|
431
529
|
}
|
|
432
530
|
async request(method, path, body, contentType, options) {
|
|
@@ -584,7 +682,9 @@ class ApiDatasetClient extends BaseApiClient {
|
|
|
584
682
|
// must not store unbilled rows in its default dataset.
|
|
585
683
|
if (this.id === (0, charging_js_1.defaultDatasetIdFromEnv)() && (0, charging_js_1.refuseDefaultDatasetPush)(parsed.length))
|
|
586
684
|
return;
|
|
587
|
-
|
|
685
|
+
// One key per push, the same on every attempt: the API stores a
|
|
686
|
+
// re-sent push once, so a push whose connection dropped can be retried.
|
|
687
|
+
await this.request('POST', `/datasets/${this.id}/items`, parsed, undefined, { idempotencyKey: (0, node_crypto_1.randomUUID)() });
|
|
588
688
|
}
|
|
589
689
|
}
|
|
590
690
|
exports.ApiDatasetClient = ApiDatasetClient;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.20",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"test:charging": "npx tsx --test tests/charging-pending.test.ts",
|
|
25
25
|
"test:charge-limit-guard": "npx tsx --test tests/charge-limit-guard.test.ts",
|
|
26
26
|
"test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
|
|
27
|
+
"test:push-idempotency": "npx tsx --test tests/push-idempotency.test.ts",
|
|
27
28
|
"test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
|
|
28
29
|
"test:charging-client": "npx tsx --test tests/charging-client.test.ts",
|
|
29
30
|
"test:proxy": "npx tsx --test tests/proxy-configuration.test.ts",
|
|
@@ -44,7 +45,8 @@
|
|
|
44
45
|
"test:terminal-status": "npx tsx --test tests/terminal-status-message.test.ts",
|
|
45
46
|
"test:cdp-retry": "npx tsx --test tests/cdp-retry.test.ts",
|
|
46
47
|
"test:proxy-input-aliases": "npx tsx --test tests/proxy-input-aliases.test.ts",
|
|
47
|
-
"test:charge-retries": "npx tsx --test tests/charge-retries.test.ts"
|
|
48
|
+
"test:charge-retries": "npx tsx --test tests/charge-retries.test.ts",
|
|
49
|
+
"test:solver": "npx tsx --test tests/solver.test.ts"
|
|
48
50
|
},
|
|
49
51
|
"author": "SandeepDev1",
|
|
50
52
|
"license": "ISC AND Apache-2.0",
|