2216 lines
85 KiB
TypeScript
2216 lines
85 KiB
TypeScript
import { readFile, rm } from 'node:fs/promises';
|
|
import type { Server } from 'node:http';
|
|
import http from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
|
|
import type {
|
|
CrawlingContext,
|
|
EnqueueLinksOptions,
|
|
ErrorHandler,
|
|
RequestHandler,
|
|
RequestOptions,
|
|
Source,
|
|
} from '@crawlee/basic';
|
|
import {
|
|
BasicCrawler,
|
|
Configuration,
|
|
CriticalError,
|
|
EventType,
|
|
KeyValueStore,
|
|
MissingRouteError,
|
|
NonRetryableError,
|
|
Request,
|
|
RequestList,
|
|
RequestQueue,
|
|
} from '@crawlee/basic';
|
|
import { RequestState } from '@crawlee/core';
|
|
import type { Dictionary } from '@crawlee/utils';
|
|
import { RobotsTxtFile, sleep } from '@crawlee/utils';
|
|
import express from 'express';
|
|
import type { SetRequired } from 'type-fest';
|
|
import type { Mock } from 'vitest';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
|
|
|
|
import log from '@apify/log';
|
|
|
|
import { startExpressAppPromise } from '../../shared/_helper';
|
|
import { MemoryStorageEmulator } from '../../shared/MemoryStorageEmulator';
|
|
|
|
describe('BasicCrawler', () => {
|
|
let logLevel: number;
|
|
const localStorageEmulator = new MemoryStorageEmulator();
|
|
const events = Configuration.getEventManager();
|
|
|
|
const HOSTNAME = '127.0.0.1';
|
|
let port: number;
|
|
let server: Server;
|
|
beforeAll(async () => {
|
|
const app = express();
|
|
|
|
app.get('/', (req, res) => {
|
|
res.send(`<html><head><title>Example Domain</title></head></html>`);
|
|
});
|
|
|
|
server = await startExpressAppPromise(app, 0);
|
|
port = (server.address() as AddressInfo).port;
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
logLevel = log.getLevel();
|
|
log.setLevel(log.LEVELS.OFF);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vitest.clearAllMocks();
|
|
await localStorageEmulator.init();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await localStorageEmulator.destroy();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
log.setLevel(logLevel);
|
|
});
|
|
|
|
afterAll(() => {
|
|
server.close();
|
|
});
|
|
|
|
test('does not leak sigint events', async () => {
|
|
let count = 0;
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestHandler: () => {
|
|
count = process.listenerCount('SIGINT');
|
|
},
|
|
});
|
|
|
|
await crawler.run(['https://example.com']);
|
|
|
|
expect(process.listenerCount('SIGINT')).toBe(count - 1);
|
|
});
|
|
|
|
test('should run in parallel thru all the requests', async () => {
|
|
const sources = [...Array(500).keys()].map((index) => ({ url: `https://example.com/${index}` }));
|
|
const sourcesCopy = JSON.parse(JSON.stringify(sources));
|
|
|
|
const processed: { url: string }[] = [];
|
|
const requestList = await RequestList.open(null, sources);
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed.push({ url: request.url });
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
minConcurrency: 25,
|
|
maxConcurrency: 25,
|
|
requestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(basicCrawler.autoscaledPool!.minConcurrency).toBe(25);
|
|
expect(processed).toEqual(sourcesCopy);
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
});
|
|
|
|
test('should allow using run method multiple times', async () => {
|
|
const sources = [...Array(100).keys()].map((index) => `https://example.com/${index}`);
|
|
const sourcesCopy = JSON.parse(JSON.stringify(sources));
|
|
|
|
const processed: { url: string }[] = [];
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed.push({ url: request.url });
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
minConcurrency: 25,
|
|
maxConcurrency: 25,
|
|
requestHandler,
|
|
});
|
|
|
|
await basicCrawler.run(sources);
|
|
await basicCrawler.run(sources);
|
|
await basicCrawler.run(sources);
|
|
|
|
expect(processed).toHaveLength(sourcesCopy.length * 3);
|
|
});
|
|
|
|
test('should process 4 requests total when calling run() twice with maxRequestsPerCrawl: 2', async () => {
|
|
const processed: { url: string }[] = [];
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed.push({ url: request.url });
|
|
};
|
|
|
|
const crawler = new BasicCrawler({
|
|
maxRequestsPerCrawl: 2,
|
|
minConcurrency: 1,
|
|
maxConcurrency: 1,
|
|
requestHandler,
|
|
});
|
|
|
|
// First run should process 2 requests
|
|
await crawler.run([...Array(5).keys()].map((index) => `https://example.com/first/${index}`));
|
|
expect(processed).toHaveLength(2);
|
|
|
|
// Make sure no extra requests were enqueued
|
|
expect(await localStorageEmulator.getRequestQueueItems()).toEqual([]);
|
|
|
|
// Second run should process 2 more requests
|
|
await crawler.run([...Array(5).keys()].map((index) => `https://example.com/second/${index}`));
|
|
expect(processed).toHaveLength(4);
|
|
|
|
// Make sure no extra requests were enqueued
|
|
expect(await localStorageEmulator.getRequestQueueItems()).toEqual([]);
|
|
|
|
const processedUrls = processed.map((p) => p.url);
|
|
|
|
expect(processedUrls).toEqual([
|
|
'https://example.com/first/0',
|
|
'https://example.com/first/1',
|
|
'https://example.com/second/0',
|
|
'https://example.com/second/1',
|
|
]);
|
|
});
|
|
|
|
test('addRequests should respect maxCrawlDepth', async () => {
|
|
const processedUrls: string[] = [];
|
|
|
|
const requestHandler: RequestHandler = async ({ request, addRequests }) => {
|
|
processedUrls.push(request.url);
|
|
const url = new URL(request.url);
|
|
url.pathname = `${url.pathname}deep/`;
|
|
|
|
await addRequests([url.toString()]);
|
|
};
|
|
|
|
const crawler = new BasicCrawler({
|
|
maxCrawlDepth: 2,
|
|
maxRequestsPerCrawl: 10, // safeguard against infinite loops
|
|
requestHandler,
|
|
});
|
|
|
|
await crawler.run(['https://example.com/']);
|
|
|
|
expect(processedUrls).toEqual([
|
|
'https://example.com/',
|
|
'https://example.com/deep/',
|
|
'https://example.com/deep/deep/',
|
|
]);
|
|
});
|
|
|
|
test('enqueueLinks should respect maxCrawlDepth', async () => {
|
|
const processedUrls: string[] = [];
|
|
|
|
const requestHandler: RequestHandler = async ({ request, enqueueLinks }) => {
|
|
processedUrls.push(request.url);
|
|
const url = new URL(request.url);
|
|
url.pathname = `${url.pathname}deep/`;
|
|
|
|
await enqueueLinks({
|
|
urls: [url.toString()],
|
|
});
|
|
};
|
|
|
|
const crawler = new BasicCrawler({
|
|
maxCrawlDepth: 2,
|
|
maxRequestsPerCrawl: 10, // safeguard against infinite loops
|
|
requestHandler,
|
|
});
|
|
|
|
await crawler.run(['https://example.com/']);
|
|
|
|
expect(processedUrls).toEqual([
|
|
'https://example.com/',
|
|
'https://example.com/deep/',
|
|
'https://example.com/deep/deep/',
|
|
]);
|
|
});
|
|
|
|
describe('enqueueLinksWithCrawlDepth()', () => {
|
|
let onSkippedRequestMock: Mock;
|
|
let addRequestsBatchedMock: Mock;
|
|
let options: SetRequired<EnqueueLinksOptions, 'urls'>;
|
|
let request: Request;
|
|
let requestQueue: RequestQueue;
|
|
|
|
type EnqueueLinksWrapperOptions = Parameters<BasicCrawler['enqueueLinksWithCrawlDepth']>;
|
|
class TestCrawler extends BasicCrawler {
|
|
public exposedEnqueueLinksWithCrawlDepth(...enqueueLinksOptions: EnqueueLinksWrapperOptions) {
|
|
return this.enqueueLinksWithCrawlDepth(...enqueueLinksOptions);
|
|
}
|
|
}
|
|
|
|
const crawler = new TestCrawler({ maxCrawlDepth: 3 });
|
|
|
|
beforeEach(() => {
|
|
addRequestsBatchedMock = vi.fn().mockImplementation(async () => ({
|
|
addedRequests: [],
|
|
waitForAllRequestsToBeAdded: Promise.resolve([]),
|
|
requestsOverLimit: [],
|
|
}));
|
|
onSkippedRequestMock = vi.fn();
|
|
|
|
options = {
|
|
urls: ['https://example.com/1/', 'https://example.com/2/'],
|
|
onSkippedRequest: onSkippedRequestMock,
|
|
};
|
|
request = new Request({ url: 'https://example.com/', crawlDepth: 2 });
|
|
requestQueue = {
|
|
addRequestsBatched: addRequestsBatchedMock as RequestQueue['addRequestsBatched'],
|
|
} as RequestQueue;
|
|
});
|
|
|
|
it('should generate requests with maxCrawlDepth', async () => {
|
|
await crawler.exposedEnqueueLinksWithCrawlDepth(options, request, requestQueue);
|
|
|
|
const requests = addRequestsBatchedMock.mock.calls[0][0];
|
|
expect(requests).toHaveLength(2);
|
|
expect(requests[0]).toMatchObject({ url: 'https://example.com/1/', crawlDepth: 3 });
|
|
expect(requests[1]).toMatchObject({ url: 'https://example.com/2/', crawlDepth: 3 });
|
|
|
|
expect(onSkippedRequestMock).not.toBeCalled();
|
|
});
|
|
|
|
it('should skip requests with crawlDepth exceeding maxCrawlDepth', async () => {
|
|
const requestWithMaxDepth = new Request({ url: 'https://example.com/', crawlDepth: 3 });
|
|
await crawler.exposedEnqueueLinksWithCrawlDepth(options, requestWithMaxDepth, requestQueue);
|
|
|
|
const requests = addRequestsBatchedMock.mock.calls[0][0];
|
|
expect(requests).toHaveLength(0);
|
|
|
|
const skippedRequests = onSkippedRequestMock.mock.calls.map((call) => call[0]);
|
|
expect(skippedRequests).toHaveLength(2);
|
|
expect(skippedRequests[0]).toStrictEqual({ url: 'https://example.com/1/', reason: 'depth' });
|
|
expect(skippedRequests[1]).toStrictEqual({ url: 'https://example.com/2/', reason: 'depth' });
|
|
});
|
|
|
|
it('should respect user provided transformRequestFunction', async () => {
|
|
const transformRequestFunction = vi.fn((req: RequestOptions) => req);
|
|
const optionsWithTransform = { ...options, transformRequestFunction };
|
|
|
|
await crawler.exposedEnqueueLinksWithCrawlDepth(optionsWithTransform, request, requestQueue);
|
|
|
|
expect(transformRequestFunction).toHaveBeenCalled();
|
|
|
|
const requests = addRequestsBatchedMock.mock.calls[0][0];
|
|
expect(requests).toHaveLength(2);
|
|
expect(requests[0]).toMatchObject({ url: 'https://example.com/1/', crawlDepth: 3 });
|
|
});
|
|
});
|
|
|
|
it('addCrawlDepthRequestGenerator() should generate requests with maxCrawlDepth', async () => {
|
|
type AddCrawlDepthWrapperOptions = Parameters<BasicCrawler['addCrawlDepthRequestGenerator']>;
|
|
class TestCrawler extends BasicCrawler {
|
|
public exposedAddCrawlDepthRequestGenerator(...enqueueLinksOptions: AddCrawlDepthWrapperOptions) {
|
|
return this.addCrawlDepthRequestGenerator(...enqueueLinksOptions);
|
|
}
|
|
}
|
|
|
|
const crawler = new TestCrawler();
|
|
|
|
const requests = ['https://example.com/1/', { url: 'https://example.com/2/' }];
|
|
const newCrawlDepth = 4;
|
|
const addRequestsGenerator = crawler.exposedAddCrawlDepthRequestGenerator(requests, newCrawlDepth);
|
|
|
|
const generatedRequests: Source[] = [];
|
|
for await (const generatedRequest of addRequestsGenerator) {
|
|
generatedRequests.push(generatedRequest);
|
|
}
|
|
|
|
expect(generatedRequests).toHaveLength(2);
|
|
expect(generatedRequests[0].url).toBe('https://example.com/1/');
|
|
expect(generatedRequests[0].crawlDepth).toBe(4);
|
|
|
|
expect(generatedRequests[1].url).toBe('https://example.com/2/');
|
|
expect(generatedRequests[1].crawlDepth).toBe(4);
|
|
});
|
|
|
|
test('should correctly combine shorthand and full length options', async () => {
|
|
const shorthandOptions = {
|
|
minConcurrency: 123,
|
|
maxConcurrency: 456,
|
|
maxRequestsPerMinute: 789,
|
|
};
|
|
|
|
const autoscaledPoolOptions = {
|
|
minConcurrency: 16,
|
|
maxConcurrency: 32,
|
|
maxTasksPerMinute: 64,
|
|
};
|
|
|
|
const collectResults = (crawler: BasicCrawler): typeof shorthandOptions | typeof autoscaledPoolOptions => {
|
|
return {
|
|
minConcurrency: crawler.autoscaledPool!.minConcurrency,
|
|
maxConcurrency: crawler.autoscaledPool!.maxConcurrency,
|
|
// eslint-disable-next-line dot-notation -- accessing a private member
|
|
maxRequestsPerMinute: crawler.autoscaledPool!['maxTasksPerMinute'],
|
|
// eslint-disable-next-line dot-notation
|
|
maxTasksPerMinute: crawler.autoscaledPool!['maxTasksPerMinute'],
|
|
};
|
|
};
|
|
|
|
const requestList = await RequestList.open(null, []);
|
|
const requestHandler = async () => {};
|
|
|
|
const results = await Promise.all(
|
|
[
|
|
new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
...shorthandOptions,
|
|
}),
|
|
new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
autoscaledPoolOptions,
|
|
}),
|
|
new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
...shorthandOptions,
|
|
autoscaledPoolOptions,
|
|
}),
|
|
].map(async (c) => {
|
|
await c.run();
|
|
return collectResults(c);
|
|
}),
|
|
);
|
|
|
|
expect(results[0]).toEqual(expect.objectContaining(shorthandOptions));
|
|
|
|
expect(results[1]).toEqual(expect.objectContaining(autoscaledPoolOptions));
|
|
|
|
expect(results[2]).toEqual(expect.objectContaining(shorthandOptions));
|
|
});
|
|
|
|
test('auto-saved state object', async () => {
|
|
const sources = [...Array(50).keys()].map((index) => ({ url: `https://example.com/${index}` }));
|
|
const sourcesCopy = JSON.parse(JSON.stringify(sources));
|
|
|
|
const processed: { url: string }[] = [];
|
|
const requestList = await RequestList.open(null, sources);
|
|
const requestHandler: RequestHandler = async ({ request, crawler }) => {
|
|
await sleep(10);
|
|
const state = await crawler.useState({ processed });
|
|
state.processed.push({ url: request.url });
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
const state = await basicCrawler.useState();
|
|
|
|
expect(processed).toEqual(sourcesCopy);
|
|
expect(state.processed).toEqual(sourcesCopy);
|
|
expect(state.processed).toBe(processed);
|
|
expect(state.processed).toEqual(sourcesCopy);
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
});
|
|
|
|
test.each([
|
|
EventType.MIGRATING,
|
|
EventType.ABORTING,
|
|
])('should pause on %s event and persist RequestList state', async (event) => {
|
|
const sources = [...Array(500).keys()].map((index) => ({ url: `https://example.com/${index + 1}` }));
|
|
|
|
let persistResolve!: (value?: unknown) => void;
|
|
const persistPromise = new Promise((res) => {
|
|
persistResolve = res;
|
|
});
|
|
|
|
// Mock the calls to persist sources.
|
|
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
|
|
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
|
|
getValueSpy.mockResolvedValue(null);
|
|
|
|
const processed: { url: string }[] = [];
|
|
const requestList = await RequestList.open('reqList', sources);
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
if (request.url.endsWith('200')) events.emit(event);
|
|
processed.push({ url: request.url });
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
minConcurrency: 25,
|
|
maxConcurrency: 25,
|
|
requestHandler,
|
|
});
|
|
|
|
let finished = false;
|
|
// Mock the call to persist state.
|
|
setValueSpy.mockImplementationOnce(persistResolve as any);
|
|
// The crawler will pause after 200 requests
|
|
const runPromise = basicCrawler.run();
|
|
void runPromise.then(() => {
|
|
finished = true;
|
|
});
|
|
|
|
// need to monkeypatch the stats class, otherwise it will never finish
|
|
basicCrawler.stats.persistState = async () => Promise.resolve();
|
|
await persistPromise;
|
|
|
|
expect(finished).toBe(false);
|
|
expect(await requestList.isFinished()).toBe(false);
|
|
expect(await requestList.isEmpty()).toBe(false);
|
|
expect(processed.length).toBe(200);
|
|
|
|
expect(getValueSpy).toBeCalled();
|
|
expect(setValueSpy).toBeCalled();
|
|
|
|
// clean up
|
|
// @ts-expect-error Accessing private method
|
|
await basicCrawler.autoscaledPool!._destroy();
|
|
});
|
|
|
|
test('should retry failed requests', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
];
|
|
const processed: Dictionary<Request> = {};
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed[request.url] = request;
|
|
|
|
if (request.url === 'http://example.com/2') {
|
|
throw Error(`This is ${request.retryCount}th error!`);
|
|
}
|
|
|
|
request.userData.foo = 'bar';
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
maxRequestRetries: 10,
|
|
minConcurrency: 3,
|
|
maxConcurrency: 3,
|
|
requestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(processed['http://example.com/1'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/1'].errorMessages).toEqual([]);
|
|
expect(processed['http://example.com/1'].retryCount).toBe(0);
|
|
expect(processed['http://example.com/3'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/3'].errorMessages).toEqual([]);
|
|
expect(processed['http://example.com/3'].retryCount).toBe(0);
|
|
|
|
expect(processed['http://example.com/2'].userData.foo).toBeUndefined();
|
|
expect(processed['http://example.com/2'].errorMessages).toHaveLength(11);
|
|
expect(processed['http://example.com/2'].retryCount).toBe(10);
|
|
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
});
|
|
|
|
test('should retry failed requests based on `request.maxRetries`', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/1', maxRetries: 10 },
|
|
{ url: 'http://example.com/2', maxRetries: 5 },
|
|
{ url: 'http://example.com/3', maxRetries: 1 },
|
|
];
|
|
const processed: Dictionary<Request> = {};
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed[request.url] = request;
|
|
throw Error(`This is ${request.retryCount}th error!`);
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
minConcurrency: 3,
|
|
maxConcurrency: 3,
|
|
requestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(processed['http://example.com/1'].errorMessages).toHaveLength(11);
|
|
expect(processed['http://example.com/1'].retryCount).toBe(10);
|
|
expect(processed['http://example.com/2'].errorMessages).toHaveLength(6);
|
|
expect(processed['http://example.com/2'].retryCount).toBe(5);
|
|
expect(processed['http://example.com/3'].errorMessages).toHaveLength(2);
|
|
expect(processed['http://example.com/3'].retryCount).toBe(1);
|
|
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
});
|
|
|
|
test('should not retry requests with noRetry set to true ', async () => {
|
|
const noRetryRequest = new Request({ url: 'http://example.com/3' });
|
|
noRetryRequest.noRetry = true;
|
|
|
|
const sources = [
|
|
{ url: 'http://example.com/1', noRetry: true },
|
|
{ url: 'http://example.com/2' },
|
|
noRetryRequest,
|
|
];
|
|
const processed: Dictionary<Request> = {};
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed[request.url] = request;
|
|
request.userData.foo = 'bar';
|
|
throw Error(`This is ${request.retryCount}th error!`);
|
|
};
|
|
|
|
let failedRequestHandlerCalls = 0;
|
|
const failedRequestHandler = async () => {
|
|
failedRequestHandlerCalls++;
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
maxRequestRetries: 10,
|
|
minConcurrency: 3,
|
|
maxConcurrency: 3,
|
|
requestHandler,
|
|
failedRequestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(processed['http://example.com/1'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/1'].errorMessages).toHaveLength(1);
|
|
expect(processed['http://example.com/1'].retryCount).toBe(0);
|
|
expect(processed['http://example.com/3'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/3'].errorMessages).toHaveLength(1);
|
|
expect(processed['http://example.com/3'].retryCount).toBe(0);
|
|
|
|
expect(processed['http://example.com/2'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/2'].errorMessages).toHaveLength(11);
|
|
expect(processed['http://example.com/2'].retryCount).toBe(10);
|
|
|
|
expect(failedRequestHandlerCalls).toBe(3);
|
|
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
});
|
|
|
|
test('should correctly track request.state', async () => {
|
|
const sources = [{ url: 'http://example.com/1' }];
|
|
const requestList = await RequestList.open(null, sources);
|
|
const requestStates: RequestState[] = [];
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
requestStates.push(request.state);
|
|
throw new Error('Error');
|
|
};
|
|
|
|
const errorHandler: RequestHandler = async ({ request }) => {
|
|
requestStates.push(request.state);
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
maxConcurrency: 1,
|
|
maxRequestRetries: 1,
|
|
requestHandler,
|
|
errorHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(requestStates).toEqual([
|
|
RequestState.REQUEST_HANDLER,
|
|
RequestState.ERROR_HANDLER,
|
|
RequestState.REQUEST_HANDLER,
|
|
]);
|
|
});
|
|
|
|
test('should use errorHandler', async () => {
|
|
const sources = [{ url: 'http://example.com/', label: 'start' }];
|
|
|
|
let errorHandlerCalls = 0;
|
|
let failedRequestHandlerCalls = 0;
|
|
|
|
const failed: Dictionary<{ request: Request; error: Error }> = {};
|
|
const requestList = await RequestList.open({ sources });
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
expect(request.label).toBe(errorHandlerCalls === 0 ? 'start' : `error_${errorHandlerCalls}`);
|
|
throw new Error(`This is an error ${errorHandlerCalls}`);
|
|
};
|
|
|
|
const errorHandler: ErrorHandler = async ({ request }, error) => {
|
|
expect(error.message).toBe(`This is an error ${errorHandlerCalls}`);
|
|
errorHandlerCalls++;
|
|
request.label = `error_${errorHandlerCalls}`;
|
|
};
|
|
|
|
const failedRequestHandler: ErrorHandler = async ({ request }, error) => {
|
|
failed[request.url] = { request, error };
|
|
failedRequestHandlerCalls++;
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
errorHandler,
|
|
failedRequestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(errorHandlerCalls).toBe(3);
|
|
expect(failedRequestHandlerCalls).toBe(1);
|
|
expect(Object.values(failed)).toHaveLength(1);
|
|
expect(failed['http://example.com/'].request.label).not.toBe('start');
|
|
expect(failed['http://example.com/'].request.label).toBe('error_3');
|
|
expect(failed['http://example.com/'].error.message).toEqual('This is an error 3');
|
|
});
|
|
|
|
test('should allow to handle failed requests', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
];
|
|
const processed: Dictionary<Request> = {};
|
|
const failed: Dictionary<Request> = {};
|
|
const errors: Error[] = [];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await Promise.reject(new Error('some-error'));
|
|
processed[request.url] = request;
|
|
};
|
|
|
|
const failedRequestHandler: ErrorHandler = async ({ request }, error) => {
|
|
failed[request.url] = request;
|
|
errors.push(error);
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
failedRequestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(failed['http://example.com/1'].errorMessages).toHaveLength(4);
|
|
expect(failed['http://example.com/1'].retryCount).toBe(3);
|
|
expect(failed['http://example.com/2'].errorMessages).toHaveLength(4);
|
|
expect(failed['http://example.com/2'].retryCount).toBe(3);
|
|
expect(failed['http://example.com/3'].errorMessages).toHaveLength(4);
|
|
expect(failed['http://example.com/3'].retryCount).toBe(3);
|
|
expect(Object.values(failed)).toHaveLength(3);
|
|
expect(Object.values(processed)).toHaveLength(0);
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
errors.forEach((error) => expect(error).toBeInstanceOf(Error));
|
|
});
|
|
|
|
test('should not retry on NonRetryableError', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
];
|
|
const failed: Dictionary<Request> = {};
|
|
const errors: Error[] = [];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const requestHandler: RequestHandler = async () => {
|
|
throw new NonRetryableError('some-error');
|
|
};
|
|
|
|
const failedRequestHandler: ErrorHandler = async ({ request }, error) => {
|
|
failed[request.url] = request;
|
|
errors.push(error);
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
failedRequestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(failed['http://example.com/1'].errorMessages).toHaveLength(1);
|
|
expect(failed['http://example.com/1'].retryCount).toBe(0);
|
|
expect(failed['http://example.com/2'].errorMessages).toHaveLength(1);
|
|
expect(failed['http://example.com/2'].retryCount).toBe(0);
|
|
expect(failed['http://example.com/3'].errorMessages).toHaveLength(1);
|
|
expect(failed['http://example.com/3'].retryCount).toBe(0);
|
|
expect(Object.values(failed)).toHaveLength(3);
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
errors.forEach((error) => expect(error).toBeInstanceOf(NonRetryableError));
|
|
});
|
|
|
|
test('noRetry after calling errorHandler', async () => {
|
|
const sources = [{ url: `http://example.com` }];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
let request: Request<Dictionary<any>>;
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
errorHandler: (context, error) => {
|
|
request = context.request;
|
|
context.request.noRetry = true;
|
|
},
|
|
maxRequestRetries: 3,
|
|
requestHandler: () => {
|
|
throw new Error('Failure');
|
|
},
|
|
});
|
|
|
|
await crawler.run();
|
|
|
|
expect(request!.retryCount).toBe(0);
|
|
});
|
|
|
|
test('should crash on CriticalError', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const requestHandler: RequestHandler = async () => {
|
|
throw new CriticalError('some-error');
|
|
};
|
|
|
|
const failedRequestHandler = vitest.fn() as ErrorHandler;
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandler,
|
|
failedRequestHandler,
|
|
});
|
|
|
|
await expect(basicCrawler.run()).rejects.toThrow(CriticalError);
|
|
|
|
expect(failedRequestHandler).not.toBeCalled();
|
|
expect(await requestList.isFinished()).toBe(false);
|
|
});
|
|
|
|
test('should crash on MissingRouteError', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/1', label: 'TEST' }, // will match
|
|
{ url: 'http://example.com/2', label: 'FOO' }, // will fail as no FOO route or default route exists
|
|
{ url: 'http://example.com/3' }, // will fail as no default route exists
|
|
];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const failedRequestHandler = vitest.fn() as ErrorHandler;
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
failedRequestHandler,
|
|
});
|
|
const testRoute = vitest.fn();
|
|
basicCrawler.router.addHandler('TEST', testRoute);
|
|
|
|
await expect(basicCrawler.run()).rejects.toThrow(MissingRouteError);
|
|
|
|
expect(failedRequestHandler).not.toBeCalled();
|
|
expect(testRoute).toBeCalled();
|
|
expect(await requestList.isFinished()).toBe(false);
|
|
});
|
|
|
|
test('should correctly combine RequestList and RequestQueue', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/0' },
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
];
|
|
const processed: Dictionary<Request> = {};
|
|
const requestList = await RequestList.open(null, sources);
|
|
const requestQueue = new RequestQueue({ id: 'xxx', client: Configuration.getStorageClient() });
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed[request.url] = request;
|
|
|
|
if (request.url === 'http://example.com/1') {
|
|
throw Error(`This is ${request.retryCount}th error!`);
|
|
}
|
|
|
|
request.userData.foo = 'bar';
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
requestQueue,
|
|
maxRequestRetries: 3,
|
|
minConcurrency: 1,
|
|
maxConcurrency: 1,
|
|
requestHandler,
|
|
});
|
|
|
|
vitest.spyOn(requestQueue, 'handledCount').mockResolvedValueOnce(0);
|
|
|
|
vitest
|
|
.spyOn(requestQueue, 'addRequest')
|
|
.mockResolvedValueOnce({ requestId: 'id-0' } as any)
|
|
.mockResolvedValueOnce({ requestId: 'id-1' } as any)
|
|
.mockResolvedValueOnce({ requestId: 'id-2' } as any);
|
|
|
|
const request0 = new Request({ id: 'id-0', ...sources[0] });
|
|
const request1 = new Request({ id: 'id-1', ...sources[1] });
|
|
const request2 = new Request({ id: 'id-2', ...sources[2] });
|
|
|
|
const queueContent = [request0, request1, request2, request1, request1, request1];
|
|
|
|
vitest.spyOn(requestQueue, 'fetchNextRequest').mockImplementation(async () => queueContent.shift() ?? null);
|
|
|
|
const markReqHandled = vitest
|
|
.spyOn(requestQueue, 'markRequestHandled')
|
|
.mockReturnValue(Promise.resolve() as any);
|
|
const reclaimReq = vitest.spyOn(requestQueue, 'reclaimRequest').mockReturnValue(Promise.resolve() as any);
|
|
|
|
vitest.spyOn(requestQueue, 'isEmpty').mockImplementation(async () => queueContent.length <= 0);
|
|
vitest.spyOn(requestQueue, 'isFinished').mockResolvedValueOnce(true);
|
|
|
|
await basicCrawler.run();
|
|
|
|
// 1st try
|
|
|
|
expect(reclaimReq).toBeCalledWith(request1, expect.objectContaining({}));
|
|
expect(reclaimReq).toBeCalledTimes(3);
|
|
|
|
expect(processed['http://example.com/0'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/0'].errorMessages).toEqual([]);
|
|
expect(processed['http://example.com/0'].retryCount).toBe(0);
|
|
expect(processed['http://example.com/2'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/2'].errorMessages).toEqual([]);
|
|
expect(processed['http://example.com/2'].retryCount).toBe(0);
|
|
|
|
expect(processed['http://example.com/1'].userData.foo).toBeUndefined();
|
|
expect(processed['http://example.com/1'].errorMessages).toHaveLength(4);
|
|
expect(processed['http://example.com/1'].retryCount).toBe(3);
|
|
|
|
expect(await requestList.isFinished()).toBe(true);
|
|
expect(await requestList.isEmpty()).toBe(true);
|
|
|
|
vitest.restoreAllMocks();
|
|
});
|
|
|
|
test('should say that task is not ready requestList is not set and requestQueue is empty', async () => {
|
|
const requestQueue = new RequestQueue({ id: 'xxx', client: Configuration.getStorageClient() });
|
|
requestQueue.isEmpty = async () => Promise.resolve(true);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
requestHandler: async () => {},
|
|
});
|
|
|
|
// @ts-expect-error Accessing private prop
|
|
expect(await crawler._isTaskReadyFunction()).toBe(false);
|
|
});
|
|
|
|
test('should be possible to override isFinishedFunction and isTaskReadyFunction of underlying AutoscaledPool', async () => {
|
|
const requestQueue = new RequestQueue({ id: 'xxx', client: Configuration.getStorageClient() });
|
|
const processed: Request[] = [];
|
|
const queue: Request[] = [];
|
|
let isFinished = false;
|
|
let isFinishedFunctionCalled = false;
|
|
let isTaskReadyFunctionCalled = false;
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestQueue,
|
|
autoscaledPoolOptions: {
|
|
minConcurrency: 1,
|
|
maxConcurrency: 1,
|
|
isFinishedFunction: async () => {
|
|
isFinishedFunctionCalled = true;
|
|
return Promise.resolve(isFinished);
|
|
},
|
|
isTaskReadyFunction: async () => {
|
|
isTaskReadyFunctionCalled = true;
|
|
return Promise.resolve(!isFinished);
|
|
},
|
|
},
|
|
requestHandler: async ({ request }) => {
|
|
await sleep(10);
|
|
processed.push(request);
|
|
},
|
|
});
|
|
|
|
// Speed up the test
|
|
// @ts-expect-error Accessing private prop
|
|
basicCrawler.autoscaledPoolOptions.maybeRunIntervalSecs = 0.05;
|
|
|
|
const request0 = new Request({ url: 'http://example.com/0' });
|
|
const request1 = new Request({ url: 'http://example.com/1' });
|
|
|
|
vitest.spyOn(requestQueue, 'handledCount').mockReturnValue(Promise.resolve() as any);
|
|
|
|
let handledCount = 0;
|
|
const markRequestHandled = vitest.spyOn(requestQueue, 'markRequestHandled').mockImplementation(async () => {
|
|
handledCount++;
|
|
// Only set isFinished after both requests have been handled
|
|
if (handledCount >= 2) {
|
|
// Small delay to ensure the test can verify everything
|
|
setTimeout(() => {
|
|
isFinished = true;
|
|
}, 50);
|
|
}
|
|
return Promise.resolve() as any;
|
|
});
|
|
|
|
const isFinishedOrig = vitest.spyOn(requestQueue, 'isFinished');
|
|
|
|
requestQueue.fetchNextRequest = async () => queue.pop()!;
|
|
requestQueue.isEmpty = async () => Promise.resolve(!queue.length);
|
|
|
|
// Add requests with buffer time for crawler startup.
|
|
// Use longer delays to avoid flakiness under CPU load from parallel tests.
|
|
setTimeout(() => queue.push(request0), 500);
|
|
setTimeout(() => queue.push(request1), 1000);
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(markRequestHandled).toBeCalledWith(request0);
|
|
expect(markRequestHandled).toBeCalledWith(request1);
|
|
expect(isFinishedOrig).not.toBeCalled();
|
|
expect(isFinishedFunctionCalled).toBe(true);
|
|
expect(isTaskReadyFunctionCalled).toBe(true);
|
|
|
|
// TODO: see why the request1 was passed as a second parameter to includes
|
|
expect(processed.includes(request0)).toBe(true);
|
|
|
|
vitest.restoreAllMocks();
|
|
});
|
|
|
|
test('keepAlive', async () => {
|
|
const requestQueue = new RequestQueue({ id: 'xxx', client: Configuration.getStorageClient() });
|
|
const processed: Request[] = [];
|
|
const queue: Request[] = [];
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestQueue,
|
|
keepAlive: true,
|
|
requestHandler: async ({ request }) => {
|
|
await sleep(10);
|
|
processed.push(request);
|
|
},
|
|
});
|
|
|
|
// Speed up the test
|
|
// @ts-expect-error Accessing private prop
|
|
basicCrawler.autoscaledPoolOptions.maybeRunIntervalSecs = 0.05;
|
|
|
|
const request0 = new Request({ url: 'http://example.com/0' });
|
|
const request1 = new Request({ url: 'http://example.com/1' });
|
|
|
|
vitest.spyOn(requestQueue, 'handledCount').mockReturnValue(Promise.resolve() as any);
|
|
const markRequestHandled = vitest
|
|
.spyOn(requestQueue, 'markRequestHandled')
|
|
.mockReturnValue(Promise.resolve() as any);
|
|
|
|
const isFinishedOrig = vitest.spyOn(requestQueue, 'isFinished');
|
|
|
|
requestQueue.fetchNextRequest = async () => Promise.resolve(queue.pop()!);
|
|
requestQueue.isEmpty = async () => Promise.resolve(!queue.length);
|
|
|
|
// Use longer delays to avoid flakiness under CPU load from parallel tests.
|
|
setTimeout(() => queue.push(request0), 500);
|
|
setTimeout(() => queue.push(request1), 1000);
|
|
setTimeout(() => {
|
|
void basicCrawler.teardown();
|
|
}, 3000);
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(markRequestHandled).toBeCalledWith(request0);
|
|
expect(markRequestHandled).toBeCalledWith(request1);
|
|
expect(isFinishedOrig).not.toBeCalled();
|
|
|
|
// TODO: see why the request1 was passed as a second parameter to includes
|
|
expect(processed.includes(request0)).toBe(true);
|
|
|
|
vitest.restoreAllMocks();
|
|
});
|
|
|
|
test('should support maxRequestsPerCrawl parameter', async () => {
|
|
const sources = [
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
{ url: 'http://example.com/4' },
|
|
{ url: 'http://example.com/5' },
|
|
];
|
|
const processed: Dictionary<Request> = {};
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const requestHandler: RequestHandler = async ({ request }) => {
|
|
await sleep(10);
|
|
processed[request.url] = request;
|
|
if (request.url === 'http://example.com/2') throw Error();
|
|
request.userData.foo = 'bar';
|
|
};
|
|
|
|
let failedRequestHandlerCalls = 0;
|
|
const failedRequestHandler = async () => {
|
|
failedRequestHandlerCalls++;
|
|
};
|
|
|
|
const basicCrawler = new BasicCrawler({
|
|
requestList,
|
|
maxRequestRetries: 3,
|
|
maxRequestsPerCrawl: 3,
|
|
maxConcurrency: 1,
|
|
requestHandler,
|
|
failedRequestHandler,
|
|
});
|
|
|
|
await basicCrawler.run();
|
|
|
|
expect(processed['http://example.com/1'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/1'].errorMessages).toEqual([]);
|
|
expect(processed['http://example.com/1'].retryCount).toBe(0);
|
|
expect(processed['http://example.com/3'].userData.foo).toBe('bar');
|
|
expect(processed['http://example.com/3'].errorMessages).toEqual([]);
|
|
expect(processed['http://example.com/3'].retryCount).toBe(0);
|
|
|
|
expect(processed['http://example.com/2'].userData.foo).toEqual(undefined);
|
|
expect(processed['http://example.com/2'].errorMessages).toHaveLength(4);
|
|
expect(processed['http://example.com/2'].retryCount).toBe(3);
|
|
|
|
expect(failedRequestHandlerCalls).toBe(1);
|
|
|
|
expect(await requestList.isFinished()).toBe(false);
|
|
expect(await requestList.isEmpty()).toBe(false);
|
|
});
|
|
|
|
test('should derive handledRequestCount from Statistics', async () => {
|
|
const requestQueue = new RequestQueue({ id: 'id', client: Configuration.getStorageClient() });
|
|
requestQueue.isEmpty = async () => false;
|
|
requestQueue.isFinished = async () => false;
|
|
|
|
requestQueue.fetchNextRequest = async () => new Request({ id: 'id', url: 'http://example.com' });
|
|
// @ts-expect-error Overriding the method for testing purposes
|
|
requestQueue.markRequestHandled = async () => {};
|
|
|
|
// Even though the request queue reports 33 handled requests (e.g. from a previous crawler run),
|
|
// the crawler should use its own Statistics to track the count and process all 40 requests.
|
|
vitest.spyOn(requestQueue, 'handledCount').mockResolvedValue(33);
|
|
|
|
let count = 0;
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxConcurrency: 1,
|
|
requestHandler: async () => {
|
|
await sleep(1);
|
|
count++;
|
|
},
|
|
maxRequestsPerCrawl: 40,
|
|
});
|
|
|
|
await crawler.run();
|
|
// The crawler should have processed 40 requests (its own limit), not 7 (40 - 33).
|
|
expect(count).toBe(40);
|
|
vitest.restoreAllMocks();
|
|
});
|
|
|
|
test('should timeout after handleRequestTimeoutSecs', async () => {
|
|
const url = 'https://example.com';
|
|
const requestList = await RequestList.open({ sources: [{ url }] });
|
|
|
|
const results: Request[] = [];
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
handleRequestTimeoutSecs: 0.01,
|
|
maxRequestRetries: 1,
|
|
requestHandler: async () => sleep(1000),
|
|
failedRequestHandler: async ({ request }) => {
|
|
results.push(request);
|
|
},
|
|
});
|
|
|
|
await crawler.run();
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].url).toEqual(url);
|
|
results[0].errorMessages.forEach((msg) => expect(msg).toMatch('requestHandler timed out'));
|
|
});
|
|
|
|
test('limits handleRequestTimeoutSecs and derived vars to a valid value', async () => {
|
|
const url = 'https://example.com';
|
|
const requestList = await RequestList.open({ sources: [{ url }] });
|
|
|
|
const results = [];
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandlerTimeoutSecs: Infinity,
|
|
maxRequestRetries: 1,
|
|
requestHandler: async () => sleep(1000),
|
|
failedRequestHandler: async ({ request }) => {
|
|
results.push(request);
|
|
},
|
|
});
|
|
|
|
const maxSignedInteger = 2 ** 31 - 1;
|
|
// @ts-expect-error Accessing private prop
|
|
expect(crawler.requestHandlerTimeoutMillis).toBe(maxSignedInteger);
|
|
// @ts-expect-error Accessing private prop
|
|
expect(crawler.internalTimeoutMillis).toBe(maxSignedInteger);
|
|
});
|
|
|
|
test('should not log stack trace for timeout errors by default', async () => {
|
|
const sources = [{ url: `http://${HOSTNAME}:${port}` }];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandlerTimeoutSecs: 0.1,
|
|
maxRequestRetries: 3,
|
|
requestHandler: async () => sleep(1e3),
|
|
});
|
|
|
|
const warningSpy = vitest.spyOn(crawler.log, 'warning');
|
|
const errorSpy = vitest.spyOn(crawler.log, 'error');
|
|
|
|
await crawler.run();
|
|
|
|
expect(warningSpy.mock.calls.length).toBe(3);
|
|
for (const args of warningSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Reclaiming failed request back to the list or queue/.test(args[0])).toBe(true);
|
|
expect(/requestHandler timed out after/.test(args[0])).toBe(true);
|
|
expect(/at Timeout\._onTimeout/.test(args[0])).toBe(false);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
|
|
expect(errorSpy.mock.calls.length).toBe(1);
|
|
for (const args of errorSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Request failed and reached maximum retries/.test(args[0])).toBe(true);
|
|
expect(/requestHandler timed out after/.test(args[0])).toBe(true);
|
|
expect(/at Timeout\._onTimeout/.test(args[0])).toBe(false);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('should log stack trace for non-timeout errors only when request will no longer be retried by default', async () => {
|
|
const sources = [{ url: `http://${HOSTNAME}:${port}` }];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
maxRequestRetries: 3,
|
|
requestHandler: () => {
|
|
throw new Error('Other non-timeout error');
|
|
},
|
|
});
|
|
|
|
const warningSpy = vitest.spyOn(crawler.log, 'warning');
|
|
const errorSpy = vitest.spyOn(crawler.log, 'error');
|
|
|
|
await crawler.run();
|
|
|
|
expect(warningSpy.mock.calls.length).toBe(3);
|
|
for (const args of warningSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Reclaiming failed request back to the list or queue/.test(args[0])).toBe(true);
|
|
expect(/Other non-timeout error/.test(args[0])).toBe(true);
|
|
expect(args[0].split('\n').length).toBeLessThanOrEqual(2);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
|
|
expect(errorSpy.mock.calls.length).toBe(1);
|
|
for (const args of errorSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Request failed and reached maximum retries/.test(args[0])).toBe(true);
|
|
expect(/Other non-timeout error/.test(args[0])).toBe(true);
|
|
expect(/at _?BasicCrawler\.requestHandler/.test(args[0])).toBe(true);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('should log stack trace for timeout errors when verbose log is enabled', async () => {
|
|
log.setLevel(log.LEVELS.INFO);
|
|
process.env.CRAWLEE_VERBOSE_LOG = 'true';
|
|
const sources = [{ url: `http://${HOSTNAME}:${port}` }];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
requestHandlerTimeoutSecs: 0.1,
|
|
maxRequestRetries: 3,
|
|
requestHandler: async () => sleep(1e3),
|
|
});
|
|
|
|
const warningSpy = vitest.spyOn(crawler.log, 'warning');
|
|
const errorSpy = vitest.spyOn(crawler.log, 'error');
|
|
|
|
await crawler.run();
|
|
|
|
expect(warningSpy.mock.calls.length).toBe(3);
|
|
for (const args of warningSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Reclaiming failed request back to the list or queue/.test(args[0])).toBe(true);
|
|
expect(/requestHandler timed out after/.test(args[0])).toBe(true);
|
|
expect(/at Timeout\._onTimeout/.test(args[0])).toBe(true);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
|
|
expect(errorSpy.mock.calls.length).toBe(1);
|
|
for (const args of errorSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Request failed and reached maximum retries/.test(args[0])).toBe(true);
|
|
expect(/requestHandler timed out after/.test(args[0])).toBe(true);
|
|
expect(/at Timeout\._onTimeout/.test(args[0])).toBe(true);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
|
|
log.setLevel(log.LEVELS.OFF);
|
|
process.env.CRAWLEE_VERBOSE_LOG = undefined;
|
|
});
|
|
|
|
test('should log stack trace for non-timeout errors when verbose log is enabled', async () => {
|
|
log.setLevel(log.LEVELS.INFO);
|
|
process.env.CRAWLEE_VERBOSE_LOG = 'true';
|
|
const sources = [{ url: `http://${HOSTNAME}:${port}` }];
|
|
const requestList = await RequestList.open(null, sources);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
maxRequestRetries: 3,
|
|
requestHandler: () => {
|
|
throw new Error('Other non-timeout error');
|
|
},
|
|
});
|
|
|
|
const warningSpy = vitest.spyOn(crawler.log, 'warning');
|
|
const errorSpy = vitest.spyOn(crawler.log, 'error');
|
|
|
|
await crawler.run();
|
|
|
|
expect(warningSpy.mock.calls.length).toBe(3);
|
|
for (const args of warningSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Reclaiming failed request back to the list or queue/.test(args[0])).toBe(true);
|
|
expect(/Other non-timeout error/.test(args[0])).toBe(true);
|
|
expect(/at _?BasicCrawler\.requestHandler/.test(args[0])).toBe(true);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
|
|
expect(errorSpy.mock.calls.length).toBe(1);
|
|
for (const args of errorSpy.mock.calls) {
|
|
expect(args.length).toBe(2);
|
|
expect(typeof args[0]).toBe('string');
|
|
expect(/Request failed and reached maximum retries/.test(args[0])).toBe(true);
|
|
expect(/Other non-timeout error/.test(args[0])).toBe(true);
|
|
expect(/at _?BasicCrawler\.requestHandler/.test(args[0])).toBe(true);
|
|
expect(args[1]).toBeDefined();
|
|
}
|
|
|
|
log.setLevel(log.LEVELS.OFF);
|
|
process.env.CRAWLEE_VERBOSE_LOG = undefined;
|
|
});
|
|
|
|
describe('Uses SessionPool', () => {
|
|
it('should use SessionPool when useSessionPool is true ', async () => {
|
|
const url = 'https://example.com';
|
|
const requestList = await RequestList.open({ sources: [{ url }] });
|
|
const results: Request[] = [];
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
handleRequestTimeoutSecs: 0.01,
|
|
maxRequestRetries: 1,
|
|
useSessionPool: true,
|
|
sessionPoolOptions: {
|
|
maxPoolSize: 10,
|
|
persistStateKey: 'POOL',
|
|
},
|
|
requestHandler: async ({ session }) => {
|
|
expect(session!.constructor.name).toEqual('Session');
|
|
expect(session!.id).toBeDefined();
|
|
},
|
|
failedRequestHandler: async ({ request }) => {
|
|
results.push(request);
|
|
},
|
|
});
|
|
|
|
await crawler.run();
|
|
expect(crawler.sessionPool).toBeDefined();
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should use pass options to sessionPool', async () => {
|
|
const url = 'https://example.com';
|
|
const requestList = await RequestList.open({ sources: [{ url }] });
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
handleRequestTimeoutSecs: 0.01,
|
|
maxRequestRetries: 1,
|
|
useSessionPool: true,
|
|
sessionPoolOptions: {
|
|
maxPoolSize: 10,
|
|
persistStateKey: 'POOL',
|
|
},
|
|
requestHandler: async () => {},
|
|
failedRequestHandler: async () => {},
|
|
});
|
|
await crawler.run();
|
|
|
|
// @ts-expect-error private symbol
|
|
expect(crawler.sessionPool.maxPoolSize).toEqual(10);
|
|
});
|
|
|
|
it('should destroy Session pool after it is finished', async () => {
|
|
const url = 'https://example.com';
|
|
const requestList = await RequestList.open({ sources: [{ url }] });
|
|
events.off(EventType.PERSIST_STATE);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
handleRequestTimeoutSecs: 0.01,
|
|
maxRequestRetries: 1,
|
|
useSessionPool: true,
|
|
sessionPoolOptions: {
|
|
maxPoolSize: 10,
|
|
},
|
|
requestHandler: async () => {},
|
|
failedRequestHandler: async () => {},
|
|
});
|
|
|
|
await crawler.run();
|
|
expect(events.listenerCount(EventType.PERSIST_STATE)).toEqual(0);
|
|
// @ts-expect-error private symbol
|
|
expect(crawler.sessionPool.maxPoolSize).toEqual(10);
|
|
});
|
|
});
|
|
|
|
describe('CrawlingContext', () => {
|
|
test('should be kept and later deleted', async () => {
|
|
const urls = [
|
|
'https://example.com/0',
|
|
'https://example.com/1',
|
|
'https://example.com/2',
|
|
'https://example.com/3',
|
|
];
|
|
const requestList = await RequestList.open(null, urls);
|
|
let counter = 0;
|
|
let finish: (value?: unknown) => void;
|
|
const allFinishedPromise = new Promise((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
const mainContexts: CrawlingContext[] = [];
|
|
const otherContexts: CrawlingContext[][] = [];
|
|
const crawler = new BasicCrawler({
|
|
requestList,
|
|
minConcurrency: 4,
|
|
async requestHandler(crawlingContext) {
|
|
// @ts-expect-error Accessing private prop
|
|
mainContexts[counter] = crawler.crawlingContexts.get(crawlingContext.id);
|
|
// @ts-expect-error Accessing private prop
|
|
otherContexts[counter] = Array.from(crawler.crawlingContexts).map(([, v]) => v);
|
|
counter++;
|
|
if (counter === 4) finish();
|
|
await allFinishedPromise;
|
|
},
|
|
});
|
|
await crawler.run();
|
|
|
|
expect(counter).toBe(4);
|
|
expect(mainContexts).toHaveLength(4);
|
|
expect(otherContexts).toHaveLength(4);
|
|
// @ts-expect-error Accessing private prop
|
|
expect(crawler.crawlingContexts.size).toBe(0);
|
|
mainContexts.forEach((ctx, idx) => {
|
|
expect(typeof ctx.id).toBe('string');
|
|
expect(otherContexts[idx]).toContain(ctx);
|
|
});
|
|
otherContexts.forEach((list, idx) => {
|
|
expect(list).toHaveLength(idx + 1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('sendRequest', () => {
|
|
const html = `<!DOCTYPE html><html><head><title>foobar</title></head><body><p>Hello, world!</p></body></html>`;
|
|
|
|
const httpServer = http.createServer((request, response) => {
|
|
response.setHeader('content-type', 'text/html');
|
|
response.end(html);
|
|
});
|
|
|
|
let url: string;
|
|
|
|
beforeAll(async () => {
|
|
await new Promise<void>((resolve) => {
|
|
httpServer.listen(0, () => {
|
|
url = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/`;
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise((resolve) => httpServer.close(resolve));
|
|
});
|
|
|
|
test('works', async () => {
|
|
const responses: { statusCode: number; body: string }[] = [];
|
|
|
|
const requestList = await RequestList.open(null, [url]);
|
|
|
|
const crawler = new BasicCrawler({
|
|
useSessionPool: true,
|
|
requestList,
|
|
async requestHandler({ sendRequest }) {
|
|
const response = await sendRequest();
|
|
|
|
responses.push({
|
|
statusCode: response.statusCode,
|
|
body: response.body,
|
|
});
|
|
},
|
|
});
|
|
|
|
await crawler.run();
|
|
|
|
expect(responses).toStrictEqual([
|
|
{
|
|
statusCode: 200,
|
|
body: html,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('works without session', async () => {
|
|
const requestList = await RequestList.open(null, [url]);
|
|
|
|
const responses: { statusCode: number; body: string }[] = [];
|
|
|
|
const crawler = new BasicCrawler({
|
|
useSessionPool: false,
|
|
requestList,
|
|
async requestHandler({ sendRequest }) {
|
|
const response = await sendRequest();
|
|
|
|
responses.push({
|
|
statusCode: response.statusCode,
|
|
body: response.body,
|
|
});
|
|
},
|
|
});
|
|
|
|
await crawler.run();
|
|
|
|
expect(responses).toStrictEqual([
|
|
{
|
|
statusCode: 200,
|
|
body: html,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('proxyUrl TypeScript support', async () => {
|
|
const crawler = new BasicCrawler({
|
|
useSessionPool: true,
|
|
async requestHandler({ sendRequest }) {
|
|
await sendRequest({
|
|
proxyUrl: 'http://example.com',
|
|
});
|
|
},
|
|
});
|
|
|
|
expect(crawler).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('Request enqueuing limits with maxRequestsPerCrawl', () => {
|
|
test('should not enqueue more requests than maxRequestsPerCrawl allows', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
const addRequestsBatchedSpy = vitest.spyOn(requestQueue, 'addRequestsBatched');
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxRequestsPerCrawl: 5,
|
|
requestHandler: async () => {},
|
|
});
|
|
|
|
crawler.stats.state.requestsFinished = 2;
|
|
|
|
// Try to add 6 requests - should only add 3 due to limit
|
|
const requestsToAdd = [
|
|
'http://example.com/1',
|
|
'http://example.com/2',
|
|
'http://example.com/3',
|
|
'http://example.com/4',
|
|
'http://example.com/5',
|
|
'http://example.com/6',
|
|
];
|
|
|
|
await crawler.addRequests(requestsToAdd);
|
|
|
|
// Should only have added the first 3 requests (since 2 were already processed, limit allows 3 more)
|
|
expect(addRequestsBatchedSpy).toHaveBeenCalledOnce();
|
|
await expect(localStorageEmulator.getRequestQueueItems()).resolves.toMatchObject([
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
]);
|
|
});
|
|
|
|
test('should not enqueue more requests than maxRequestsPerCrawl allows across multiple addRequests calls', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
const addRequestsBatchedSpy = vitest.spyOn(requestQueue, 'addRequestsBatched');
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxRequestsPerCrawl: 5,
|
|
requestHandler: async () => {},
|
|
});
|
|
|
|
crawler.stats.state.requestsFinished = 1;
|
|
|
|
// First call - should add 2 requests (2 more slots to go)
|
|
await crawler.addRequests(['http://example.com/1', 'http://example.com/2']);
|
|
|
|
await expect(localStorageEmulator.getRequestQueueItems()).resolves.toMatchObject([
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
]);
|
|
|
|
// Second call - should add only 2 more requests
|
|
await crawler.addRequests([
|
|
'http://example.com/3',
|
|
'http://example.com/4',
|
|
'http://example.com/5', // This should be ignored
|
|
'http://example.com/6', // This should be ignored
|
|
]);
|
|
|
|
await expect(localStorageEmulator.getRequestQueueItems()).resolves.toMatchObject([
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
{ url: 'http://example.com/4' },
|
|
]);
|
|
|
|
// Third call - should add no requests (limit already reached)
|
|
await crawler.addRequests(['http://example.com/7', 'http://example.com/8']);
|
|
|
|
await expect(localStorageEmulator.getRequestQueueItems()).resolves.toMatchObject([
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/2' },
|
|
{ url: 'http://example.com/3' },
|
|
{ url: 'http://example.com/4' },
|
|
]);
|
|
|
|
expect(addRequestsBatchedSpy).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test('should respect robots.txt when limiting requests', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
const addRequestsBatchedSpy = vitest.spyOn(requestQueue, 'addRequestsBatched');
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxRequestsPerCrawl: 2,
|
|
respectRobotsTxtFile: true,
|
|
requestHandler: async () => {},
|
|
});
|
|
|
|
crawler.stats.state.requestsFinished = 0;
|
|
|
|
// Mock robots.txt checking to disallow some URLs
|
|
vitest.spyOn(crawler as any, 'isAllowedBasedOnRobotsTxtFile').mockImplementation(async (url) => {
|
|
return url !== 'http://example.com/2';
|
|
});
|
|
|
|
await crawler.addRequests([
|
|
'http://example.com/1', // Allowed by robots.txt
|
|
'http://example.com/2', // Blocked by robots.txt
|
|
'http://example.com/3', // Allowed by robots.txt && within limit
|
|
'http://example.com/4', // Would exceed limit
|
|
]);
|
|
|
|
await expect(localStorageEmulator.getRequestQueueItems()).resolves.toMatchObject([
|
|
{ url: 'http://example.com/1' },
|
|
{ url: 'http://example.com/3' },
|
|
]);
|
|
|
|
// Should only have added the first request (allowed by robots.txt and within limit)
|
|
expect(addRequestsBatchedSpy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test.each([
|
|
{
|
|
testName: 'custom user-agent robots.txt rules',
|
|
userAgent: 'MyCrawler',
|
|
visitedUrls: [
|
|
{
|
|
url: 'http://example.com/yes',
|
|
},
|
|
{
|
|
url: 'http://example.com/my-crawler/anything',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
testName: 'catch-all robots.txt rules with custom user-agent',
|
|
userAgent: 'RandomCrawler',
|
|
visitedUrls: [
|
|
{
|
|
url: 'http://example.com/yes',
|
|
},
|
|
],
|
|
},
|
|
])('should respect $testName', async ({ userAgent, visitedUrls }) => {
|
|
const requestQueue = await RequestQueue.open();
|
|
const addRequestsBatchedSpy = vitest.spyOn(requestQueue, 'addRequestsBatched');
|
|
|
|
const crawler = new (class MockedRobotsTxtCrawler extends BasicCrawler {
|
|
override async getRobotsTxtFileForUrl(_: string) {
|
|
return RobotsTxtFile.from(
|
|
'http://example.com/robots.txt',
|
|
`User-agent: *
|
|
Disallow: /
|
|
Allow: /yes
|
|
|
|
User-agent: MyCrawler
|
|
Disallow: /no
|
|
Allow: /my-crawler
|
|
`,
|
|
);
|
|
}
|
|
})({
|
|
requestQueue,
|
|
respectRobotsTxtFile: { userAgent },
|
|
requestHandler: async () => {},
|
|
});
|
|
|
|
await crawler.addRequests([
|
|
'http://example.com/yes', // Allowed by robots.txt
|
|
'http://example.com/no', // Blocked by robots.txt for "MyCrawler"
|
|
'http://example.com/no-globally', // Blocked by robots.txt, "*" rule
|
|
'http://example.com/my-crawler/anything', // Blocked by robots.txt for all user-agents, but allowed for "MyCrawler"
|
|
]);
|
|
|
|
await expect(localStorageEmulator.getRequestQueueItems()).resolves.toMatchObject(visitedUrls);
|
|
|
|
// Should only have added the first request (allowed by robots.txt and within limit)
|
|
expect(addRequestsBatchedSpy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test('enqueueLinks should respect custom user-agent robots.txt rules', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
const visitedUrls: string[] = [];
|
|
|
|
const crawler = new (class MockedRobotsTxtCrawler extends BasicCrawler {
|
|
override async getRobotsTxtFileForUrl(_: string) {
|
|
return RobotsTxtFile.from(
|
|
'http://example.com/robots.txt',
|
|
`User-agent: *
|
|
Disallow: /
|
|
Allow: /yes
|
|
|
|
User-agent: MyCrawler
|
|
Disallow: /no
|
|
Allow: /my-crawler
|
|
`,
|
|
);
|
|
}
|
|
})({
|
|
requestQueue,
|
|
maxConcurrency: 1,
|
|
respectRobotsTxtFile: { userAgent: 'MyCrawler' },
|
|
requestHandler: async (context) => {
|
|
visitedUrls.push(context.request.url);
|
|
|
|
if (context.request.label) {
|
|
return;
|
|
}
|
|
|
|
await context.enqueueLinks({
|
|
urls: [
|
|
'http://example.com/yes',
|
|
'http://example.com/no',
|
|
'http://example.com/no-globally',
|
|
'http://example.com/my-crawler/anything',
|
|
],
|
|
label: 'child',
|
|
});
|
|
},
|
|
});
|
|
|
|
await crawler.run(['http://example.com/start']);
|
|
|
|
expect(visitedUrls).toEqual([
|
|
'http://example.com/start',
|
|
'http://example.com/yes',
|
|
'http://example.com/my-crawler/anything',
|
|
]);
|
|
});
|
|
|
|
test('enqueueLinks forwards respectRobotsTxtFile.userAgent to the robots.txt check', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
const isAllowedSpy = vitest.fn(() => true);
|
|
|
|
const crawler = new (class MockedRobotsTxtCrawler extends BasicCrawler {
|
|
override async getRobotsTxtFileForUrl(_: string) {
|
|
return { isAllowed: isAllowedSpy } as unknown as RobotsTxtFile;
|
|
}
|
|
})({
|
|
requestQueue,
|
|
maxConcurrency: 1,
|
|
respectRobotsTxtFile: { userAgent: 'MyCrawler' },
|
|
requestHandler: async (context) => {
|
|
if (context.request.label) return;
|
|
await context.enqueueLinks({ urls: ['http://example.com/child'], label: 'child' });
|
|
},
|
|
});
|
|
|
|
await crawler.run(['http://example.com/start']);
|
|
|
|
expect(isAllowedSpy).toHaveBeenCalledWith('http://example.com/child', 'MyCrawler');
|
|
});
|
|
|
|
test('enqueueLinks should respect maxRequestsPerCrawl', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
const addRequestsBatchedSpy = vitest.spyOn(requestQueue, 'addRequestsBatched');
|
|
|
|
// Will try to add 6 requests - should only add 3 due to limit
|
|
const requestsToAdd = [
|
|
'http://example.com/1',
|
|
'http://example.com/2',
|
|
'http://example.com/3',
|
|
'http://example.com/4',
|
|
'http://example.com/5',
|
|
'http://example.com/6',
|
|
];
|
|
const visitedUrls: string[] = [];
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxRequestsPerCrawl: 5,
|
|
requestHandler: async (context) => {
|
|
visitedUrls.push(context.request.url);
|
|
|
|
if (context.request.label) {
|
|
return;
|
|
}
|
|
|
|
crawler.stats.state.requestsFinished = 2;
|
|
|
|
await context.enqueueLinks({ urls: requestsToAdd, label: 'not-undefined' });
|
|
},
|
|
});
|
|
|
|
await crawler.run(['http://example.com']);
|
|
|
|
expect(visitedUrls).toEqual([
|
|
'http://example.com', // added by crawler.run()
|
|
'http://example.com/1',
|
|
'http://example.com/2',
|
|
]);
|
|
|
|
// Should only have added the first 2 requests (since 2 were already processed and 1 is in progress, limit allows 2 more)
|
|
expect(addRequestsBatchedSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('enqueueLinks limit log message should only be logged once', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
|
|
// Will try to add 10 requests with a limit of 2
|
|
const requestsToAdd = Array.from({ length: 10 }, (_, i) => `http://example.com/${i}`);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
requestHandler: async (context) => {
|
|
if (context.request.label) {
|
|
return;
|
|
}
|
|
|
|
await context.enqueueLinks({ urls: requestsToAdd, limit: 2, label: 'child' });
|
|
},
|
|
});
|
|
|
|
const infoSpy = vitest.spyOn(crawler.log, 'info');
|
|
|
|
await crawler.run(['http://example.com']);
|
|
|
|
// The enqueueLinks limit message should only appear once, not 8 times (for each skipped request)
|
|
const enqueueLimitMessages = infoSpy.mock.calls.filter(
|
|
(call) => typeof call[0] === 'string' && call[0].includes('enqueueLinks limit'),
|
|
);
|
|
expect(enqueueLimitMessages).toHaveLength(1);
|
|
});
|
|
|
|
test('enqueueLinks limit log message should be logged again on subsequent runs', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
|
|
const requestsToAdd = Array.from({ length: 5 }, (_, i) => `http://example.com/${i}`);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
requestHandler: async (context) => {
|
|
if (context.request.label) {
|
|
return;
|
|
}
|
|
|
|
await context.enqueueLinks({ urls: requestsToAdd, limit: 1, label: 'child' });
|
|
},
|
|
});
|
|
|
|
const infoSpy = vitest.spyOn(crawler.log, 'info');
|
|
|
|
// First run
|
|
await crawler.run(['http://example.com/first']);
|
|
|
|
// Second run with a new start URL
|
|
await crawler.run(['http://example.com/second']);
|
|
|
|
// The enqueueLinks limit message should appear twice (once per run)
|
|
const enqueueLimitMessages = infoSpy.mock.calls.filter(
|
|
(call) => typeof call[0] === 'string' && call[0].includes('enqueueLinks limit'),
|
|
);
|
|
expect(enqueueLimitMessages).toHaveLength(2);
|
|
});
|
|
|
|
test('enqueueLinks limit log message should be logged once per request handler, not once per run', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
|
|
// Each handler will try to add 5 URLs with a limit of 1
|
|
const requestsToAdd = Array.from({ length: 5 }, (_, i) => `http://example.com/child${i}`);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
requestHandler: async (context) => {
|
|
if (context.request.label === 'child') {
|
|
return;
|
|
}
|
|
|
|
await context.enqueueLinks({ urls: requestsToAdd, limit: 1, label: 'child' });
|
|
},
|
|
});
|
|
|
|
const infoSpy = vitest.spyOn(crawler.log, 'info');
|
|
|
|
// Single run with two initial requests - both will trigger the limit
|
|
await crawler.run(['http://example.com/first', 'http://example.com/second']);
|
|
|
|
// The enqueueLinks limit message should appear twice (once per request handler that triggered the limit)
|
|
const enqueueLimitMessages = infoSpy.mock.calls.filter(
|
|
(call) => typeof call[0] === 'string' && call[0].includes('enqueueLinks limit'),
|
|
);
|
|
expect(enqueueLimitMessages).toHaveLength(2);
|
|
});
|
|
|
|
test('maxCrawlDepth limit log message should only be logged once per run', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
|
|
// Each handler will try to add URLs that exceed maxCrawlDepth
|
|
const requestsToAdd = Array.from({ length: 5 }, (_, i) => `http://example.com/child${i}`);
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxCrawlDepth: 1, // Only allow depth 0 (initial) and depth 1 (first level children)
|
|
requestHandler: async (context) => {
|
|
// Stop processing at depth 2 to avoid infinite loops
|
|
if (context.request.crawlDepth >= 2) {
|
|
return;
|
|
}
|
|
|
|
// This will add requests at depth+1, so initial requests add at depth 1 (allowed)
|
|
// and depth 1 requests add at depth 2 (blocked by maxCrawlDepth)
|
|
await context.enqueueLinks({
|
|
urls: requestsToAdd,
|
|
label: `depth-${context.request.crawlDepth + 1}`,
|
|
});
|
|
},
|
|
});
|
|
|
|
const infoSpy = vitest.spyOn(crawler.log, 'info');
|
|
|
|
// Run with two initial requests
|
|
// Each will enqueue children at depth 1, then those children will try to enqueue at depth 2 (blocked)
|
|
await crawler.run(['http://example.com/first', 'http://example.com/second']);
|
|
|
|
// The maxCrawlDepth limit message should only appear once per run, even though multiple requests triggered it
|
|
const maxCrawlDepthMessages = infoSpy.mock.calls.filter(
|
|
(call) => typeof call[0] === 'string' && call[0].includes('maxCrawlDepth'),
|
|
);
|
|
expect(maxCrawlDepthMessages).toHaveLength(1);
|
|
});
|
|
|
|
test('should not count duplicate URLs toward maxRequestsPerCrawl limit (addRequests)', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxRequestsPerCrawl: 5,
|
|
requestHandler: async () => {},
|
|
});
|
|
|
|
// 10 duplicate links to the same URL + 1 unique link at the end
|
|
const requestsToAdd = [
|
|
...Array.from({ length: 10 }, () => 'http://example.com/same'),
|
|
'http://example.com/new',
|
|
];
|
|
|
|
await crawler.addRequests(requestsToAdd);
|
|
|
|
// Both unique URLs should have been enqueued — duplicates should not consume the budget
|
|
await expect(localStorageEmulator.getRequestQueueItems()).resolves.toMatchObject([
|
|
{ url: 'http://example.com/same' },
|
|
{ url: 'http://example.com/new' },
|
|
]);
|
|
});
|
|
|
|
test('addRequestsBatched with maxNewRequests should correctly report requestsOverLimit for array input', async () => {
|
|
const queue = await RequestQueue.open();
|
|
|
|
const result = await queue.addRequestsBatched(
|
|
[
|
|
{ url: 'http://example.com/a' },
|
|
{ url: 'http://example.com/b' },
|
|
{ url: 'http://example.com/c' },
|
|
{ url: 'http://example.com/d' },
|
|
{ url: 'http://example.com/e' },
|
|
],
|
|
{ maxNewRequests: 2 },
|
|
);
|
|
|
|
const addedUrls = result.addedRequests.filter((r) => !r.wasAlreadyPresent).map((r) => r.uniqueKey);
|
|
|
|
const overLimitUrls = (result.requestsOverLimit ?? []).map((r) => (typeof r === 'string' ? r : r.url));
|
|
|
|
expect(addedUrls).toHaveLength(2);
|
|
expect(overLimitUrls).toHaveLength(3);
|
|
});
|
|
|
|
test('addRequestsBatched with maxNewRequests should correctly report requestsOverLimit for generator input', async () => {
|
|
const queue = await RequestQueue.open();
|
|
|
|
async function* urls() {
|
|
yield { url: 'http://example.com/a' };
|
|
yield { url: 'http://example.com/b' };
|
|
yield { url: 'http://example.com/c' };
|
|
yield { url: 'http://example.com/d' };
|
|
yield { url: 'http://example.com/e' };
|
|
}
|
|
|
|
const result = await queue.addRequestsBatched(urls(), { maxNewRequests: 2 });
|
|
|
|
const addedUrls = result.addedRequests.filter((r) => !r.wasAlreadyPresent).map((r) => r.uniqueKey);
|
|
|
|
const overLimitUrls = (result.requestsOverLimit ?? []).map((r) => (typeof r === 'string' ? r : r.url));
|
|
|
|
expect(addedUrls).toHaveLength(2);
|
|
expect(overLimitUrls).toHaveLength(3);
|
|
});
|
|
|
|
test('should not count duplicate URLs toward maxRequestsPerCrawl limit (enqueueLinks)', async () => {
|
|
const requestQueue = await RequestQueue.open();
|
|
|
|
const visitedUrls: string[] = [];
|
|
|
|
const crawler = new BasicCrawler({
|
|
requestQueue,
|
|
maxRequestsPerCrawl: 5,
|
|
requestHandler: async (context) => {
|
|
visitedUrls.push(context.request.url);
|
|
|
|
if (context.request.label) {
|
|
return;
|
|
}
|
|
|
|
// Enqueue 10 duplicate links + 1 new unique link
|
|
const urls = [...Array.from({ length: 10 }, () => 'http://example.com/'), 'http://example.com/new'];
|
|
|
|
await context.enqueueLinks({ urls, label: 'child' });
|
|
},
|
|
});
|
|
|
|
await crawler.run(['http://example.com/']);
|
|
|
|
// Both the start URL and the new URL should have been visited
|
|
expect(visitedUrls).toContain('http://example.com/');
|
|
expect(visitedUrls).toContain('http://example.com/new');
|
|
});
|
|
});
|
|
|
|
describe('addRequests input validation', () => {
|
|
test('should throw error when passed a non-iterable value', async () => {
|
|
const crawler = new BasicCrawler({
|
|
requestHandler: async () => {},
|
|
});
|
|
|
|
await expect(crawler.addRequests(new WeakSet() as any)).rejects.toThrow(
|
|
'Expected an iterable or async iterable, got weakset',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Dataset helpers, crawler parallelism', () => {
|
|
const payload: Dictionary[] = [{ foo: 'bar', baz: 123 }];
|
|
const getPayload: (id: string) => Dictionary[] = (id) => [{ foo: id }];
|
|
|
|
const tmpDir = `${__dirname}/tmp/foo/bar`;
|
|
|
|
beforeAll(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('should expose default Dataset methods', async () => {
|
|
const crawler = new BasicCrawler();
|
|
|
|
await crawler.pushData(payload);
|
|
|
|
expect((await crawler.getData()).items).toEqual(payload);
|
|
});
|
|
|
|
test('export data', async () => {
|
|
const row: Dictionary = { foo: 'bar', baz: 123 };
|
|
const crawler = new BasicCrawler();
|
|
|
|
await crawler.pushData(row);
|
|
await crawler.pushData(row);
|
|
await crawler.pushData(row);
|
|
|
|
await crawler.exportData(`${tmpDir}/result.csv`);
|
|
await crawler.exportData(`${tmpDir}/result.json`);
|
|
|
|
const csv = await readFile(`${tmpDir}/result.csv`);
|
|
expect(csv.toString()).toBe('foo,baz\nbar,123\nbar,123\nbar,123\n');
|
|
const json = await readFile(`${tmpDir}/result.json`);
|
|
expect(json.toString()).toBe(
|
|
'[\n' +
|
|
' {\n' +
|
|
' "foo": "bar",\n' +
|
|
' "baz": 123\n' +
|
|
' },\n' +
|
|
' {\n' +
|
|
' "foo": "bar",\n' +
|
|
' "baz": 123\n' +
|
|
' },\n' +
|
|
' {\n' +
|
|
' "foo": "bar",\n' +
|
|
' "baz": 123\n' +
|
|
' }\n' +
|
|
']\n',
|
|
);
|
|
|
|
await rm(`${tmpDir}/result.csv`);
|
|
await rm(`${tmpDir}/result.json`);
|
|
});
|
|
|
|
test('exports do not fail on empty dataset', async () => {
|
|
const crawler = new BasicCrawler();
|
|
|
|
await crawler.exportData(`${tmpDir}/result.csv`);
|
|
await crawler.exportData(`${tmpDir}/result.json`);
|
|
|
|
const csv = await readFile(`${tmpDir}/result.csv`);
|
|
expect(csv.toString()).toBe('');
|
|
const json = await readFile(`${tmpDir}/result.json`);
|
|
expect(json.toString()).toBe('[]\n');
|
|
|
|
await rm(`${tmpDir}/result.csv`);
|
|
await rm(`${tmpDir}/result.json`);
|
|
});
|
|
|
|
test('should expose pushData helper', async () => {
|
|
const crawler = new BasicCrawler({
|
|
requestHandler: async ({ pushData }) => pushData(payload),
|
|
});
|
|
|
|
await crawler.run([
|
|
{
|
|
url: `http://${HOSTNAME}:${port}`,
|
|
},
|
|
]);
|
|
|
|
expect((await crawler.getData()).items).toEqual(payload);
|
|
});
|
|
|
|
test('crawler.exportData works with `collectAllKeys`', async () => {
|
|
const crawler = new BasicCrawler();
|
|
await crawler.pushData([{ foo: 'bar', baz: 123 }]);
|
|
await crawler.pushData([{ foo: 'baz', qux: 456 }]);
|
|
|
|
await crawler.exportData(`${tmpDir}/result.csv`, 'csv', { collectAllKeys: true });
|
|
|
|
const csv = await readFile(`${tmpDir}/result.csv`);
|
|
expect(csv.toString()).toBe('foo,baz,qux\nbar,123,\nbaz,,456\n');
|
|
|
|
await rm(`${tmpDir}/result.csv`);
|
|
});
|
|
|
|
test("Crawlers with different Configurations don't share Datasets", async () => {
|
|
const crawlerA = new BasicCrawler({}, new Configuration({ persistStorage: false }));
|
|
const crawlerB = new BasicCrawler({}, new Configuration({ persistStorage: false }));
|
|
|
|
await crawlerA.pushData(getPayload('A'));
|
|
await crawlerB.pushData(getPayload('B'));
|
|
|
|
expect((await crawlerA.getData()).items).toEqual(getPayload('A'));
|
|
|
|
expect((await crawlerB.getData()).items).toEqual(getPayload('B'));
|
|
});
|
|
|
|
test('Crawlers with different Configurations run separately', async () => {
|
|
const crawlerA = new BasicCrawler(
|
|
{ requestHandler: () => {} },
|
|
new Configuration({ persistStorage: false }),
|
|
);
|
|
const crawlerB = new BasicCrawler(
|
|
{ requestHandler: () => {} },
|
|
new Configuration({ persistStorage: false }),
|
|
);
|
|
|
|
await crawlerA.run([{ url: `http://${HOSTNAME}:${port}` }]);
|
|
await crawlerB.run([{ url: `http://${HOSTNAME}:${port}` }]);
|
|
|
|
expect(crawlerA.stats.state.requestsFinished).toBe(1);
|
|
expect(crawlerB.stats.state.requestsFinished).toBe(1);
|
|
});
|
|
|
|
test('Crawlers with different Configurations does not use global Configuration', async () => {
|
|
const getGlobalConfigSpy = vitest.spyOn(Configuration, 'getGlobalConfig');
|
|
|
|
const configA = new Configuration({ persistStorage: false });
|
|
const crawlerA = new BasicCrawler({ requestHandler: () => {} }, configA);
|
|
const configB = new Configuration({ persistStorage: false });
|
|
const crawlerB = new BasicCrawler({ requestHandler: () => {} }, configB);
|
|
|
|
await crawlerA.run([{ url: `http://${HOSTNAME}:${port}` }]);
|
|
await crawlerB.run([{ url: `http://${HOSTNAME}:${port}` }]);
|
|
|
|
expect(getGlobalConfigSpy.mock.calls.length).toBe(0);
|
|
expect(crawlerA.requestQueue?.config).toBe(configA);
|
|
expect(crawlerB.requestQueue?.config).toBe(configB);
|
|
});
|
|
});
|
|
});
|