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>(); 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 'Body'; }; 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 = 'Žluťoučký kůň'; 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('"<>"<>'); expect(context?.$.html({ decodeEntities: false })).toBe('"<>"<>'); expect(context?.body).toBe('"<>"<>'); }); }); 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 { 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 = 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']); }); });