项目文件夹

文件
2026-07-13 13:23:39 +08:00

1423 行
52 KiB
TypeScript

import type { IncomingHttpHeaders, Server } from 'node:http';
import { Readable } from 'node:stream';
import type {
Cheerio,
CheerioAPI,
CheerioCrawlingContext,
CheerioRequestHandler,
CheerioRoot,
Element,
ProxyInfo,
Source,
} from '@crawlee/cheerio';
import {
AutoscaledPool,
CheerioCrawler,
CrawlerExtension,
createCheerioRouter,
EnqueueStrategy,
mergeCookies,
ProxyConfiguration,
Request,
RequestList,
Session,
} from '@crawlee/cheerio';
import type { Dictionary } from '@crawlee/utils';
import { sleep } from '@crawlee/utils';
// @ts-expect-error type import of ESM only package
import type { OptionsInit } from 'got-scraping';
import iconv from 'iconv-lite';
import log, { Log } from '@apify/log';
import { responseSamples, runExampleComServer } from '../../shared/_helper';
import { MemoryStorageEmulator } from '../../shared/MemoryStorageEmulator';
let server: Server;
let port: number;
let serverAddress = 'http://localhost:';
async function getRequestListForMock(mockData: Dictionary, pathName = 'special/mock') {
const sources: Source[] = [1, 2, 3, 4].map((num) => {
return {
url: `${serverAddress}/${pathName}?a=${num}`,
payload: JSON.stringify(mockData),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
});
const requestList = await RequestList.open(null, sources);
return requestList;
}
async function getRequestListForMirror() {
const sources = [
{ url: `${serverAddress}/special/mirror?a=12` },
{ url: `${serverAddress}/special/mirror?a=23` },
{ url: `${serverAddress}/special/mirror?a=33` },
{ url: `${serverAddress}/special/mirror?a=43` },
];
const requestList = await RequestList.open(null, sources);
return requestList;
}
beforeAll(async () => {
[server, port] = await runExampleComServer();
serverAddress += port;
});
afterAll(() => {
server.close();
});
describe('CheerioCrawler', () => {
let logLevel: number;
const localStorageEmulator = new MemoryStorageEmulator();
beforeAll(async () => {
logLevel = log.getLevel();
log.setLevel(log.LEVELS.ERROR);
});
beforeEach(async () => {
await localStorageEmulator.init();
});
afterAll(async () => {
await localStorageEmulator.destroy();
});
afterAll(async () => {
log.setLevel(logLevel);
});
test('should work', async () => {
const requestList = await getRequestListForMirror();
const processed: Request[] = [];
const failed: Request[] = [];
const requestHandler: CheerioRequestHandler = ({ $, body, request }) => {
request.userData.title = $('title').text();
request.userData.body = body;
processed.push(request);
};
const cheerioCrawler = new CheerioCrawler({
requestList,
minConcurrency: 2,
maxConcurrency: 2,
requestHandler,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
await cheerioCrawler.run();
expect(cheerioCrawler.autoscaledPool!.minConcurrency).toBe(2);
expect(processed).toHaveLength(4);
expect(failed).toHaveLength(0);
processed.forEach((request) => {
expect(request.userData.title).toBe('Title');
expect(typeof request.userData.body).toBe('string');
expect((request.userData.body as string).length).not.toBe(0);
});
});
test('should work with implicit router', async () => {
const requestList = await getRequestListForMirror();
const processed: Request[] = [];
const failed: Request[] = [];
const cheerioCrawler = new CheerioCrawler({
requestList,
minConcurrency: 2,
maxConcurrency: 2,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
cheerioCrawler.router.addDefaultHandler<{ title: string; body: string }>(({ $, body, request }) => {
request.userData.title = $('title').text();
request.userData.body = body.toString();
processed.push(request);
});
await cheerioCrawler.run();
expect(cheerioCrawler.autoscaledPool!.minConcurrency).toBe(2);
expect(processed).toHaveLength(4);
expect(failed).toHaveLength(0);
processed.forEach((request) => {
expect(request.userData.title).toBe('Title');
expect(typeof request.userData.body).toBe('string');
expect((request.userData.body as string).length).not.toBe(0);
});
});
test('should work with explicit router', async () => {
const requestList = await getRequestListForMirror();
const processed: Request[] = [];
const failed: Request[] = [];
const router = createCheerioRouter<CheerioCrawlingContext<{ title: string; body: string }>>();
const cheerioCrawler = new CheerioCrawler({
requestHandler: router,
requestList,
minConcurrency: 2,
maxConcurrency: 2,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
router.addDefaultHandler(({ $, body, request }) => {
request.userData.title = $('title').text();
request.userData.body = body.toString();
processed.push(request);
});
await cheerioCrawler.run();
expect(cheerioCrawler.autoscaledPool!.minConcurrency).toBe(2);
expect(processed).toHaveLength(4);
expect(failed).toHaveLength(0);
processed.forEach((request) => {
expect(request.userData.title).toBe('Title');
expect(typeof request.userData.body).toBe('string');
expect((request.userData.body as string).length).not.toBe(0);
});
});
test('should throw when no requestHandler nor default route provided', async () => {
const requestList = await getRequestListForMirror();
const cheerioCrawler = new CheerioCrawler({
requestList,
minConcurrency: 2,
maxConcurrency: 2,
});
await expect(cheerioCrawler.run()).rejects.toThrow(
"Route not found for label 'undefined'. You must set up a route for this label or a default route. Use `requestHandler`, `router.addHandler` or `router.addDefaultHandler`.",
);
});
test('should ignore ssl by default', async () => {
const sources = [{ url: 'http://example.com/?q=1' }];
const requestList = await RequestList.open(null, sources);
const requestHandler = () => {};
const cheerioCrawler = new CheerioCrawler({
requestList,
maxConcurrency: 1,
requestHandler,
});
await cheerioCrawler.run();
// @ts-expect-error Accessing private prop
expect(cheerioCrawler.ignoreSslErrors).toBeTruthy();
});
test('should work with not encoded urls', async () => {
const sources = [
{ url: `${serverAddress}/mirror?q=abc` },
{ url: `${serverAddress}/mirror?q=%` },
{ url: `${serverAddress}/mirror?q=%cf` },
];
const requestList = await RequestList.open(null, sources);
const processed: Request[] = [];
const failed: Request[] = [];
const requestHandler: CheerioRequestHandler = ({ $, body, request }) => {
request.userData.title = $('title').text();
request.userData.body = body;
processed.push(request);
};
const cheerioCrawler = new CheerioCrawler({
requestList,
minConcurrency: 2,
maxConcurrency: 2,
requestHandler,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
await cheerioCrawler.run();
expect(processed).toHaveLength(3);
expect(failed).toHaveLength(0);
const sorted = processed.map((r) => r.loadedUrl).sort();
expect(sorted).toEqual([
`${serverAddress}/mirror?q=%`,
`${serverAddress}/mirror?q=%cf`,
`${serverAddress}/mirror?q=abc`,
]);
});
test('should serialize body and html', async () => {
const sources = [`${serverAddress}/special/html-type`];
const requestList = await RequestList.open(null, sources);
const tmp: any[] = [];
const cheerioCrawler = new CheerioCrawler({
requestList,
maxRequestRetries: 0,
maxConcurrency: 1,
requestHandler: ({ $, body, request }) => {
tmp.push(body, $.html(), request.loadedUrl);
},
});
await cheerioCrawler.run();
expect(tmp).toHaveLength(3);
expect(tmp[0]).toBe(responseSamples.html);
expect(tmp[1]).toBe(tmp[0]);
// test that `request.loadedUrl` is no longer optional
expect(tmp[2].length).toBe(sources[0].length);
});
describe('should timeout', () => {
let ll: number;
beforeAll(() => {
ll = log.getLevel();
log.setLevel(log.LEVELS.OFF);
});
afterAll(() => {
log.setLevel(ll);
});
test('after navigationTimeoutSecs', async () => {
const sources = [
{ url: 'http://example.com/?q=0' },
{ url: 'http://example.com/?q=1' },
{ url: 'http://example.com/?q=2' },
];
const processed: Request[] = [];
const failed: Request[] = [];
const requestList = await RequestList.open(null, sources);
const requestHandler: CheerioRequestHandler = ({ request }) => {
processed.push(request);
};
const cheerioCrawler = new CheerioCrawler({
requestList,
navigationTimeoutSecs: 5 / 1000,
maxRequestRetries: 1,
minConcurrency: 2,
maxConcurrency: 2,
requestHandler,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
// @ts-expect-error Overriding private method
cheerioCrawler._requestFunction = async () => {
await sleep(300);
return '<html><head></head><body>Body</body></html>';
};
await cheerioCrawler.run();
expect(processed).toHaveLength(0);
expect(failed).toHaveLength(3);
failed.forEach((request) => {
expect(request.errorMessages).toHaveLength(2);
expect(request.errorMessages[0]).toMatch('request timed out');
expect(request.errorMessages[1]).toMatch('request timed out');
});
});
test('after requestHandlerTimeoutSecs', async () => {
const failed: Request[] = [];
const requestList = await getRequestListForMirror();
const requestHandler = async () => {
await sleep(2000);
};
const cheerioCrawler = new CheerioCrawler({
requestList,
requestHandlerTimeoutSecs: 1,
maxRequestRetries: 1,
minConcurrency: 2,
maxConcurrency: 2,
requestHandler,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
// Override low value to prevent seeing timeouts from BasicCrawler
// @ts-expect-error Overriding private property
cheerioCrawler.handleRequestTimeoutMillis = 10000;
await cheerioCrawler.run();
expect(failed).toHaveLength(4);
failed.forEach((request) => {
expect(request.errorMessages).toHaveLength(2);
expect(request.errorMessages[0]).toMatch('requestHandler timed out');
expect(request.errorMessages[1]).toMatch('requestHandler timed out');
});
});
});
describe('should not timeout by the default httpRequest timeoutSecs', () => {
it('when navigationTimeoutSecs is greater than 30', async () => {
const sources = [{ url: `${serverAddress}/timeout?a=12` }, { url: `${serverAddress}/timeout?a=23` }];
const processed: Request[] = [];
const failed: Request[] = [];
const requestList = await RequestList.open(null, sources);
const requestHandler: CheerioRequestHandler = ({ request }) => {
processed.push(request);
};
const cheerioCrawler = new CheerioCrawler({
requestList,
maxRequestRetries: 1,
navigationTimeoutSecs: 35,
minConcurrency: 2,
maxConcurrency: 2,
requestHandler,
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
await cheerioCrawler.run();
expect(processed).toHaveLength(2);
expect(failed).toHaveLength(0);
}, 40000);
});
describe('should ensure text/html Content-Type', () => {
test('by setting a correct Accept header', async () => {
const headers: IncomingHttpHeaders[] = [];
const requestList = await getRequestListForMirror();
const crawler = new CheerioCrawler({
requestList,
requestHandler: ({ response }) => {
headers.push(response.request.options.headers);
},
});
await crawler.run();
expect(headers).toHaveLength(4);
headers.forEach((h) => {
const acceptHeader = h.accept || h.Accept;
expect(acceptHeader!.includes('text/html')).toBe(true);
expect(acceptHeader!.includes('application/xhtml+xml')).toBe(true);
});
});
describe('by throwing', () => {
let crawler;
let handlePageInvocationCount = 0;
let errorMessages: string[] = [];
beforeEach(() => {
log.setLevel(log.LEVELS.OFF);
});
afterEach(async () => {
log.setLevel(log.LEVELS.ERROR);
crawler = null;
handlePageInvocationCount = 0;
errorMessages = [];
});
test('when invalid Content-Type header is received', async () => {
// Mock Request to inject invalid response headers.
crawler = new CheerioCrawler({
requestList: await getRequestListForMock({
headers: {
'content-type': 'text/plain',
},
statusCode: 200,
}),
maxRequestRetries: 1,
requestHandler: () => {
handlePageInvocationCount++;
},
failedRequestHandler: ({ request }) => {
errorMessages = [...errorMessages, ...request.errorMessages];
},
});
await crawler.run();
expect(handlePageInvocationCount).toBe(0);
expect(errorMessages).toHaveLength(4);
errorMessages.forEach((msg) =>
expect(msg).toMatch(
' Content-Type text/plain, but only text/html, text/xml, application/xhtml+xml, application/xml, application/json are allowed.' +
' Skipping resource.',
),
);
});
test('when statusCode >= 500 and text/html is received', async () => {
// sometimes if you get blocked you can get 500+ with some html inside
crawler = new CheerioCrawler({
requestList: await getRequestListForMock({
statusCode: 508,
headers: {
'content-type': 'text/html',
},
body: 'DATABASE ERROR',
}),
maxRequestRetries: 1,
requestHandler: () => {
handlePageInvocationCount++;
},
failedRequestHandler: ({ request }) => {
errorMessages = [...errorMessages, ...request.errorMessages];
},
});
await crawler.run();
expect(handlePageInvocationCount).toBe(0);
expect(errorMessages).toHaveLength(8);
errorMessages.forEach((msg) => expect(msg).toMatch('Internal Server Error'));
});
test('when statusCode >= 500 and application/json is received', async () => {
crawler = new CheerioCrawler({
requestList: await getRequestListForMock({}, 'special/jsonError'),
maxRequestRetries: 1,
requestHandler: () => {
handlePageInvocationCount++;
},
failedRequestHandler: ({ request }) => {
errorMessages = [...errorMessages, ...request.errorMessages];
},
});
await crawler.run();
expect(handlePageInvocationCount).toBe(0);
expect(errorMessages).toHaveLength(8);
errorMessages.forEach((msg) => expect(msg).toMatch('CUSTOM_ERROR'));
});
});
});
test('should ignore http error status codes set by user', async () => {
const requestList = await getRequestListForMock({
headers: {
'content-type': 'text/plain',
},
statusCode: 500,
});
const failed: Request[] = [];
const cheerioCrawler = new CheerioCrawler({
requestList,
minConcurrency: 2,
maxConcurrency: 2,
ignoreHttpErrorStatusCodes: [500],
requestHandler: () => {},
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
await cheerioCrawler.run();
expect(cheerioCrawler.autoscaledPool!.minConcurrency).toBe(2);
expect(failed).toHaveLength(0);
});
test('should throw an error on http error status codes set by user', async () => {
const requestList = await getRequestListForMirror();
const failed: Request[] = [];
const cheerioCrawler = new CheerioCrawler({
requestList,
minConcurrency: 2,
maxConcurrency: 2,
additionalHttpErrorStatusCodes: [200],
requestHandler: () => {},
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
await cheerioCrawler.run();
expect(cheerioCrawler.autoscaledPool!.minConcurrency).toBe(2);
expect(failed).toHaveLength(4);
});
test('should work with all defaults content types', async () => {
let handledRequests = 0;
const contentTypes = ['text/html', 'application/xhtml+xml', 'text/xml', 'application/xml', 'application/json'];
const sources: Source[] = contentTypes.map((contentType) => ({
url: `${serverAddress}/mock?ct=${contentType}`,
payload: JSON.stringify({ headers: { 'Content-Type': contentType }, statusCode: 200 }),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}));
const requestList = await RequestList.open({
sources,
});
const crawler = new CheerioCrawler({
requestList,
requestHandler: () => {
handledRequests++;
},
});
await crawler.run();
expect(handledRequests).toEqual(contentTypes.length);
});
describe('should work with all content types from options.additionalMimeTypes', () => {
let handlePageInvocationParams: CheerioCrawlingContext;
let handleFailedInvoked = false;
const runCrawler = async (url: string) => {
const sources = [url];
const requestList = await RequestList.open(null, sources);
const crawler = new CheerioCrawler({
requestList,
additionalMimeTypes: ['application/json', 'image/png', 'application/xml'],
maxRequestRetries: 1,
requestHandler: (params) => {
handlePageInvocationParams = params;
},
failedRequestHandler: () => {
handleFailedInvoked = true;
},
});
await crawler.run();
};
test('when response is application/json', async () => {
const url = `${serverAddress}/special/json-type`;
await runCrawler(url);
expect(handlePageInvocationParams.json).toBeInstanceOf(Object);
expect(handlePageInvocationParams.body).toEqual(Buffer.from(JSON.stringify(responseSamples.json)));
expect(handlePageInvocationParams.contentType.type).toBe('application/json');
expect(handleFailedInvoked).toBe(false);
});
test('when response is application/xml', async () => {
const url = `${serverAddress}/special/xml-type`;
await runCrawler(url);
expect(typeof handlePageInvocationParams.body).toBe('string');
expect(handlePageInvocationParams.body).toEqual(responseSamples.xml);
expect(handlePageInvocationParams.$).toBeInstanceOf(Function);
expect(handlePageInvocationParams.contentType.type).toBe('application/xml');
});
test('when response is image/png', async () => {
const url = `${serverAddress}/special/image-type`;
await runCrawler(url);
expect(handlePageInvocationParams.body).toBeInstanceOf(Buffer);
expect(handlePageInvocationParams.body).toEqual(responseSamples.image);
expect(handlePageInvocationParams.contentType.type).toBe('image/png');
});
});
describe('should use response encoding', () => {
const html = '<html>Žluťoučký kůň</html>';
test('as a fallback', async () => {
const requestList = await RequestList.open({
sources: ['http://useless.x'],
});
const suggestResponseEncoding = 'windows-1250';
const buf = iconv.encode(html, suggestResponseEncoding);
// Ensure it's really encoded.
expect(buf.toString('utf8')).not.toBe(html);
const crawler = new CheerioCrawler({
requestList,
requestHandler: () => {},
suggestResponseEncoding,
});
const stream = Readable.from([buf]);
// @ts-expect-error Using private method
const { response, encoding } = crawler._encodeResponse({}, stream);
expect(encoding).toBe('utf8');
for await (const chunk of response) {
const string = chunk.toString('utf8');
expect(string).toBe(html);
}
});
test('always when forced', async () => {
const requestList = await RequestList.open({
sources: ['http://useless.x'],
});
const forceResponseEncoding = 'win1250';
const buf = iconv.encode(html, forceResponseEncoding);
// Ensure it's really encoded.
expect(buf.toString('utf8')).not.toBe(html);
const crawler = new CheerioCrawler({
requestList,
requestHandler: () => {},
forceResponseEncoding,
});
const stream = Readable.from([buf]);
// @ts-expect-error Using private method
const { response, encoding } = crawler._encodeResponse({}, stream, 'ascii');
expect(encoding).toBe('utf8');
for await (const chunk of response) {
const string = chunk.toString('utf8');
expect(string).toBe(html);
}
});
test('Cheerio decodes html entities', async () => {
let context: CheerioCrawlingContext | null = null;
const crawler = new CheerioCrawler({
requestHandler: (ctx) => {
context = ctx;
},
});
await crawler.run([`${serverAddress}/special/html-entities`]);
context = context as unknown as CheerioCrawlingContext;
expect(context?.$.html()).toBe('&quot;&lt;&gt;&quot;&lt;&gt;');
expect(context?.$.html({ decodeEntities: false })).toBe('"<>"<>');
expect(context?.body).toBe('&quot;&lt;&gt;"<>');
});
});
describe('proxy', () => {
beforeEach(async () => {
// Do not use clearAllMocks: https://github.com/facebook/vitest/issues/7136
vitest.restoreAllMocks();
});
test('should work with Apify Proxy configuration', async () => {
const proxyUrl = serverAddress;
const proxyConfiguration = new ProxyConfiguration({
proxyUrls: [proxyUrl],
});
const requestList = await getRequestListForMirror();
const proxies: string[] = [];
const crawler = new CheerioCrawler({
requestList,
requestHandler: ({ proxyInfo }) => {
proxies.push(proxyInfo!.url);
},
proxyConfiguration,
});
await crawler.run();
expect(proxies[0]).toEqual(proxyUrl);
expect(proxies[1]).toEqual(proxyUrl);
expect(proxies[2]).toEqual(proxyUrl);
expect(proxies[3]).toEqual(proxyUrl);
});
test('requestHandler should expose the proxyInfo object with sessions correctly', async () => {
const proxyUrls = [0, 1, 2, 3].map((n) => `${serverAddress}/proxy?x=${n}`);
const proxyConfiguration = new ProxyConfiguration({
proxyUrls,
});
const proxies: ProxyInfo[] = [];
const sessions: Session[] = [];
const requestHandler = ({ session, proxyInfo }: CheerioCrawlingContext) => {
proxies.push(proxyInfo!);
sessions.push(session!);
};
const requestList = await getRequestListForMirror();
const crawler = new CheerioCrawler({
requestList,
requestHandler,
proxyConfiguration,
useSessionPool: true,
});
await crawler.run();
for (let i = 0; i < 4; i++) {
const proxyInfo = proxies[i];
const session = sessions[i];
expect(typeof proxyInfo.url).toBe('string');
expect(typeof session.id).toBe('string');
expect(proxyInfo.sessionId).toBe(session.id);
expect(proxyInfo).toEqual(await proxyConfiguration.newProxyInfo(session.id));
}
});
test('proxy rotation on error works as expected', async () => {
const goodProxyUrl = 'http://good.proxy';
const proxyConfiguration = new ProxyConfiguration({
proxyUrls: ['http://localhost', 'http://localhost:1234', goodProxyUrl],
});
const check = vitest.fn();
const crawler = new (class extends CheerioCrawler {
protected override async _requestFunction(...args: any[]): Promise<any> {
check(...args);
if (args[0].proxyUrl === goodProxyUrl) {
return null;
}
throw new Error('Proxy responded with 400 - Bad request');
}
})({
maxSessionRotations: 2,
maxConcurrency: 1,
useSessionPool: true,
proxyConfiguration,
requestHandler: () => {},
});
await expect(crawler.run([serverAddress])).resolves.not.toThrow();
expect(check).toBeCalledWith(expect.objectContaining({ proxyUrl: goodProxyUrl }));
});
test('proxy rotation on error respects maxSessionRotations, calls failedRequestHandler', async () => {
const proxyConfiguration = new ProxyConfiguration({
proxyUrls: ['http://localhost', 'http://localhost:1234'],
});
/**
* The first increment is the base case when the proxy is retrieved for the first time.
*/
let numberOfRotations = -1;
const failedRequestHandler = vitest.fn();
const crawler = new CheerioCrawler({
proxyConfiguration,
maxSessionRotations: 5,
requestHandler: async () => {},
failedRequestHandler,
});
vitest.spyOn(crawler, '_requestAsBrowser' as any).mockImplementation(async ({ proxyUrl }: any) => {
if (proxyUrl.includes('localhost')) {
numberOfRotations++;
throw new Error('Proxy responded with 400 - Bad request');
}
return null;
});
await crawler.run([serverAddress]);
expect(failedRequestHandler).toBeCalledTimes(1);
expect(numberOfRotations).toBe(5);
});
test('proxy rotation logs the original proxy error', async () => {
const proxyConfiguration = new ProxyConfiguration({ proxyUrls: ['http://localhost:1234'] });
const proxyError =
'Proxy responded with 400 - Bad request. Also, this error message contains some useful payload.';
const crawler = new CheerioCrawler({
proxyConfiguration,
maxSessionRotations: 1,
requestHandler: async () => {},
});
vitest.spyOn(crawler, '_requestAsBrowser' as any).mockImplementation(async ({ proxyUrl }: any) => {
if (proxyUrl.includes('localhost')) {
throw new Error(proxyError);
}
return null;
});
const spy = vitest.spyOn((crawler as any).log, 'warning' as any).mockImplementation(() => {});
await crawler.run([serverAddress]);
expect(spy).toBeCalled();
expect(spy.mock.calls[0][0]).toEqual(expect.stringContaining(proxyError));
});
});
describe('SessionPool', () => {
let requestList: RequestList;
beforeEach(async () => {
requestList = await RequestList.open(null, [serverAddress]);
});
test('should work', async () => {
expect.assertions(1);
const crawler = new CheerioCrawler({
requestList,
useSessionPool: true,
persistCookiesPerSession: false,
requestHandler: ({ session }) => {
expect(session).toBeInstanceOf(Session);
},
});
await crawler.run();
});
test('should correctly set session pool options', async () => {
const crawler = new CheerioCrawler({
requestList,
useSessionPool: true,
persistCookiesPerSession: false,
sessionPoolOptions: {
sessionOptions: {
maxUsageCount: 1,
},
persistStateKeyValueStoreId: 'abc',
},
requestHandler: () => {},
});
// @ts-expect-error Accessing private prop
expect(crawler.sessionPoolOptions.sessionOptions.maxUsageCount).toBe(1);
// @ts-expect-error Accessing private prop
expect(crawler.sessionPoolOptions.persistStateKeyValueStoreId).toBe('abc');
});
test('should markBad sessions after request timeout', async () => {
// log.setLevel(log.LEVELS.OFF);
const cheerioCrawler = new CheerioCrawler({
requestList: await RequestList.open(null, [
`${serverAddress}/special/timeout?a=12`,
`${serverAddress}/special/timeout?a=23`,
]),
maxRequestRetries: 1,
navigationTimeoutSecs: 1,
maxConcurrency: 1,
useSessionPool: true,
requestHandler: async () => {
await sleep(1);
},
});
await cheerioCrawler.run();
// @ts-expect-error private symbol
const sessions = cheerioCrawler.sessionPool!.sessions;
expect(sessions.length).toBe(4);
sessions.forEach((session) => {
// TODO this test is flaky in CI and we need some more info to debug why.
if (session.errorScore !== 1) {
console.log('SESSIONS:');
console.dir(sessions);
}
// TODO too flaky
// expect(session.errorScore).toEqual(1);
});
// log.setLevel(log.LEVELS.ERROR);
});
test('should retire session on "blocked" status codes', async () => {
for (const code of [401, 403, 429]) {
const failed: Request[] = [];
const sessions: Session[] = [];
const crawler = new CheerioCrawler({
requestList: await getRequestListForMock({
statusCode: code,
error: false,
headers: { 'Content-type': 'text/html' },
}),
useSessionPool: true,
persistCookiesPerSession: false,
maxRequestRetries: 0,
requestHandler: ({ session }) => {
sessions.push(session!);
},
failedRequestHandler: ({ request }) => {
failed.push(request);
},
});
await crawler.run();
// @ts-expect-error private symbol
expect(crawler.sessionPool.sessions.length).toBe(4);
// @ts-expect-error private symbol
crawler.sessionPool.sessions.forEach((session) => {
expect(session.errorScore).toBeGreaterThanOrEqual(session.maxErrorScore);
});
expect(failed.length).toBe(4);
failed.forEach((request) => {
expect(
request.errorMessages[0].includes(`Request blocked - received ${code} status code`),
).toBeTruthy();
});
}
});
test('should throw when "options.useSessionPool" false and "options.persistCookiesPerSession" is true', async () => {
try {
// eslint-disable-next-line no-new
new CheerioCrawler({
requestList: await getRequestListForMock({}),
useSessionPool: false,
persistCookiesPerSession: true,
maxRequestRetries: 0,
requestHandler: () => {},
});
} catch (e) {
expect((e as Error).message).toEqual(
'You cannot use "persistCookiesPerSession" without "useSessionPool" set to true.',
);
}
});
test('should send cookies', async () => {
const cookie = 'SESSID=abcd123';
const requests: Request[] = [];
const crawler = new CheerioCrawler({
requestList: await getRequestListForMock(
{
headers: { 'set-cookie': cookie, 'content-type': 'text/html' },
statusCode: 200,
},
'/getRawHeaders',
),
useSessionPool: true,
persistCookiesPerSession: true,
sessionPoolOptions: {
maxPoolSize: 1,
},
maxRequestRetries: 1,
maxConcurrency: 1,
requestHandler: ({ request }) => {
requests.push(request);
},
});
await crawler.run();
requests.forEach((_req, i) => {
if (i >= 1) {
const response = JSON.parse(_req.payload!);
expect(response.headers['set-cookie']).toEqual(cookie);
}
});
});
test('should merge cookies set in pre-nav hook with the session ones', async () => {
const responses: unknown[] = [];
const gotOptions: OptionsInit[] = [];
const crawler = new CheerioCrawler({
requestList: await RequestList.open(null, [
{
url: `${serverAddress}/special/headers`,
headers: { cookie: 'foo=bar2; baz=123' },
},
]),
useSessionPool: true,
persistCookiesPerSession: false,
sessionPoolOptions: {
maxPoolSize: 1,
},
requestHandler: ({ json }) => {
responses.push(json);
},
preNavigationHooks: [
(_context, options) => {
gotOptions.push(options);
},
],
});
const sessSpy = vitest.spyOn(Session.prototype, 'getCookieString');
sessSpy.mockReturnValueOnce('foo=bar1; other=cookie1; coo=kie');
await crawler.run();
expect(responses).toHaveLength(1);
expect(responses[0]).toMatchObject({
headers: {
cookie: 'foo=bar2; other=cookie1; coo=kie; baz=123',
},
});
expect(gotOptions).toHaveLength(1);
expect(gotOptions[0]).toMatchObject({
headers: {
Cookie: 'foo=bar2; other=cookie1; coo=kie; baz=123', // header name normalized to `Cookie`
},
});
});
test('should work with cookies adjusted on `context.request` in pre-nav hook', async () => {
const responses: unknown[] = [];
const crawler = new CheerioCrawler({
requestList: await RequestList.open(null, [
{
url: `${serverAddress}/special/headers`,
headers: { cookie: 'foo=bar2; baz=123' },
},
]),
useSessionPool: true,
persistCookiesPerSession: false,
sessionPoolOptions: {
maxPoolSize: 1,
},
requestHandler: ({ json }) => {
responses.push(json);
},
preNavigationHooks: [
({ request }) => {
request.headers!.Cookie = 'foo=override; coo=kie';
},
],
});
await crawler.run();
expect(responses).toHaveLength(1);
expect(responses[0]).toMatchObject({
headers: {
cookie: 'foo=override; baz=123; coo=kie',
},
});
});
test('should work with `context.request.headers` being undefined', async () => {
const requests: Request[] = [];
const responses: unknown[] = [];
const crawler = new CheerioCrawler({
requestList: await RequestList.open(null, [
{
url: `${serverAddress}/special/headers`,
},
]),
useSessionPool: true,
requestHandler: async ({ json, request }) => {
responses.push(json);
requests.push(request);
},
preNavigationHooks: [
({ request }) => {
request.headers!.Cookie = 'foo=override; coo=kie';
},
],
});
await crawler.run();
expect(requests).toHaveLength(1);
expect(requests[0].retryCount).toBe(0);
expect(responses).toHaveLength(1);
expect(responses[0]).toMatchObject({
headers: {
cookie: 'foo=override; coo=kie',
},
});
});
test('mergeCookies()', async () => {
const deprecatedSpy = vitest.spyOn(Log.prototype, 'deprecated');
const cookie1 = mergeCookies('https://example.com', [
'foo=bar1; other=cookie1 ; coo=kie',
'foo=bar2; baz=123',
'other=cookie2;foo=bar3',
]);
expect(cookie1).toBe('foo=bar3; other=cookie2; coo=kie; baz=123');
expect(deprecatedSpy).not.toBeCalled();
const cookie2 = mergeCookies('https://example.com', [
'Foo=bar1; other=cookie1 ; coo=kie',
'foo=bar2; baz=123',
'Other=cookie2;foo=bar3',
]);
expect(cookie2).toBe('Foo=bar1; other=cookie1; coo=kie; foo=bar3; baz=123; Other=cookie2');
expect(deprecatedSpy).toBeCalledTimes(3);
expect(deprecatedSpy).toBeCalledWith(
`Found cookies with similar name during cookie merging: 'foo' and 'Foo'`,
);
expect(deprecatedSpy).toBeCalledWith(
`Found cookies with similar name during cookie merging: 'Other' and 'other'`,
);
deprecatedSpy.mockClear();
const cookie3 = mergeCookies('https://example.com', [
'foo=bar1; Other=cookie1 ; Coo=kie',
'foo=bar2; baz=123',
'Other=cookie2;Foo=bar3;coo=kee',
]);
expect(cookie3).toBe('foo=bar2; Other=cookie2; Coo=kie; baz=123; Foo=bar3; coo=kee');
expect(deprecatedSpy).toBeCalledTimes(2);
expect(deprecatedSpy).toBeCalledWith(
`Found cookies with similar name during cookie merging: 'Foo' and 'foo'`,
);
expect(deprecatedSpy).toBeCalledWith(
`Found cookies with similar name during cookie merging: 'coo' and 'Coo'`,
);
});
test('should use sessionId in proxyUrl when the session pool is enabled', async () => {
const sourcesNew = [{ url: 'http://example.com/?q=1' }];
const requestListNew = await RequestList.open({ sources: sourcesNew });
let usedSession: Session;
const proxyConfiguration = new ProxyConfiguration({ proxyUrls: ['http://localhost:8080'] });
const newUrlSpy = vitest.spyOn(proxyConfiguration, 'newUrl');
const cheerioCrawler = new CheerioCrawler({
requestList: requestListNew,
maxRequestRetries: 0,
maxSessionRotations: 0,
requestHandler: () => {},
failedRequestHandler: () => {},
useSessionPool: true,
proxyConfiguration,
});
// @ts-expect-error Accessing private method
const oldHandleRequestF = cheerioCrawler._runRequestHandler;
// @ts-expect-error Overriding private method
cheerioCrawler._runRequestHandler = async (opts) => {
usedSession = opts.session!;
return oldHandleRequestF.call(cheerioCrawler, opts);
};
try {
await cheerioCrawler.run();
} catch (e) {
// localhost proxy causes proxy errors, session rotations and finally throws, but we don't care
}
expect(newUrlSpy).toBeCalledWith(
usedSession!.id,
expect.objectContaining({ request: expect.any(Request) }),
);
});
});
describe('Crawling context', () => {
let requestList: RequestList;
let actualLogLevel: number;
beforeEach(async () => {
actualLogLevel = log.getLevel();
log.setLevel(log.LEVELS.OFF);
requestList = await RequestList.open(null, [serverAddress]);
});
afterAll(() => {
log.setLevel(actualLogLevel);
});
test('uses correct crawling context', async () => {
let prepareCrawlingContext: CheerioCrawlingContext;
const prepareRequestFunction = (crawlingContext: CheerioCrawlingContext) => {
prepareCrawlingContext = crawlingContext;
expect(crawlingContext.request).toBeInstanceOf(Request);
expect(crawlingContext.crawler.autoscaledPool).toBeInstanceOf(AutoscaledPool);
expect(crawlingContext.session).toBeInstanceOf(Session);
};
const requestHandler = (crawlingContext: CheerioCrawlingContext) => {
expect(crawlingContext === prepareCrawlingContext).toEqual(true);
expect(crawlingContext.request).toBeInstanceOf(Request);
expect(crawlingContext.crawler.autoscaledPool).toBeInstanceOf(AutoscaledPool);
expect(crawlingContext.session).toBeInstanceOf(Session);
expect(typeof crawlingContext.$).toBe('function');
expect(typeof crawlingContext.response).toBe('object');
expect(typeof crawlingContext.contentType).toBe('object');
throw new Error('some error');
};
const failedRequestHandler = (crawlingContext: CheerioCrawlingContext, error: Error) => {
expect(crawlingContext === prepareCrawlingContext).toEqual(true);
expect(crawlingContext.request).toBeInstanceOf(Request);
expect(crawlingContext.crawler.autoscaledPool).toBeInstanceOf(AutoscaledPool);
expect(crawlingContext.session).toBeInstanceOf(Session);
expect(typeof crawlingContext.$).toBe('function');
expect(typeof crawlingContext.response).toBe('object');
expect(typeof crawlingContext.contentType).toBe('object');
expect(crawlingContext.error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(Error);
expect(error.message).toEqual('some error');
};
const cheerioCrawler = new CheerioCrawler({
requestList,
maxRequestRetries: 0,
maxConcurrency: 1,
useSessionPool: true,
preNavigationHooks: [prepareRequestFunction],
requestHandler,
failedRequestHandler,
});
await cheerioCrawler.run();
});
test('should have correct types in crawling context', async () => {
const requestHandler = (crawlingContext: CheerioCrawlingContext) => {
// Checking that types are correct
const _cheerioRootType: CheerioRoot = crawlingContext.$;
const _apiType: CheerioAPI = crawlingContext.$;
const _cheerioElementType: Cheerio<Element> = crawlingContext.$('div');
};
const cheerioCrawler = new CheerioCrawler({
requestList,
maxRequestRetries: 0,
maxConcurrency: 1,
requestHandler,
});
await cheerioCrawler.run();
});
});
describe('use', () => {
const sources = ['http://example.com/'];
let requestList: RequestList;
class DummyExtension extends CrawlerExtension {
constructor(readonly options: Dictionary) {
super();
}
override getCrawlerOptions() {
return this.options;
}
}
beforeEach(async () => {
requestList = await RequestList.open(null, sources.slice());
});
test('should throw if "CrawlerExtension" class is not used', () => {
const cheerioCrawler = new CheerioCrawler({
requestList,
maxRequestRetries: 0,
requestHandler: () => {},
failedRequestHandler: () => {},
});
expect(
// @ts-expect-error Validating JS side checks
() => cheerioCrawler.use({}),
).toThrow('Expected object `{}` to be of type `CrawlerExtension`');
});
test('Should throw if "CrawlerExtension" is trying to override non existing property', () => {
const extension = new DummyExtension({
doesNotExist: true,
});
const cheerioCrawler = new CheerioCrawler({
requestList,
maxRequestRetries: 0,
requestHandler: () => {},
failedRequestHandler: () => {},
});
expect(() => cheerioCrawler.use(extension)).toThrow(
'DummyExtension tries to set property "doesNotExist" that is not configurable on CheerioCrawler instance.',
);
});
test('should override crawler properties', () => {
const extension = new DummyExtension({
useSessionPool: true,
requestHandler: undefined,
});
const cheerioCrawler = new CheerioCrawler({
requestList,
useSessionPool: false,
maxRequestRetries: 0,
requestHandler: () => {},
failedRequestHandler: () => {},
});
// @ts-expect-error Accessing private prop
expect(cheerioCrawler.useSessionPool).toEqual(false);
cheerioCrawler.use(extension);
// @ts-expect-error Accessing private prop
expect(cheerioCrawler.useSessionPool).toEqual(true);
// @ts-expect-error Accessing private prop
expect(cheerioCrawler.requestHandler).toBeUndefined();
// @ts-expect-error Accessing private prop
expect(cheerioCrawler.requestHandler).toBeUndefined();
});
});
test('should work with delete requests', async () => {
const sources: Source[] = [1, 2, 3, 4].map((num) => {
return {
url: `${serverAddress}/special/mock?a=${num}`,
method: 'DELETE',
};
});
const requestList = await RequestList.open(null, sources);
const failed: Request[] = [];
const cheerioCrawler = new CheerioCrawler({
requestList,
maxConcurrency: 1,
maxRequestRetries: 0,
navigationTimeoutSecs: 5,
requestHandlerTimeoutSecs: 5,
requestHandler: async () => {},
failedRequestHandler: async ({ request }) => {
failed.push(request);
},
});
await cheerioCrawler.run();
expect(failed).toHaveLength(0);
});
test("enqueueLinks() should skip links that don't match the strategy post redirect", async () => {
const succeeded: string[] = [];
const crawler = new CheerioCrawler({
maxConcurrency: 1,
maxRequestRetries: 0,
requestHandler: async ({ $, enqueueLinks }) => {
succeeded.push($('title').text());
await enqueueLinks({ strategy: EnqueueStrategy.SameOrigin });
},
});
await crawler.run([`${serverAddress}/special/redirect`]);
expect(succeeded).toHaveLength(1);
expect(succeeded[0]).toEqual('Redirecting outside');
});
test('enqueueLinks should respect maxCrawlDepth', async () => {
const succeeded: string[] = [];
const crawler = new CheerioCrawler({
maxCrawlDepth: 1,
maxRequestsPerCrawl: 10, // to avoid accidental runaway
requestHandler: async ({ $, enqueueLinks }) => {
succeeded.push($('title').text());
await enqueueLinks({ strategy: EnqueueStrategy.All });
},
});
await crawler.run([`${serverAddress}/special/html-type`]);
expect(succeeded).toHaveLength(2);
expect(succeeded).toEqual(['Example Domain', 'Example Domains']);
});
});