@crawlee/playwright 4.0.0-beta.11 → 4.0.0-beta.111
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 +17 -13
- package/index.d.ts +1 -2
- package/index.js +0 -1
- package/internals/adaptive-playwright-crawler.d.ts +107 -47
- package/internals/adaptive-playwright-crawler.js +302 -219
- package/internals/enqueue-links/click-elements.d.ts +37 -55
- package/internals/enqueue-links/click-elements.js +51 -43
- package/internals/playwright-crawler.d.ts +103 -55
- package/internals/playwright-crawler.js +48 -42
- package/internals/playwright-launcher.d.ts +6 -5
- package/internals/playwright-launcher.js +10 -11
- package/internals/utils/playwright-utils.d.ts +61 -24
- package/internals/utils/playwright-utils.js +100 -53
- package/internals/utils/rendering-type-prediction.d.ts +28 -13
- package/internals/utils/rendering-type-prediction.js +71 -29
- package/package.json +17 -13
- package/index.d.ts.map +0 -1
- package/index.js.map +0 -1
- package/internals/adaptive-playwright-crawler.d.ts.map +0 -1
- package/internals/adaptive-playwright-crawler.js.map +0 -1
- package/internals/enqueue-links/click-elements.d.ts.map +0 -1
- package/internals/enqueue-links/click-elements.js.map +0 -1
- package/internals/playwright-crawler.d.ts.map +0 -1
- package/internals/playwright-crawler.js.map +0 -1
- package/internals/playwright-launcher.d.ts.map +0 -1
- package/internals/playwright-launcher.js.map +0 -1
- package/internals/utils/playwright-utils.d.ts.map +0 -1
- package/internals/utils/playwright-utils.js.map +0 -1
- package/internals/utils/rendering-type-prediction.d.ts.map +0 -1
- package/internals/utils/rendering-type-prediction.js.map +0 -1
- package/tsconfig.build.tsbuildinfo +0 -1
|
@@ -2,11 +2,12 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
2
2
|
import { BasicCrawler } from '@crawlee/basic';
|
|
3
3
|
import { extractUrlsFromPage } from '@crawlee/browser';
|
|
4
4
|
import { CheerioCrawler } from '@crawlee/cheerio';
|
|
5
|
-
import {
|
|
6
|
-
import { extractUrlsFromCheerio } from '@crawlee/utils';
|
|
5
|
+
import { createStorageTransaction, OwnedOrInjected, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/core';
|
|
6
|
+
import { extractUrlsFromCheerio } from '@crawlee/utils/internal';
|
|
7
|
+
import ow from 'ow';
|
|
7
8
|
import { addTimeoutToPromise } from '@apify/timeout';
|
|
8
9
|
import { PlaywrightCrawler } from './playwright-crawler.js';
|
|
9
|
-
import { RenderingTypePredictor } from './utils/rendering-type-prediction.js';
|
|
10
|
+
import { RenderingTypePredictor, } from './utils/rendering-type-prediction.js';
|
|
10
11
|
class AdaptivePlaywrightCrawlerStatistics extends Statistics {
|
|
11
12
|
state = null; // this needs to be assigned for a valid override, but the initialization is done by a reset() call from the parent constructor
|
|
12
13
|
constructor(options = {}) {
|
|
@@ -19,8 +20,8 @@ class AdaptivePlaywrightCrawlerStatistics extends Statistics {
|
|
|
19
20
|
this.state.browserRequestHandlerRuns = 0;
|
|
20
21
|
this.state.renderingTypeMispredictions = 0;
|
|
21
22
|
}
|
|
22
|
-
async
|
|
23
|
-
await super.
|
|
23
|
+
async maybeLoadStatistics() {
|
|
24
|
+
await super.maybeLoadStatistics();
|
|
24
25
|
const savedState = await this.keyValueStore?.getValue(this.persistStateKey);
|
|
25
26
|
if (!savedState) {
|
|
26
27
|
return;
|
|
@@ -82,42 +83,74 @@ const proxyLogMethods = [
|
|
|
82
83
|
* @experimental
|
|
83
84
|
*/
|
|
84
85
|
export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
resultComparator;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
#renderingTypePredictor;
|
|
87
|
+
#resultChecker;
|
|
88
|
+
#shouldPropagateError;
|
|
89
|
+
#resultComparator;
|
|
90
|
+
#staticContextPipeline;
|
|
91
|
+
#browserContextPipeline;
|
|
92
|
+
#individualRequestHandlerTimeoutMillis;
|
|
93
|
+
// The constructor always injects an `AdaptivePlaywrightCrawlerStatistics`, so narrowing the cast is sound.
|
|
94
|
+
get stats() {
|
|
95
|
+
return super.stats;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The write policy of the per-attempt transactions. Defaults the request queue to `deferred`:
|
|
99
|
+
* a discarded attempt's enqueues must never reach the queue.
|
|
100
|
+
*/
|
|
101
|
+
#attemptWritePolicy;
|
|
102
|
+
#teardownHooks = [];
|
|
103
|
+
constructor(options = {}) {
|
|
104
|
+
const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, contextPipelineBuilder, transactionalStorage, ...rest } = options;
|
|
105
|
+
// The user's value is replaced by `false` in the `super` call below — validate it separately.
|
|
106
|
+
ow(transactionalStorage, 'transactionalStorage', BasicCrawler.optionsShape.transactionalStorage);
|
|
107
|
+
// Per-attempt buffering is load-bearing here: the handler runs up to twice per request and the
|
|
108
|
+
// losing attempt's writes must be discardable.
|
|
109
|
+
if (transactionalStorage === false) {
|
|
110
|
+
throw new Error('AdaptivePlaywrightCrawler requires transactional storage - it runs the request handler ' +
|
|
111
|
+
'multiple times per request and must be able to discard the storage writes of losing ' +
|
|
112
|
+
'attempts. `transactionalStorage: false` is therefore not supported; a write policy ' +
|
|
113
|
+
'object is accepted and forwarded to the per-attempt transactions.');
|
|
114
|
+
}
|
|
115
|
+
if (statistics !== undefined && !(statistics instanceof AdaptivePlaywrightCrawlerStatistics)) {
|
|
116
|
+
throw new Error('AdaptivePlaywrightCrawler tracks extra fields on its own Statistics subclass and cannot use a ' +
|
|
117
|
+
'plain `statistics` instance. Omit the option to let the crawler build its own.');
|
|
118
|
+
}
|
|
97
119
|
super({
|
|
98
120
|
...rest,
|
|
99
|
-
// Pass error handlers to the "main" crawler - we only pluck them from `rest` so that they don't go to the sub crawlers
|
|
100
121
|
errorHandler,
|
|
101
122
|
failedRequestHandler,
|
|
102
|
-
// Same for request handler
|
|
103
123
|
requestHandler,
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
requestHandlerTimeoutSecs,
|
|
125
|
+
// Inject our subclass so the base tracks the extra adaptive fields instead of building a plain `Statistics`.
|
|
126
|
+
statistics: statistics ??
|
|
127
|
+
new AdaptivePlaywrightCrawlerStatistics({
|
|
128
|
+
logMessage: `${AdaptivePlaywrightCrawler.name} request statistics:`,
|
|
129
|
+
}),
|
|
130
|
+
contextPipelineBuilder: contextPipelineBuilder ?? (() => this.buildContextPipeline()),
|
|
131
|
+
// The base crawler must not wrap requests in a transaction of its own - this crawler opens
|
|
132
|
+
// one per request handler attempt in `crawlOne` instead, forwarding the write policy of the
|
|
133
|
+
// user-facing option (validated above) to those.
|
|
134
|
+
transactionalStorage: false,
|
|
135
|
+
});
|
|
136
|
+
this.#individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
|
|
137
|
+
// `renderingTypeDetectionRatio` only configures the default predictor - an injected one brings its own
|
|
138
|
+
// detection ratio (and its own state), so the option is ignored in that case.
|
|
139
|
+
this.#renderingTypePredictor = OwnedOrInjected.resolve(renderingTypePredictor, () => new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio }));
|
|
140
|
+
this.#attemptWritePolicy = {
|
|
141
|
+
requestQueue: 'deferred',
|
|
142
|
+
...(typeof transactionalStorage === 'object' ? transactionalStorage : {}),
|
|
143
|
+
};
|
|
144
|
+
this.#resultChecker = resultChecker ?? (() => true);
|
|
145
|
+
this.#shouldPropagateError = shouldPropagateError ?? (() => false);
|
|
113
146
|
if (resultComparator !== undefined) {
|
|
114
|
-
this
|
|
147
|
+
this.#resultComparator = resultComparator;
|
|
115
148
|
}
|
|
116
149
|
else if (resultChecker !== undefined) {
|
|
117
|
-
this
|
|
150
|
+
this.#resultComparator = (resultA, resultB) => this.#resultChecker(resultA) && this.#resultChecker(resultB);
|
|
118
151
|
}
|
|
119
152
|
else {
|
|
120
|
-
this
|
|
153
|
+
this.#resultComparator = (resultA, resultB) => {
|
|
121
154
|
return (resultA.datasetItems.length === resultB.datasetItems.length &&
|
|
122
155
|
resultA.datasetItems.every((itemA, i) => {
|
|
123
156
|
const itemB = resultB.datasetItems[i];
|
|
@@ -125,167 +158,168 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
125
158
|
}));
|
|
126
159
|
};
|
|
127
160
|
}
|
|
161
|
+
// `extendContext` is forwarded to the inner crawlers, which run it *before* navigation (see
|
|
162
|
+
// `BasicCrawler`), keeping the behavior consistent with the non-adaptive crawlers: the
|
|
163
|
+
// extension is visible to the pre/post-navigation hooks and the request handler, but cannot
|
|
164
|
+
// access navigation-dependent members (`page`, `response`, `$`, ...).
|
|
165
|
+
//
|
|
166
|
+
// The adaptive hooks target a subset context (`AdaptiveHookContext`); the casts to the inner
|
|
167
|
+
// crawlers' `PlaywrightHook` type relax that nominal difference. The `ContextPipeline` merges
|
|
168
|
+
// each hook's overrides at runtime regardless of the static type.
|
|
128
169
|
const staticCrawler = new CheerioCrawler({
|
|
129
170
|
...rest,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
async (context) => {
|
|
136
|
-
for (const hook of preNavigationHooks ?? []) {
|
|
137
|
-
await hook(context, undefined);
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
],
|
|
141
|
-
postNavigationHooks: [
|
|
142
|
-
async (context) => {
|
|
143
|
-
for (const hook of postNavigationHooks ?? []) {
|
|
144
|
-
await hook(context, undefined);
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
],
|
|
148
|
-
}, config);
|
|
171
|
+
statistics: new Statistics({ persistenceOptions: { enable: false } }),
|
|
172
|
+
preNavigationHooks,
|
|
173
|
+
postNavigationHooks,
|
|
174
|
+
extendContext,
|
|
175
|
+
});
|
|
149
176
|
const browserCrawler = new PlaywrightCrawler({
|
|
150
177
|
...rest,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
await hook(context, gotoOptions);
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
],
|
|
162
|
-
postNavigationHooks: [
|
|
163
|
-
async (context, gotoOptions) => {
|
|
164
|
-
for (const hook of postNavigationHooks ?? []) {
|
|
165
|
-
await hook(context, gotoOptions);
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
],
|
|
169
|
-
}, config);
|
|
170
|
-
this.teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
|
|
171
|
-
this.staticContextPipeline = staticCrawler.contextPipeline
|
|
172
|
-
.compose({
|
|
178
|
+
statistics: new Statistics({ persistenceOptions: { enable: false } }),
|
|
179
|
+
preNavigationHooks: preNavigationHooks,
|
|
180
|
+
postNavigationHooks: postNavigationHooks,
|
|
181
|
+
extendContext,
|
|
182
|
+
});
|
|
183
|
+
this.#teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
|
|
184
|
+
this.#staticContextPipeline = staticCrawler.contextPipeline.compose({
|
|
173
185
|
action: this.adaptCheerioContext.bind(this),
|
|
174
|
-
})
|
|
175
|
-
.compose({
|
|
176
|
-
action: async (context) => extendContext ? await extendContext(context) : context,
|
|
177
186
|
});
|
|
178
|
-
this
|
|
179
|
-
.compose({
|
|
187
|
+
this.#browserContextPipeline = browserCrawler.contextPipeline.compose({
|
|
180
188
|
action: this.adaptPlaywrightContext.bind(this),
|
|
181
|
-
})
|
|
182
|
-
.compose({
|
|
183
|
-
action: async (context) => extendContext ? await extendContext(context) : context,
|
|
184
189
|
});
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
}
|
|
191
|
+
async init() {
|
|
192
|
+
// Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
|
|
193
|
+
// lifecycle (including restoring persisted state) stays with whoever created it.
|
|
194
|
+
await this.#renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
|
|
195
|
+
return await super.init();
|
|
196
|
+
}
|
|
197
|
+
buildContextPipeline() {
|
|
198
|
+
const errorMessage = (prop) => `The \`${prop}\` property is not available on the outer context pipeline of AdaptivePlaywrightCrawler - it is provided by the inner (static/browser) pipelines`;
|
|
199
|
+
return super.buildContextPipeline().compose({
|
|
200
|
+
action: async ({ request }) => ({
|
|
201
|
+
get request() {
|
|
202
|
+
return request;
|
|
203
|
+
},
|
|
204
|
+
get response() {
|
|
205
|
+
throw new Error(errorMessage('response'));
|
|
206
|
+
},
|
|
207
|
+
get page() {
|
|
208
|
+
throw new Error(errorMessage('page'));
|
|
209
|
+
},
|
|
210
|
+
get querySelector() {
|
|
211
|
+
throw new Error(errorMessage('querySelector'));
|
|
212
|
+
},
|
|
213
|
+
get querySelectorAll() {
|
|
214
|
+
throw new Error(errorMessage('querySelectorAll'));
|
|
215
|
+
},
|
|
216
|
+
get waitForSelector() {
|
|
217
|
+
throw new Error(errorMessage('waitForSelector'));
|
|
218
|
+
},
|
|
219
|
+
get parseWithCheerio() {
|
|
220
|
+
throw new Error(errorMessage('parseWithCheerio'));
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
189
223
|
});
|
|
190
|
-
this.preventDirectStorageAccess = preventDirectStorageAccess;
|
|
191
224
|
}
|
|
192
225
|
async adaptCheerioContext(cheerioContext) {
|
|
193
|
-
// Capture the original response to avoid infinite recursion when the getter is copied to the context
|
|
194
|
-
const originalResponse = cheerioContext.response;
|
|
195
|
-
const enqueueLinks = this.resultObjects.get(cheerioContext)?.enqueueLinks;
|
|
196
|
-
if (enqueueLinks === undefined) {
|
|
197
|
-
throw new Error('Logical error - `this.resultObjects` does not contain the result object');
|
|
198
|
-
}
|
|
199
226
|
return {
|
|
200
227
|
get page() {
|
|
201
228
|
throw new Error('Page object was used in HTTP-only request handler');
|
|
202
229
|
},
|
|
203
|
-
get response() {
|
|
204
|
-
return {
|
|
205
|
-
// TODO remove this once cheerioContext.response is just a Response
|
|
206
|
-
complete: true,
|
|
207
|
-
headers: originalResponse.headers,
|
|
208
|
-
trailers: {},
|
|
209
|
-
url: originalResponse.url,
|
|
210
|
-
statusCode: originalResponse.statusCode,
|
|
211
|
-
redirectUrls: originalResponse.redirectUrls ?? [],
|
|
212
|
-
};
|
|
213
|
-
},
|
|
214
230
|
async querySelector(selector) {
|
|
231
|
+
return cheerioContext.$(selector).first();
|
|
232
|
+
},
|
|
233
|
+
async querySelectorAll(selector) {
|
|
215
234
|
return cheerioContext.$(selector);
|
|
216
235
|
},
|
|
217
|
-
async
|
|
236
|
+
enqueueLinks: async (options = {}) => {
|
|
218
237
|
const urls = options.urls ??
|
|
219
238
|
extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
|
|
220
|
-
await enqueueLinks({ ...options, urls });
|
|
239
|
+
return (await this.enqueueLinks({ ...options, urls }, cheerioContext.request));
|
|
221
240
|
},
|
|
241
|
+
response: cheerioContext.response,
|
|
222
242
|
};
|
|
223
243
|
}
|
|
224
244
|
async adaptPlaywrightContext(playwrightContext) {
|
|
225
245
|
// Capture the original response to avoid infinite recursion when the getter is copied to the context
|
|
226
246
|
const originalResponse = playwrightContext.response;
|
|
227
|
-
const enqueueLinks = this.resultObjects.get(playwrightContext)?.enqueueLinks;
|
|
228
|
-
if (enqueueLinks === undefined) {
|
|
229
|
-
throw new Error('Logical error - `this.resultObjects` does not contain the result object');
|
|
230
|
-
}
|
|
231
247
|
return {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
trailers: {},
|
|
238
|
-
complete: true,
|
|
239
|
-
redirectUrls: [],
|
|
240
|
-
};
|
|
241
|
-
},
|
|
248
|
+
response: new Response(Uint8Array.from(await originalResponse.body()), {
|
|
249
|
+
headers: originalResponse.headers(),
|
|
250
|
+
status: originalResponse.status(),
|
|
251
|
+
statusText: originalResponse.statusText(),
|
|
252
|
+
}),
|
|
242
253
|
async querySelector(selector, timeoutMs = 5000) {
|
|
243
254
|
const locator = playwrightContext.page.locator(selector).first();
|
|
244
255
|
await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
|
|
245
256
|
const $ = await playwrightContext.parseWithCheerio();
|
|
246
|
-
return $(selector);
|
|
257
|
+
return $(selector).first();
|
|
247
258
|
},
|
|
248
|
-
async
|
|
249
|
-
const selector = options.selector ?? 'a';
|
|
259
|
+
async querySelectorAll(selector, timeoutMs = 5000) {
|
|
250
260
|
const locator = playwrightContext.page.locator(selector).first();
|
|
251
261
|
await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
|
|
262
|
+
const $ = await playwrightContext.parseWithCheerio();
|
|
263
|
+
return $(selector);
|
|
264
|
+
},
|
|
265
|
+
enqueueLinks: async (options = {}, timeoutMs = 5000) => {
|
|
252
266
|
// TODO consider using `context.parseWithCheerio` to make this universal and avoid code duplication
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
267
|
+
let urls;
|
|
268
|
+
if (options.urls === undefined) {
|
|
269
|
+
const selector = options.selector ?? 'a';
|
|
270
|
+
const locator = playwrightContext.page.locator(selector).first();
|
|
271
|
+
await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
|
|
272
|
+
urls =
|
|
273
|
+
options.urls ??
|
|
274
|
+
(await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl));
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
urls = options.urls;
|
|
278
|
+
}
|
|
279
|
+
return (await this.enqueueLinks({ ...options, urls }, playwrightContext.request));
|
|
256
280
|
},
|
|
257
281
|
};
|
|
258
282
|
}
|
|
259
|
-
|
|
260
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
|
|
285
|
+
* (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
|
|
286
|
+
* time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
|
|
287
|
+
* result, and failed attempts are routine here. The caller owns the outcome and disposal.
|
|
288
|
+
*/
|
|
289
|
+
async crawlOne(renderingType, context, useStateFunction, transactions) {
|
|
290
|
+
const transaction = createStorageTransaction({
|
|
291
|
+
policy: this.#attemptWritePolicy,
|
|
292
|
+
commitTimeoutMillis: this.internalTimeoutMillis,
|
|
293
|
+
});
|
|
294
|
+
transactions.push(transaction);
|
|
261
295
|
const logs = [];
|
|
262
296
|
const deferredCleanup = [];
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
pushData: result.pushData,
|
|
266
|
-
useState: this.allowStorageAccess(useStateFunction),
|
|
267
|
-
getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
|
|
268
|
-
enqueueLinks: result.enqueueLinks,
|
|
297
|
+
const attemptBoundContextHelpers = {
|
|
298
|
+
useState: useStateFunction,
|
|
269
299
|
log: this.createLogProxy(context.log, logs),
|
|
270
300
|
registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
|
|
271
301
|
};
|
|
272
|
-
const subCrawlerContext = {
|
|
273
|
-
|
|
302
|
+
const subCrawlerContext = Object.defineProperties({}, Object.getOwnPropertyDescriptors(context));
|
|
303
|
+
// Mark attempt-bound helpers as non-configurable so they survive the sub-crawler context pipeline
|
|
304
|
+
// (which would otherwise override them with the sub-crawler's own versions, losing the binding).
|
|
305
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(attemptBoundContextHelpers))) {
|
|
306
|
+
Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
|
|
307
|
+
}
|
|
274
308
|
try {
|
|
275
309
|
const callAdaptiveRequestHandler = async () => {
|
|
276
310
|
if (renderingType === 'static') {
|
|
277
|
-
await this
|
|
311
|
+
await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
|
|
278
312
|
}
|
|
279
313
|
else if (renderingType === 'clientOnly') {
|
|
280
|
-
await this
|
|
314
|
+
await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
|
|
281
315
|
}
|
|
282
316
|
};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return { result, ok: true, logs };
|
|
317
|
+
// this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
|
|
318
|
+
// to resolve any per-route override too - otherwise routes would be silently ignored here
|
|
319
|
+
const routeTimeoutSecs = this.requestHandler.getTimeoutSecs?.(context.request.label);
|
|
320
|
+
const timeoutMillis = routeTimeoutSecs === undefined ? this.#individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
|
|
321
|
+
await addTimeoutToPromise(async () => transaction.run(callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
|
|
322
|
+
return { result: transaction, ok: true, logs };
|
|
289
323
|
}
|
|
290
324
|
catch (error) {
|
|
291
325
|
return { error, ok: false, logs };
|
|
@@ -295,89 +329,111 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
295
329
|
}
|
|
296
330
|
}
|
|
297
331
|
async runRequestHandler(crawlingContext) {
|
|
298
|
-
const renderingTypePrediction = this
|
|
332
|
+
const renderingTypePrediction = this.#renderingTypePredictor.value.predict(crawlingContext.request);
|
|
299
333
|
const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
|
|
300
334
|
if (!shouldDetectRenderingType) {
|
|
301
335
|
crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
|
|
302
336
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
: plainHTTPRun.error;
|
|
318
|
-
crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
|
|
322
|
-
this.stats.trackRenderingTypeMisprediction();
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
|
|
326
|
-
this.stats.trackBrowserRequestHandlerRun();
|
|
327
|
-
// Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
|
|
328
|
-
// a rendering type detection if necessary. Without this measure, the HTTP request handler would run
|
|
329
|
-
// under different conditions, which could change its behavior. Changes done to the crawler state by
|
|
330
|
-
// the HTTP request handler will not be committed to the actual storage.
|
|
331
|
-
const stateTracker = {
|
|
332
|
-
stateCopy: null,
|
|
333
|
-
async getLiveState(defaultValue = {}) {
|
|
334
|
-
const state = await crawlingContext.useState(defaultValue);
|
|
335
|
-
if (this.stateCopy === null) {
|
|
336
|
-
this.stateCopy = JSON.parse(JSON.stringify(state));
|
|
337
|
-
}
|
|
338
|
-
return state;
|
|
339
|
-
},
|
|
340
|
-
async getStateCopy(defaultValue = {}) {
|
|
341
|
-
if (this.stateCopy === null) {
|
|
342
|
-
return defaultValue;
|
|
337
|
+
// Every transaction created for this request - up to two, since the static-then-browser
|
|
338
|
+
// fall-through and the browser-then-detection pair are mutually exclusive. Disposed in the
|
|
339
|
+
// `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
|
|
340
|
+
const transactions = [];
|
|
341
|
+
try {
|
|
342
|
+
if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
|
|
343
|
+
crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
|
|
344
|
+
this.stats.trackHttpOnlyRequestHandlerRun();
|
|
345
|
+
const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
|
|
346
|
+
if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
|
|
347
|
+
crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
|
|
348
|
+
plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
|
|
349
|
+
await plainHTTPRun.result.commit();
|
|
350
|
+
return;
|
|
343
351
|
}
|
|
344
|
-
|
|
345
|
-
},
|
|
346
|
-
};
|
|
347
|
-
const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker));
|
|
348
|
-
if (!browserRun.ok) {
|
|
349
|
-
throw browserRun.error;
|
|
350
|
-
}
|
|
351
|
-
await this.commitResult(crawlingContext, browserRun.result);
|
|
352
|
-
if (shouldDetectRenderingType) {
|
|
353
|
-
crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
|
|
354
|
-
const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker));
|
|
355
|
-
const detectionResult = (() => {
|
|
352
|
+
// Execution will "fall through" and try running the request handler in a browser
|
|
356
353
|
if (!plainHTTPRun.ok) {
|
|
357
|
-
|
|
354
|
+
const actualError = plainHTTPRun.error instanceof RequestHandlerError
|
|
355
|
+
? plainHTTPRun.error.cause
|
|
356
|
+
: plainHTTPRun.error;
|
|
357
|
+
if (await this.#shouldPropagateError(actualError, crawlingContext)) {
|
|
358
|
+
throw actualError;
|
|
359
|
+
}
|
|
360
|
+
crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
|
|
364
|
+
this.stats.trackRenderingTypeMisprediction();
|
|
358
365
|
}
|
|
359
|
-
|
|
360
|
-
|
|
366
|
+
}
|
|
367
|
+
crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
|
|
368
|
+
this.stats.trackBrowserRequestHandlerRun();
|
|
369
|
+
// Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
|
|
370
|
+
// a rendering type detection if necessary. Without this measure, the HTTP request handler would run
|
|
371
|
+
// under different conditions, which could change its behavior. Changes done to the crawler state by
|
|
372
|
+
// the HTTP request handler will not be committed to the actual storage.
|
|
373
|
+
const stateTracker = {
|
|
374
|
+
stateCopy: null,
|
|
375
|
+
async getLiveState(defaultValue = {}) {
|
|
376
|
+
const state = await crawlingContext.useState(defaultValue);
|
|
377
|
+
if (this.stateCopy === null) {
|
|
378
|
+
this.stateCopy = JSON.parse(JSON.stringify(state));
|
|
379
|
+
}
|
|
380
|
+
return state;
|
|
381
|
+
},
|
|
382
|
+
async getStateCopy(defaultValue = {}) {
|
|
383
|
+
if (this.stateCopy === null) {
|
|
384
|
+
return defaultValue;
|
|
385
|
+
}
|
|
386
|
+
return this.stateCopy;
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
|
|
390
|
+
if (!browserRun.ok) {
|
|
391
|
+
throw browserRun.error;
|
|
392
|
+
}
|
|
393
|
+
browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
|
|
394
|
+
await browserRun.result.commit();
|
|
395
|
+
if (shouldDetectRenderingType) {
|
|
396
|
+
crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
|
|
397
|
+
// The detection attempt's transaction is never committed - its writes exist only for the
|
|
398
|
+
// result comparison.
|
|
399
|
+
const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
|
|
400
|
+
const detectionResult = (() => {
|
|
401
|
+
if (!plainHTTPRun.ok) {
|
|
402
|
+
return 'clientOnly';
|
|
403
|
+
}
|
|
404
|
+
const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
|
|
405
|
+
if (comparisonResult === true || comparisonResult === 'equal') {
|
|
406
|
+
return 'static';
|
|
407
|
+
}
|
|
408
|
+
if (comparisonResult === false || comparisonResult === 'different') {
|
|
409
|
+
return 'clientOnly';
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
})();
|
|
413
|
+
crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
|
|
414
|
+
if (detectionResult !== undefined) {
|
|
415
|
+
this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
|
|
361
416
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
// A still-open transaction here belongs to a discarded attempt - roll it back, then release.
|
|
421
|
+
for (const transaction of transactions) {
|
|
422
|
+
transaction.rollback();
|
|
423
|
+
transaction.dispose();
|
|
424
|
+
}
|
|
366
425
|
}
|
|
367
426
|
}
|
|
368
|
-
async
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
allowStorageAccess(func) {
|
|
380
|
-
return async (...args) => withCheckedStorageAccess(() => { }, async () => func(...args));
|
|
427
|
+
async enqueueLinks(options, request) {
|
|
428
|
+
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
429
|
+
enqueueStrategy: options?.strategy,
|
|
430
|
+
finalRequestUrl: request.loadedUrl,
|
|
431
|
+
originalRequestUrl: request.url,
|
|
432
|
+
userProvidedBaseUrl: options?.baseUrl,
|
|
433
|
+
});
|
|
434
|
+
// The per-attempt transaction buffers these (the queue policy defaults to `deferred` here),
|
|
435
|
+
// so a discarded attempt's enqueues never reach the queue.
|
|
436
|
+
return await this.enqueueLinksWithCrawlDepth({ ...options, baseUrl }, request, await this.getRequestManager());
|
|
381
437
|
}
|
|
382
438
|
createLogProxy(log, logs) {
|
|
383
439
|
return new Proxy(log, {
|
|
@@ -393,12 +449,39 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
393
449
|
}
|
|
394
450
|
async teardown() {
|
|
395
451
|
await super.teardown();
|
|
396
|
-
for (const hook of this
|
|
452
|
+
for (const hook of this.#teardownHooks) {
|
|
397
453
|
await hook();
|
|
398
454
|
}
|
|
399
455
|
}
|
|
400
456
|
}
|
|
401
|
-
export function createAdaptivePlaywrightRouter(
|
|
402
|
-
return Router.create(
|
|
457
|
+
export function createAdaptivePlaywrightRouter(routesOrSchemas) {
|
|
458
|
+
return Router.create(routesOrSchemas);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* An opt-in {@link AdaptivePlaywrightCrawlerOptions.resultComparator|`resultComparator`} that considers two
|
|
462
|
+
* request handler results equal only if *all* of their observable effects match - the pushed dataset items, the
|
|
463
|
+
* enqueued requests, and the key-value store changes. This is stricter than the default comparator, which only
|
|
464
|
+
* compares dataset items.
|
|
465
|
+
*
|
|
466
|
+
* **Beware:** enqueued URLs are compared exactly. The same page rendered in a browser and via plain HTTP often
|
|
467
|
+
* yields links that differ only in tracking query parameters, for example:
|
|
468
|
+
* - `https://sdk.apify.com/docs/guides/getting-started`
|
|
469
|
+
* - `https://sdk.apify.com/docs/guides/getting-started?__hsfp=1136113150&__hssc=7591405.1.173549427712`
|
|
470
|
+
*
|
|
471
|
+
* Such links are treated as *different*, which will make the crawler favor browser rendering for those pages.
|
|
472
|
+
*
|
|
473
|
+
* **Example usage:**
|
|
474
|
+
* ```ts
|
|
475
|
+
* const crawler = new AdaptivePlaywrightCrawler({
|
|
476
|
+
* resultComparator: fullResultComparator,
|
|
477
|
+
* async requestHandler({ pushData, enqueueLinks }) {
|
|
478
|
+
* // ...
|
|
479
|
+
* },
|
|
480
|
+
* });
|
|
481
|
+
* ```
|
|
482
|
+
*/
|
|
483
|
+
export function fullResultComparator(resultA, resultB) {
|
|
484
|
+
return (isDeepStrictEqual(resultA.datasetItems, resultB.datasetItems) &&
|
|
485
|
+
isDeepStrictEqual(resultA.enqueuedUrls, resultB.enqueuedUrls) &&
|
|
486
|
+
isDeepStrictEqual(resultA.keyValueStoreChanges, resultB.keyValueStoreChanges));
|
|
403
487
|
}
|
|
404
|
-
//# sourceMappingURL=adaptive-playwright-crawler.js.map
|