Skip to content

Commit bec112a

Browse files
authored
feat: allow binding a Session instance to a Request (#3518)
Adds a `sessionId?: string` field to `Request`. If this is set, `BasicCrawler` will fetch this given session when processing the request (and will fail if the session is not present in the `SessionPool`). This PR also adds a `sessionId` parameter to the `enqueueLinks` (and other?) helper functions. If the request-adding function is called with this parameter, all enqueued requests will be bound to this session. Closes #3446
1 parent bd7943d commit bec112a

7 files changed

Lines changed: 131 additions & 1 deletion

File tree

packages/basic-crawler/src/internals/basic-crawler.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
KeyValueStore,
4444
LogLevel,
4545
mergeCookies,
46+
MissingSessionError,
4647
NavigationSkippedError,
4748
NonRetryableError,
4849
purgeDefaultStorages,
@@ -1104,6 +1105,16 @@ export class BasicCrawler<
11041105
private async resolveSession({ request }: { request: Request }) {
11051106
const session = await this._timeoutAndRetry(
11061107
async () => {
1108+
if (request.sessionId) {
1109+
const existingSession = await this.sessionPool!.getSession(request.sessionId);
1110+
1111+
if (!existingSession) {
1112+
throw new ContextPipelineInitializationError(new MissingSessionError(request.sessionId));
1113+
}
1114+
1115+
return existingSession;
1116+
}
1117+
11071118
return await this.sessionPool!.newSession({
11081119
proxyInfo: await this.proxyConfiguration?.newProxyInfo({
11091120
request: request ?? undefined,

packages/basic-crawler/test/batch-add-requests.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,50 @@ describe('BasicCrawler#addRequests with big batch sizes', () => {
6161
expect(result.addedRequests).toHaveLength(2000);
6262
});
6363
});
64+
65+
describe('BasicCrawler - request.sessionId', () => {
66+
const localStorageEmulator = new MemoryStorageEmulator();
67+
68+
beforeEach(async () => {
69+
await localStorageEmulator.init();
70+
});
71+
72+
afterAll(async () => {
73+
await localStorageEmulator.destroy();
74+
});
75+
76+
test('uses the session matching request.sessionId from the session pool', async () => {
77+
let resolvedSessionId: string | undefined;
78+
79+
const crawler = new BasicCrawler({
80+
requestHandler({ session }) {
81+
resolvedSessionId = session.id;
82+
},
83+
});
84+
85+
const session = await crawler.sessionPool.newSession({ id: 'my-session' });
86+
87+
await crawler.run([{ url: 'http://localhost', sessionId: session.id }]);
88+
89+
expect(resolvedSessionId).toBe('my-session');
90+
});
91+
92+
test('throws when request.sessionId is not found in the session pool', async () => {
93+
const errors: Error[] = [];
94+
95+
const crawler = new BasicCrawler({
96+
maxRequestRetries: 0,
97+
requestHandler() {},
98+
failedRequestHandler(_ctx, error) {
99+
errors.push(error);
100+
},
101+
});
102+
103+
await crawler.run([{ url: 'http://localhost', sessionId: 'nonexistent' }]);
104+
105+
expect(errors).toHaveLength(1);
106+
expect(errors[0].message).toContain(
107+
"The current SessionPool instance doesn't contain the requested sessionId: nonexistent",
108+
);
109+
});
110+
});

packages/core/src/enqueue_links/enqueue_links.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export interface EnqueueLinksOptions extends RequestQueueOperationOptions {
5555
*/
5656
label?: string;
5757

58+
/** Sets {@apilink Request.sessionId} for newly enqueued requests. */
59+
sessionId?: string;
60+
5861
/**
5962
* If set to `true`, tells the crawler to skip navigation and process the request directly.
6063
* @default false
@@ -304,6 +307,7 @@ export async function enqueueLinks(
304307
onSkippedRequest: ow.optional.function,
305308
forefront: ow.optional.boolean,
306309
skipNavigation: ow.optional.boolean,
310+
sessionId: ow.optional.string,
307311
limit: ow.optional.number,
308312
selector: ow.optional.string,
309313
baseUrl: ow.optional.string,

packages/core/src/enqueue_links/shared.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ export function filterRequestOptionsByPatterns(
217217
*/
218218
export function createRequestOptions(
219219
sources: readonly (string | Record<string, unknown>)[],
220-
options: Pick<EnqueueLinksOptions, 'label' | 'userData' | 'baseUrl' | 'skipNavigation' | 'strategy'> = {},
220+
options: Pick<
221+
EnqueueLinksOptions,
222+
'label' | 'userData' | 'baseUrl' | 'skipNavigation' | 'sessionId' | 'strategy'
223+
> = {},
221224
): RequestOptions[] {
222225
return sources
223226
.map((src) =>
@@ -247,6 +250,10 @@ export function createRequestOptions(
247250
requestOptions.skipNavigation = true;
248251
}
249252

253+
if (options.sessionId) {
254+
requestOptions.sessionId = options.sessionId;
255+
}
256+
250257
return requestOptions;
251258
});
252259
}

packages/core/src/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ export class SessionError extends RetryRequestError {
3636
}
3737
}
3838

39+
/**
40+
* Thrown when a requested session is not found in the referenced SessionPool.
41+
*/
42+
export class MissingSessionError extends Error {
43+
constructor(sessionId: string) {
44+
super(`The current SessionPool instance doesn't contain the requested sessionId: ${sessionId}`);
45+
}
46+
}
47+
3948
export class ContextPipelineInterruptedError extends Error {
4049
constructor(message?: string) {
4150
super(`Request handling was interrupted during context initialization ${message ? ` - ${message}` : ''}`);

packages/core/src/request.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const requestOptionalPredicates = {
2424
noRetry: ow.optional.boolean,
2525
retryCount: ow.optional.number,
2626
sessionRotationCount: ow.optional.number,
27+
sessionId: ow.optional.string,
2728
maxRetries: ow.optional.number,
2829
errorMessages: ow.optional.array.ofType(ow.string),
2930
headers: ow.optional.object,
@@ -170,6 +171,7 @@ class CrawleeRequest<UserData extends Dictionary = Dictionary> {
170171
noRetry = false,
171172
retryCount = 0,
172173
sessionRotationCount = 0,
174+
sessionId,
173175
maxRetries,
174176
errorMessages = [],
175177
headers = {},
@@ -185,6 +187,7 @@ class CrawleeRequest<UserData extends Dictionary = Dictionary> {
185187
loadedUrl?: string;
186188
retryCount?: number;
187189
sessionRotationCount?: number;
190+
sessionId?: string;
188191
errorMessages?: string[];
189192
handledAt?: string | Date;
190193
};
@@ -256,6 +259,7 @@ class CrawleeRequest<UserData extends Dictionary = Dictionary> {
256259
if (skipNavigation != null) this.skipNavigation = skipNavigation;
257260
if (maxRetries != null) this.maxRetries = maxRetries;
258261
if (crawlDepth != null) this.userData.__crawlee.crawlDepth ??= crawlDepth;
262+
if (sessionId) this.sessionId = sessionId;
259263

260264
// If it's already set, don't override it (for instance when fetching from storage)
261265
if (enqueueStrategy) {
@@ -332,6 +336,16 @@ class CrawleeRequest<UserData extends Dictionary = Dictionary> {
332336
}
333337
}
334338

339+
/** ID of a session to use for this request. When set, the crawler will fetch this session from the session pool instead of creating a new one. */
340+
get sessionId(): string | undefined {
341+
return this.userData.__crawlee?.sessionId;
342+
}
343+
344+
set sessionId(value: string | undefined) {
345+
(this.userData as Dictionary).__crawlee ??= {};
346+
this.userData.__crawlee.sessionId = value;
347+
}
348+
335349
/** shortcut for getting `request.userData.label` */
336350
get label(): string | undefined {
337351
return this.userData.label;
@@ -557,6 +571,12 @@ export interface RequestOptions<UserData extends Dictionary = Dictionary> {
557571
*/
558572
noRetry?: boolean;
559573

574+
/**
575+
* ID of a session from the crawler's `SessionPool` to use for this request.
576+
* When set, the crawler will fetch this session from the pool instead of creating a new one.
577+
*/
578+
sessionId?: string;
579+
560580
/**
561581
* If set to `true` then the crawler processing this request evaluates
562582
* the `requestHandler` immediately without prior browser navigation.

packages/core/test/enqueue_links/userData.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,36 @@ describe("enqueueLinks() - userData shouldn't be changed and outer label must ta
105105
expect(enqueued[0].url).toBe('https://example.com/first');
106106
expect(enqueued[0].label).toBe('first');
107107
});
108+
109+
test('sets sessionId on all enqueued requests', async () => {
110+
const { enqueued, requestQueue } = createRequestQueueMock();
111+
112+
await cheerioCrawlerEnqueueLinks({
113+
options: {
114+
sessionId: 'my-session',
115+
},
116+
$,
117+
requestQueue,
118+
originalRequestUrl: 'https://example.com',
119+
});
120+
121+
expect(enqueued).toHaveLength(2);
122+
expect(enqueued[0].userData?.__crawlee?.sessionId).toBe('my-session');
123+
expect(enqueued[1].userData?.__crawlee?.sessionId).toBe('my-session');
124+
});
125+
126+
test('does not set sessionId when option is not provided', async () => {
127+
const { enqueued, requestQueue } = createRequestQueueMock();
128+
129+
await cheerioCrawlerEnqueueLinks({
130+
options: {},
131+
$,
132+
requestQueue,
133+
originalRequestUrl: 'https://example.com',
134+
});
135+
136+
expect(enqueued).toHaveLength(2);
137+
expect(enqueued[0].userData?.__crawlee?.sessionId).toBeUndefined();
138+
expect(enqueued[1].userData?.__crawlee?.sessionId).toBeUndefined();
139+
});
108140
});

0 commit comments

Comments
 (0)