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