@crawlee/http 4.0.0-beta.96 → 4.0.0-beta.97
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.
|
@@ -21,7 +21,11 @@ JSONData extends JsonValue = any, // with default to Dictionary we cant use a ty
|
|
|
21
21
|
ContextExtension = Dictionary<never>> = ErrorHandler<CrawlingContext, HttpCrawlingContext<UserData, JSONData> & ContextExtension>;
|
|
22
22
|
export interface HttpCrawlerOptions<Context extends InternalHttpCrawlingContext = InternalHttpCrawlingContext, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> extends BasicCrawlerOptions<Context, ContextExtension, ExtendedContext, Routes> {
|
|
23
23
|
/**
|
|
24
|
-
* Timeout
|
|
24
|
+
* Timeout for the whole navigation phase, given in seconds. A single window shared by the
|
|
25
|
+
* `preNavigationHooks`, the navigation (the HTTP request to the resource), and the `postNavigationHooks` -
|
|
26
|
+
* so a slow hook eats into the same budget the navigation uses. Separate from the
|
|
27
|
+
* {@link BasicCrawlerOptions.requestHandlerTimeoutSecs|`requestHandlerTimeoutSecs`}, which times only the
|
|
28
|
+
* request handler.
|
|
25
29
|
*/
|
|
26
30
|
navigationTimeoutSecs?: number;
|
|
27
31
|
/**
|
|
@@ -344,6 +348,7 @@ export declare class HttpCrawler<Context extends InternalHttpCrawlingContext<any
|
|
|
344
348
|
* All `HttpCrawlerOptions` parameters are passed via an options object.
|
|
345
349
|
*/
|
|
346
350
|
constructor(options?: HttpCrawlerOptions<Context, ContextExtension, ExtendedContext> & RequireContextPipeline<InternalHttpCrawlingContext, Context>);
|
|
351
|
+
protected getNavigationTimeoutMillis(): number;
|
|
347
352
|
/**
|
|
348
353
|
* Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
|
|
349
354
|
* concurrency shortcuts on top. Not called for a supplied
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
2
|
import util from 'node:util';
|
|
3
|
-
import { BasicCrawler, ContextPipeline, NavigationSkippedError, RequestState, Router, SessionError, } from '@crawlee/basic';
|
|
3
|
+
import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, Router, SessionError, } from '@crawlee/basic';
|
|
4
4
|
import { getCookiesFromResponse } from '@crawlee/core';
|
|
5
5
|
import { ResponseWithUrl } from '@crawlee/http-client';
|
|
6
6
|
import { RETRY_CSS_SELECTORS } from '@crawlee/utils';
|
|
7
7
|
import contentTypeParser from 'content-type';
|
|
8
8
|
import iconv from 'iconv-lite';
|
|
9
9
|
import ow from 'ow';
|
|
10
|
-
import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
|
|
10
|
+
import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
|
|
11
11
|
import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
|
|
12
12
|
/**
|
|
13
13
|
* Default mime types, which HttpScraper supports.
|
|
@@ -161,6 +161,9 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
161
161
|
];
|
|
162
162
|
this.saveResponseCookies = saveResponseCookies;
|
|
163
163
|
}
|
|
164
|
+
getNavigationTimeoutMillis() {
|
|
165
|
+
return this.navigationTimeoutMillis;
|
|
166
|
+
}
|
|
164
167
|
/**
|
|
165
168
|
* Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
|
|
166
169
|
* concurrency shortcuts on top. Not called for a supplied
|
|
@@ -179,15 +182,26 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
179
182
|
const skipGuard = (action) => ({
|
|
180
183
|
action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
|
|
181
184
|
});
|
|
185
|
+
// A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
|
|
186
|
+
// hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
|
|
187
|
+
// window the navigation uses instead of each step being timed on its own.
|
|
188
|
+
const navigationTimedOut = `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`;
|
|
189
|
+
const windowGuard = (step) => skipGuard(async (ctx) => {
|
|
190
|
+
const remaining = remainingNavigationWindowMillis(ctx, this.navigationTimeoutMillis);
|
|
191
|
+
if (remaining <= 0) {
|
|
192
|
+
throw new TimeoutError(navigationTimedOut);
|
|
193
|
+
}
|
|
194
|
+
return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
|
|
195
|
+
});
|
|
182
196
|
let pipeline = ContextPipeline.create().compose({
|
|
183
197
|
action: this.prepareHttpRequest.bind(this),
|
|
184
198
|
});
|
|
185
199
|
for (const hook of this.preNavigationHooks) {
|
|
186
|
-
pipeline = pipeline.compose(
|
|
200
|
+
pipeline = pipeline.compose(windowGuard(hook));
|
|
187
201
|
}
|
|
188
202
|
let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
|
|
189
203
|
for (const hook of this.postNavigationHooks) {
|
|
190
|
-
pipelineWithNavigation = pipelineWithNavigation.compose(
|
|
204
|
+
pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
|
|
191
205
|
}
|
|
192
206
|
return pipelineWithNavigation
|
|
193
207
|
.compose({ action: this.processHttpResponse.bind(this) })
|
|
@@ -217,7 +231,10 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
217
231
|
tryCancel();
|
|
218
232
|
const { request, session } = crawlingContext;
|
|
219
233
|
const proxyUrl = crawlingContext.proxyInfo?.url;
|
|
220
|
-
|
|
234
|
+
// Bound the request by whatever is left of the shared navigation window (the pre-navigation hooks may
|
|
235
|
+
// have already spent part of it), so it produces a clean navigation-timeout error rather than the raw
|
|
236
|
+
// client abort.
|
|
237
|
+
const httpResponse = await addTimeoutToPromise(async () => this.requestFunction({ request, session, proxyUrl }), Math.max(1, remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis)), `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
221
238
|
tryCancel();
|
|
222
239
|
request.loadedUrl = httpResponse?.url;
|
|
223
240
|
request.state = RequestState.AFTER_NAV;
|
|
@@ -244,7 +261,14 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
244
261
|
};
|
|
245
262
|
}
|
|
246
263
|
tryCancel();
|
|
247
|
-
|
|
264
|
+
// Reading the body is still part of the navigation, so it draws from the same shared window: on a server
|
|
265
|
+
// that streams the body slowly the request completes (headers arrive) but the body read would otherwise
|
|
266
|
+
// run unbounded. `extendTimeout` from a post-navigation hook has already pushed this deadline out if asked.
|
|
267
|
+
const remaining = remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis);
|
|
268
|
+
if (remaining <= 0) {
|
|
269
|
+
throw new TimeoutError(`Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
270
|
+
}
|
|
271
|
+
const parsed = await addTimeoutToPromise(async () => this.parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
248
272
|
tryCancel();
|
|
249
273
|
const response = parsed.response;
|
|
250
274
|
const contentType = parsed.contentType;
|
|
@@ -474,7 +498,7 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
474
498
|
*/
|
|
475
499
|
handleRequestTimeout(session) {
|
|
476
500
|
session.markBad();
|
|
477
|
-
throw new Error(`
|
|
501
|
+
throw new Error(`Request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
|
|
478
502
|
}
|
|
479
503
|
_abortDownloadOfBody(request, response) {
|
|
480
504
|
const { status } = response;
|
|
@@ -498,6 +522,12 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
498
522
|
// session jar. Reads still go through the session (so session.setCookie() in pre-nav
|
|
499
523
|
// hooks keeps working) but a per-request clone is passed in so writes are discarded.
|
|
500
524
|
const cookieJar = this.saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
|
|
525
|
+
// Bind the request to the shared navigation window instead of a fixed per-request timeout, so
|
|
526
|
+
// `extendTimeout()` can push the deadline and a fixed `AbortSignal.timeout` won't fire on its own and
|
|
527
|
+
// kill a lazily-read body mid-extension. This aborts the socket only during the header phase; the body
|
|
528
|
+
// read is bounded separately at the promise level (see `processHttpResponse`), so a slow-streaming body
|
|
529
|
+
// still fails cleanly with a navigation timeout, though the socket is left to close on its own.
|
|
530
|
+
const cancelSignal = storage.getStore()?.cancelTask.signal;
|
|
501
531
|
const response = await this.httpClient.sendRequest(new Request(opts.url, {
|
|
502
532
|
body: opts.body ? Readable.toWeb(opts.body) : undefined,
|
|
503
533
|
headers: new Headers(opts.headers),
|
|
@@ -507,7 +537,8 @@ export class HttpCrawler extends BasicCrawler {
|
|
|
507
537
|
}), {
|
|
508
538
|
session,
|
|
509
539
|
cookieJar,
|
|
510
|
-
|
|
540
|
+
signal: cancelSignal,
|
|
541
|
+
timeoutMillis: cancelSignal ? undefined : opts.timeout,
|
|
511
542
|
});
|
|
512
543
|
return response;
|
|
513
544
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/http",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.97",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@apify/timeout": "^0.
|
|
50
|
+
"@apify/timeout": "^0.4.4",
|
|
51
51
|
"@apify/utilities": "^2.15.5",
|
|
52
|
-
"@crawlee/basic": "4.0.0-beta.
|
|
53
|
-
"@crawlee/core": "4.0.0-beta.
|
|
54
|
-
"@crawlee/http-client": "4.0.0-beta.
|
|
55
|
-
"@crawlee/types": "4.0.0-beta.
|
|
56
|
-
"@crawlee/utils": "4.0.0-beta.
|
|
52
|
+
"@crawlee/basic": "4.0.0-beta.97",
|
|
53
|
+
"@crawlee/core": "4.0.0-beta.97",
|
|
54
|
+
"@crawlee/http-client": "4.0.0-beta.97",
|
|
55
|
+
"@crawlee/types": "4.0.0-beta.97",
|
|
56
|
+
"@crawlee/utils": "4.0.0-beta.97",
|
|
57
57
|
"@types/content-type": "^1.1.8",
|
|
58
58
|
"cheerio": "^1.0.0",
|
|
59
59
|
"content-type": "^1.0.5",
|
|
@@ -70,5 +70,5 @@
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
|
-
"gitHead": "
|
|
73
|
+
"gitHead": "43008dd43f4832d083353ba1b731c0b4607c9cfa"
|
|
74
74
|
}
|