Files
apify--crawlee/test/core/crawlers/adaptive_playwright_crawler.test.ts
2026-07-13 13:23:39 +08:00

604 lines
22 KiB
TypeScript

import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { Configuration, type Dictionary, EventType, KeyValueStore } from '@crawlee/core';
import type {
AdaptivePlaywrightCrawlerContext,
AdaptivePlaywrightCrawlerOptions,
LoadedContext,
Request,
} from '@crawlee/playwright';
import { AdaptivePlaywrightCrawler, RenderingTypePredictor, RequestList } from '@crawlee/playwright';
import { sleep } from 'crawlee';
import express from 'express';
import { startExpressAppPromise } from '../../shared/_helper';
import { MemoryStorageEmulator } from '../../shared/MemoryStorageEmulator';
describe('AdaptivePlaywrightCrawler', () => {
// Set up an express server that will serve test pages
const HOSTNAME = '127.0.0.1';
let port: number;
let server: Server;
beforeAll(async () => {
const app = express();
server = await startExpressAppPromise(app, 0);
port = (server.address() as AddressInfo).port;
app.get('/static', (_req, res) => {
res.status(200);
res.send(`
<html>
<head>
<title>Example Domain</title>
</head>
<body>
<h1>Heading</h1>
<a href="/static?q=1">Link 1</a>
<a href="/static?q=2">Link 2</a>
<a href="/static?q=3">Link 3</a>
<a href="/static?q=4">Link 4</a>
<a href="/static?q=5">Link 5</a>
</body>
</html>
`);
});
app.get('/dynamic', (_req, res) => {
res.status(200);
res.send(`
<html>
<head>
<title>Example Domain</title>
<script type="text/javascript">
setTimeout(() => {
document.body.innerHTML = [
'<h1>Heading</h1>',
'<a href="/static?q=1">Link 1</a>',
'<a href="/static?q=2">Link 2</a>',
'<a href="/static?q=3">Link 3</a>',
'<a href="/static?q=4">Link 4</a>',
'<a href="/static?q=5">Link 5</a>',
].join(" ")
}, 500)
</script>
</head>
<body>
</body>
</html>
`);
});
app.get('/external-links', (_req, res) => {
res.status(200);
res.send(`
<html>
<head>
<title>Example Domain</title>
</head>
<body>
<h1>Heading</h1>
<a href="/external-redirect">External redirect</a>
<a href="https://google.com/">Outbound link</a>
</body>
</html>
`);
});
app.get('/external-redirect', (_req, res) => {
res.status(302);
res.setHeader('Location', 'https://google.com');
res.send('Redirecting...');
});
});
afterAll(async () => {
server.close();
});
// Set up local storage emulator
const localStorageEmulator = new MemoryStorageEmulator();
beforeEach(async () => {
await localStorageEmulator.init();
});
afterAll(async () => {
await localStorageEmulator.destroy();
});
// Test setup helpers
const makeOneshotCrawler = async (
options: Required<Pick<AdaptivePlaywrightCrawlerOptions, 'requestHandler' | 'renderingTypePredictor'>> &
Partial<AdaptivePlaywrightCrawlerOptions>,
sources: string[],
) =>
new AdaptivePlaywrightCrawler({
renderingTypeDetectionRatio: 0.1,
maxConcurrency: 1,
maxRequestRetries: 0,
maxRequestsPerCrawl: 1,
requestList: await RequestList.open({ sources }),
...options,
});
const makeRiggedRenderingTypePredictor = (prediction: {
detectionProbabilityRecommendation: number;
renderingType: 'clientOnly' | 'static';
}) => ({
initialize: async () => {},
predict: vi.fn((_request: Request) => prediction),
storeResult: vi.fn((_request: Request, _renderingType: string) => {}),
});
describe('should detect page rendering type', () => {
test.each([
['/static', 'static'],
['/dynamic', 'clientOnly'],
] as const)('for %s', async (path, expectedType) => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 1,
renderingType: 'clientOnly',
});
const url = new URL(`http://${HOSTNAME}:${port}${path}`);
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(
async ({ pushData, parseWithCheerio }) => {
const $ = await parseWithCheerio('h1');
await pushData({
heading: $('h1').text(),
});
},
);
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
},
[url.toString()],
);
await crawler.run();
// Check the detection result
expect(renderingTypePredictor.predict).toHaveBeenCalledOnce();
expect(renderingTypePredictor.predict.mock.lastCall?.[0]).toMatchObject({ url, label: undefined });
expect(renderingTypePredictor.storeResult).toHaveBeenCalledOnce();
expect(renderingTypePredictor.storeResult.mock.lastCall?.[0]).toMatchObject({ url, label: undefined });
expect(renderingTypePredictor.storeResult.mock.lastCall?.[1]).toEqual(expectedType);
// Check if the request handler was called twice
expect(requestHandler).toHaveBeenCalledTimes(2);
// Check if only one item was added to the dataset
expect(await localStorageEmulator.getDatasetItems()).toEqual([{ heading: 'Heading' }]);
});
});
test('should not store detection results on non-detection runs', async () => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType: 'static',
});
const url = new URL(`http://${HOSTNAME}:${port}/static`);
const crawler = await makeOneshotCrawler(
{
requestHandler: async () => {},
renderingTypePredictor,
},
[url.toString()],
);
await crawler.run();
expect(renderingTypePredictor.predict).toHaveBeenCalledOnce();
expect(renderingTypePredictor.predict.mock.lastCall?.[0]).toMatchObject({ url, label: undefined });
expect(renderingTypePredictor.storeResult).not.toHaveBeenCalled();
});
test('should retry with browser if result checker returns false', async () => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType: 'static',
});
const url = new URL(`http://${HOSTNAME}:${port}/dynamic`);
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(
async ({ pushData, querySelector }) => {
await pushData({
heading: (await querySelector('h1')).text(),
});
},
);
const resultChecker: AdaptivePlaywrightCrawlerOptions['resultChecker'] = vi.fn(
(result) =>
result.datasetItems.length > 0 &&
result.datasetItems.every(({ item }: Dictionary) => item.heading?.length > 0),
);
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
resultChecker,
},
[url.toString()],
);
await crawler.run();
expect(requestHandler).toHaveBeenCalledTimes(2);
expect(resultChecker).toHaveBeenCalledTimes(1);
});
describe('shouldPropagateError', () => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType: 'static',
});
const failedRequestHandler = vi.fn();
const testError = new Error('HTTP handler failed');
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(async () => {
throw testError;
});
beforeEach(() => {
vi.clearAllMocks();
});
test('should fall back to browser when shouldPropagateError returns false', async () => {
const shouldPropagateError = vi.fn(() => false);
const url = new URL(`http://${HOSTNAME}:${port}/static`);
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
shouldPropagateError,
failedRequestHandler,
},
[url.toString()],
);
await crawler.run();
expect(shouldPropagateError).toHaveBeenCalledOnce();
expect(shouldPropagateError).toHaveBeenCalledWith(testError, expect.anything());
expect(requestHandler).toHaveBeenCalledTimes(2);
});
test('should propagate error when shouldPropagateError returns true', async () => {
const shouldPropagateError = vi.fn(() => true);
const url = new URL(`http://${HOSTNAME}:${port}/static`);
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
shouldPropagateError,
failedRequestHandler,
},
[url.toString()],
);
await crawler.run();
expect(shouldPropagateError).toHaveBeenCalledOnce();
expect(shouldPropagateError).toHaveBeenCalledWith(testError, expect.anything());
expect(requestHandler).toHaveBeenCalledTimes(1);
expect(failedRequestHandler).toHaveBeenCalledOnce();
expect(failedRequestHandler.mock.calls[0][1]).toBe(testError);
});
});
test.each([
['static'],
['clientOnly'],
] as const)('crawlingContext.addRequests() should add requests correctly (%s)', async (renderingType) => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType,
});
const url = new URL(`http://${HOSTNAME}:${port}`).toString();
let requestContext: LoadedContext<AdaptivePlaywrightCrawlerContext> | undefined;
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = async (context) => {
const isStartUrl = context.request.url === url;
if (isStartUrl) await context.addRequests([`${url}/1`]);
else requestContext = context;
};
const crawler = await makeOneshotCrawler(
{ requestHandler, renderingTypePredictor, maxRequestsPerCrawl: 10 },
[],
);
await crawler.run([{ url, crawlDepth: 2 }]);
assert(requestContext);
expect(requestContext.request).toMatchObject({ url: `${url}/1`, crawlDepth: 3 });
});
describe('should enqueue links correctly', () => {
test.each([
['/static', 'static'],
['/dynamic', 'clientOnly'],
] as const)('for %s', async (path, renderingType) => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType,
});
const url = new URL(`http://${HOSTNAME}:${port}${path}`);
const enqueuedRequests: Request[] = [];
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(
async ({ enqueueLinks, request }) => {
if (request.label === 'enqueued-url') {
enqueuedRequests.push(request);
} else {
await enqueueLinks({ label: 'enqueued-url' });
}
},
);
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
maxRequestsPerCrawl: 10,
},
[url.toString()],
);
await crawler.run();
const enqueuedUrls = Array.from(enqueuedRequests).map((request) => request.url);
expect(new Set(enqueuedUrls)).toEqual(
new Set([
`http://${HOSTNAME}:${port}/static?q=1`,
`http://${HOSTNAME}:${port}/static?q=2`,
`http://${HOSTNAME}:${port}/static?q=3`,
`http://${HOSTNAME}:${port}/static?q=4`,
`http://${HOSTNAME}:${port}/static?q=5`,
]),
);
expect(enqueuedRequests[0]).toMatchObject({
url: enqueuedUrls[0],
label: 'enqueued-url',
crawlDepth: 1,
});
});
});
test.each([
['static'],
['clientOnly'],
] as const)('should respect the strategy option for enqueueLinks (%s)', async (renderingType) => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType,
});
const url = new URL(`http://${HOSTNAME}:${port}/external-links`);
const enqueuedUrls = new Set<string>();
const visitedUrls = new Set<string>();
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(
async ({ enqueueLinks, request }) => {
visitedUrls.add(request.loadedUrl);
if (!request.label) {
const result = await enqueueLinks({
label: 'enqueued-url',
strategy: 'same-hostname',
});
for (const processedRequest of result.processedRequests) {
enqueuedUrls.add(processedRequest.uniqueKey);
}
}
},
);
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
maxRequestsPerCrawl: 10,
},
[url.toString()],
);
await crawler.run();
expect(new Set(visitedUrls)).toEqual(new Set([`http://${HOSTNAME}:${port}/external-links`]));
expect(new Set(enqueuedUrls)).toEqual(new Set([`http://${HOSTNAME}:${port}/external-redirect`]));
});
test('should persist crawler state', async () => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType: 'static',
});
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(async ({ useState }) => {
const state = await useState({ count: 0 });
state.count += 1;
});
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
maxRequestsPerCrawl: 3,
},
[
`http://${HOSTNAME}:${port}/static?q=1`,
`http://${HOSTNAME}:${port}/static?q=2`,
`http://${HOSTNAME}:${port}/static?q=3`,
],
);
await crawler.run();
const state = await localStorageEmulator.getState();
expect(state!.value).toEqual({ count: 3 });
});
test('should return deeply equal but not identical state objects across handler runs', async () => {
// Force detection to happen
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 1,
renderingType: 'clientOnly',
});
// We'll store state references to compare them later
const stateReferences: any[] = [];
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(async ({ useState }) => {
const state = await useState({ data: { nested: { value: 42 } } });
stateReferences.push(JSON.parse(JSON.stringify(state)));
state.randomNumber = Math.random();
});
// Run the crawler
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
},
[`http://${HOSTNAME}:${port}/static`],
);
await crawler.run();
// The request handler should have run twice (once in browser, once in HTTP-only mode for detection)
expect(requestHandler).toHaveBeenCalledTimes(2);
expect(stateReferences).toHaveLength(2);
// The state objects should be deeply equal (same values)
expect(stateReferences[0]).toEqual(stateReferences[1]);
// But they should not be the same object instance (different references)
// This is important to ensure that state objects are properly cloned between handler runs
// and that modifications to one state object don't affect others
expect(stateReferences[0]).not.toBe(stateReferences[1]);
});
test('should persist key-value store changes', async () => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType: 'static',
});
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(
async ({ request, getKeyValueStore }) => {
const store = await getKeyValueStore();
const search = new URLSearchParams(new URL(request.url).search);
store.setValue(search.get('q'), { content: 42 });
},
);
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
maxRequestsPerCrawl: 3,
},
[
`http://${HOSTNAME}:${port}/static?q=1`,
`http://${HOSTNAME}:${port}/static?q=2`,
`http://${HOSTNAME}:${port}/static?q=3`,
],
);
await crawler.run();
const store = localStorageEmulator.getKeyValueStore();
expect((await store.getRecord('1'))!.value).toEqual({ content: 42 });
expect((await store.getRecord('2'))!.value).toEqual({ content: 42 });
expect((await store.getRecord('3'))!.value).toEqual({ content: 42 });
});
test('should not allow direct key-value store manipulation', async () => {
const renderingTypePredictor = makeRiggedRenderingTypePredictor({
detectionProbabilityRecommendation: 0,
renderingType: 'static',
});
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(async () => {
const store = await KeyValueStore.open();
await store.setValue('1', { content: 42 });
});
const failedRequestHandler = vi.fn();
const crawler = await makeOneshotCrawler(
{
requestHandler,
renderingTypePredictor,
maxRequestsPerCrawl: 3,
maxRequestRetries: 0,
failedRequestHandler,
},
[`http://${HOSTNAME}:${port}/static`],
);
await crawler.run();
expect(failedRequestHandler.mock.calls).toHaveLength(1);
expect((failedRequestHandler.mock.calls[0][1] as Error).message).toEqual(
'Directly accessing storage in a request handler is not allowed in AdaptivePlaywrightCrawler',
);
const store = localStorageEmulator.getKeyValueStore();
expect(await store.getRecord('1')).toBeUndefined();
});
test('should persist RenderingTypePredictor state on PERSIST_STATE events', async () => {
const requestHandler: AdaptivePlaywrightCrawlerOptions['requestHandler'] = vi.fn(async ({ pushData }) => {
await pushData({ content: 'test data' });
});
const crawler = await makeOneshotCrawler(
{
requestHandler,
// Use a real RenderingTypePredictor instead of the mocked one
renderingTypePredictor: new RenderingTypePredictor({ detectionRatio: 1 }),
},
[`http://${HOSTNAME}:${port}/static`],
);
// Run the crawler - this will initialize the RenderingTypePredictor and potentially store results
await crawler.run();
// Now emit a PERSIST_STATE event to trigger state persistence
const events = Configuration.getEventManager();
events.emit(EventType.PERSIST_STATE);
// Wait a bit for the event to be processed
await sleep(100);
// Verify that the regression model was actually saved to the key-value store
const store = await KeyValueStore.open();
const storedState = await store.getValue<string>('rendering-type-predictor-state');
expect(storedState).not.toBeNull();
const parsedState = JSON.parse(storedState!);
expect(parsedState).toHaveProperty('logreg');
// Test that the persisted state can be successfully restored
// by creating a new RenderingTypePredictor and seeing if it initializes without error
const newPredictor = new RenderingTypePredictor({
detectionRatio: 0.1,
persistenceOptions: { persistStateKey: 'rendering-type-predictor-state' },
});
// This should not throw since we've persisted valid state
await expect(newPredictor.initialize()).resolves.not.toThrow();
});
});