Skip to content

Commit 5d3983a

Browse files
fix: let one crawler set serve every proxy a caller asks for
1 parent ca5564e commit 5d3983a

11 files changed

Lines changed: 220 additions & 113 deletions

‎src/crawlers.ts‎

Lines changed: 48 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { createHash } from 'node:crypto';
21
import { readFile } from 'node:fs/promises';
32

43
import { ImpitHttpClient } from '@crawlee/impit-client';
@@ -33,15 +32,15 @@ import type {
3332
ContentCrawlerOptions,
3433
ContentCrawlerUserData,
3534
Output,
36-
ProxyOptions,
37-
SearchCrawlerOptions,
3835
SearchCrawlerUserData,
3936
} from './types.js';
4037
import { addTimeMeasureEvent, createRequest, createSearchRequest } from './utils.js';
4138

4239
type Crawler = CheerioCrawler | PlaywrightCrawler;
4340

44-
const crawlers = new Map<string, Crawler>();
41+
// A kind is all that distinguishes one crawler from another: nothing a caller can ask for reaches a
42+
// crawler constructor any more, so the set cannot grow past one crawler per kind.
43+
const crawlers = new Map<CrawlerKind, Promise<Crawler>>();
4544
const client = new MemoryStorage({ persistStorage: false });
4645

4746
const contentCrawlerHttpClient = new ImpitHttpClient({
@@ -67,41 +66,43 @@ async function getGhosteryBlocker(): Promise<PlaywrightBlocker | undefined> {
6766
}
6867
}
6968

70-
/**
71-
* Identifies a crawler in the `crawlers` cache, and names its request queue. Keyed off the caller's
72-
* proxy options rather than the built `ProxyConfiguration`, whose child logger snapshots the log level
73-
* and whose URL rotation state mutates - both of which kept changing the key. Hashed because the key
74-
* is logged and used as a queue name, while the options can hold credentials.
75-
*/
76-
export function getCrawlerKey(kind: CrawlerKind, proxyOptions: ProxyOptions): string {
77-
const hash = createHash('sha1').update(JSON.stringify(proxyOptions)).digest('hex');
78-
return `${kind}-${hash.slice(0, 12)}`;
79-
}
80-
8169
/** How many crawlers are alive. Exported so tests can assert that requests do not fork new ones. */
8270
export function getCrawlerCount() {
8371
return crawlers.size;
8472
}
8573

86-
async function getOrCreateCrawler(key: string, kind: CrawlerKind, startCrawler: boolean, build: () => Promise<Crawler>) {
87-
const cached = crawlers.get(key);
74+
/**
75+
* Holds the pending crawler rather than the built one: the server is listening before the crawlers
76+
* started at boot have finished building, so two concurrent requests could otherwise each build one
77+
* and orphan all but the last. A crawler that fails to build or to run is dropped, so the cache can
78+
* never hand out a dead one.
79+
*/
80+
async function getOrCreateCrawler(kind: CrawlerKind, startCrawler: boolean, build: () => Promise<Crawler>) {
81+
const cached = crawlers.get(kind);
8882
if (cached) {
8983
return cached;
9084
}
9185

92-
log.info(`Creating new ${kind} crawler with key ${key}`);
93-
const crawler = await build();
94-
if (startCrawler) {
95-
crawler.run().then(
96-
() => log.warning(`Crawler ${kind} has finished`),
97-
// eslint-disable-next-line @typescript-eslint/no-empty-function
98-
() => { },
99-
);
100-
log.info(`Crawler ${kind} has started 💪🏼`);
101-
}
102-
crawlers.set(key, crawler);
86+
const pending = (async () => {
87+
log.info(`Creating new ${kind} crawler`);
88+
const crawler = await build();
89+
if (startCrawler) {
90+
crawler.run().then(
91+
() => log.warning(`Crawler ${kind} has finished`),
92+
(err) => {
93+
log.error(`Crawler ${kind} failed to run: ${err instanceof Error ? err.message : String(err)}`);
94+
crawlers.delete(kind);
95+
},
96+
);
97+
log.info(`Crawler ${kind} has started 💪🏼`);
98+
}
99+
return crawler;
100+
})();
101+
102+
crawlers.set(kind, pending);
103+
pending.catch(() => crawlers.delete(kind));
103104
log.info(`Number of crawlers ${crawlers.size}`);
104-
return crawler;
105+
return pending;
105106
}
106107

107108
/**
@@ -110,14 +111,15 @@ async function getOrCreateCrawler(key: string, kind: CrawlerKind, startCrawler:
110111
export const addContentCrawlRequest = async (
111112
request: RequestOptions<ContentCrawlerUserData>,
112113
responseId: string,
113-
contentCrawlerKey: string,
114+
contentCrawlerKey: CrawlerKind,
114115
) => {
115-
const crawler = crawlers.get(contentCrawlerKey);
116-
if (!crawler) {
116+
const pending = crawlers.get(contentCrawlerKey);
117+
if (!pending) {
117118
log.error(`Content crawler not found: ${contentCrawlerKey}`);
118119
return;
119120
}
120121

122+
const crawler = await pending;
121123
const name = crawler instanceof PlaywrightCrawler ? 'playwright' : 'cheerio';
122124
try {
123125
await crawler.requestQueue!.addRequest(request);
@@ -135,13 +137,12 @@ export const addContentCrawlRequest = async (
135137
* A crawler won't be created if it already exists.
136138
*/
137139
export async function createAndStartSearchCrawler(
138-
searchCrawlerOptions: SearchCrawlerOptions,
140+
searchCrawlerOptions: CheerioCrawlerOptions,
139141
startCrawler = true,
140142
) {
141-
const { crawlerOptions, proxyOptions } = searchCrawlerOptions;
142-
const key = getCrawlerKey('search', proxyOptions);
143-
const crawler = await getOrCreateCrawler(key, 'search', startCrawler, async () => new CheerioCrawler({
144-
...crawlerOptions,
143+
const key: CrawlerKind = 'search';
144+
const crawler = await getOrCreateCrawler(key, startCrawler, async () => new CheerioCrawler({
145+
...searchCrawlerOptions,
145146
requestQueue: await RequestQueue.open(key, { storageClient: client }),
146147
requestHandler: async ({ request, $: _$, addRequests }: CheerioCrawlingContext<SearchCrawlerUserData>) => {
147148
// NOTE: we need to cast this to fix `cheerio` type errors
@@ -189,7 +190,6 @@ export async function createAndStartSearchCrawler(
189190
collectedResults: deduplicated,
190191
currentPage: nextPage,
191192
},
192-
proxyOptions,
193193
nextOffset,
194194
);
195195
await addRequests([nextRequest]);
@@ -208,6 +208,7 @@ export async function createAndStartSearchCrawler(
208208
result,
209209
responseId,
210210
request.userData.contentScraperSettings!,
211+
request.userData.contentProxyOptions,
211212
request.userData.timeMeasures!,
212213
actorRequestId,
213214
);
@@ -235,16 +236,15 @@ export async function createAndStartContentCrawler(
235236
contentCrawlerOptions: ContentCrawlerOptions,
236237
startCrawler = true,
237238
) {
238-
const { type: crawlerType, crawlerOptions, proxyOptions } = contentCrawlerOptions;
239+
const { type: crawlerType, crawlerOptions } = contentCrawlerOptions;
239240

240-
const key = getCrawlerKey(crawlerType, proxyOptions);
241-
const crawler = await getOrCreateCrawler(key, crawlerType, startCrawler, async () => (
241+
const crawler = await getOrCreateCrawler(crawlerType, startCrawler, async () => (
242242
crawlerType === ContentCrawlerTypes.PLAYWRIGHT
243-
? createPlaywrightContentCrawler(crawlerOptions, key)
244-
: createCheerioContentCrawler(crawlerOptions, key)
243+
? createPlaywrightContentCrawler(crawlerOptions, crawlerType)
244+
: createCheerioContentCrawler(crawlerOptions, crawlerType)
245245
));
246246

247-
return { key, crawler };
247+
return { key: crawlerType, crawler };
248248
}
249249

250250
/**
@@ -326,14 +326,15 @@ async function createCheerioContentCrawler(
326326
*/
327327
export const addSearchRequest = async (
328328
request: RequestOptions<ContentCrawlerUserData>,
329-
searchCrawlerKey: string,
329+
searchCrawlerKey: CrawlerKind,
330330
) => {
331-
const crawler = crawlers.get(searchCrawlerKey);
332-
if (!crawler) {
331+
const pending = crawlers.get(searchCrawlerKey);
332+
if (!pending) {
333333
log.error(`Search crawler not found: ${searchCrawlerKey}`);
334334
return;
335335
}
336336

337+
const crawler = await pending;
337338
addTimeMeasureEvent(request.userData!, 'before-cheerio-queue-add');
338339
await crawler.requestQueue!.addRequest(request);
339340
log.info(`Added request to cheerio-google-search-crawler: ${request.url}`);

‎src/input.ts‎

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ProxyConfigurationOptions } from 'apify';
22
import { Actor } from 'apify';
3-
import type { ProxyConfiguration } from 'crawlee';
4-
import { BrowserName, log } from 'crawlee';
3+
import type { CheerioCrawlerOptions } from 'crawlee';
4+
import { BrowserName, log, ProxyConfiguration } from 'crawlee';
55
import { firefox } from 'playwright';
66

77
import ragWebBrowserInputSchema from '../actors/apify_rag-web-browser/.actor/input_schema.json' with { type: 'json' };
@@ -11,17 +11,17 @@ import { blockMediaRequests } from './media.js';
1111
import { getMiniActor } from './mini-actors.js';
1212
import type {
1313
ContentCrawlerOptions,
14+
ContentCrawlerUserData,
1415
ContentScraperSettings,
1516
Input,
1617
OutputFormats,
1718
ProxyOptions,
1819
RagWebBrowserInput,
1920
ScrapingTool,
20-
SearchCrawlerOptions,
2121
SERPProxyGroup,
2222
UrlToMarkdownInput,
2323
} from './types.js';
24-
import { abortRun } from './utils.js';
24+
import { abortRun, isActorStandby } from './utils.js';
2525

2626
const proxyConfigurations = new Map<string, Promise<ProxyConfiguration | undefined>>();
2727

@@ -44,11 +44,43 @@ async function getProxyConfiguration(proxyOptions: ProxyOptions) {
4444
return configuration;
4545
}
4646

47-
async function createContentProxyConfiguration(proxyOptions: ProxyOptions) {
47+
const DEFAULT_PROXY_OPTIONS = ragWebBrowserInputSchema.properties.proxyConfiguration.default as ProxyOptions;
48+
49+
/**
50+
* The configuration every crawler is built with. It resolves the proxy from the request Crawlee is
51+
* about to send rather than from the input the crawler was built with, so one crawler serves callers
52+
* that each asked for a different proxy - which is what keeps the Standby crawler set at three
53+
* however many callers there are.
54+
*
55+
* Crawlee calls this per request for the HTTP crawlers and per page for the browser one. The browser
56+
* one only honours it because its launch context sets `useIncognitoPages`; without that, BrowserPool
57+
* hands every request whichever browser is free and they all go through the first browser's proxy.
58+
*/
59+
const requestProxyConfiguration = new ProxyConfiguration({
60+
newUrlFunction: async (sessionId, options) => {
61+
// Crawlee also resolves a proxy when it launches a browser, with no request in hand.
62+
const userData = options?.request?.userData as Partial<ContentCrawlerUserData> | undefined;
63+
const configuration = await getProxyConfiguration(userData?.proxyOptions ?? DEFAULT_PROXY_OPTIONS);
64+
65+
// Crawlee reads this off the configuration, right after this call, to decide whether to
66+
// ignore TLS errors. Keep it in step with whichever configuration served the request.
67+
requestProxyConfiguration.isManInTheMiddle = configuration?.isManInTheMiddle ?? false;
68+
69+
return (await configuration?.newUrl(sessionId)) ?? null;
70+
},
71+
});
72+
73+
/**
74+
* Builds the caller's proxy configuration so that an unusable one is reported now, rather than as a
75+
* run of failed pages later. The crawlers themselves route through `requestProxyConfiguration`.
76+
*/
77+
async function validateProxyConfiguration(proxyOptions: ProxyOptions, abortOnFailure: boolean) {
4878
try {
49-
return await getProxyConfiguration(proxyOptions);
79+
await getProxyConfiguration(proxyOptions);
5080
} catch (e) {
51-
return abortRun(`Cannot use Apify Proxy for scraping the target pages: ${(e as Error).message}`);
81+
const message = `Cannot use Apify Proxy for scraping the target pages: ${(e as Error).message}`;
82+
if (!abortOnFailure) throw new Error(message);
83+
await abortRun(message);
5284
}
5385
}
5486

@@ -59,10 +91,10 @@ async function createContentProxyConfiguration(proxyOptions: ProxyOptions) {
5991
export async function processStandbyInput(originalInput: Partial<Input>) {
6092
const { input, searchCrawlerOptions, contentScraperSettings } = await processInputInternal(originalInput, true);
6193

62-
const proxy = await createContentProxyConfiguration(input.proxyConfiguration);
94+
await validateProxyConfiguration(input.proxyConfiguration, true);
6395
const contentCrawlerOptions: ContentCrawlerOptions[] = [
64-
createPlaywrightCrawlerOptions(input, proxy),
65-
createCheerioCrawlerOptions(input, proxy),
96+
createPlaywrightCrawlerOptions(input),
97+
createCheerioCrawlerOptions(input),
6698
];
6799

68100
return { input, searchCrawlerOptions, contentCrawlerOptions, contentScraperSettings };
@@ -74,10 +106,12 @@ export async function processStandbyInput(originalInput: Partial<Input>) {
74106
export async function processInput(originalInput: Partial<Input>) {
75107
const { input, searchCrawlerOptions, contentScraperSettings } = await processInputInternal(originalInput);
76108

77-
const proxy = await createContentProxyConfiguration(input.proxyConfiguration);
109+
// In Standby this runs once per request, where a proxy problem must fail that request alone. In
110+
// Normal mode it is the run's startup, where there is nothing to serve and the run ends.
111+
await validateProxyConfiguration(input.proxyConfiguration, !isActorStandby());
78112
const contentCrawlerOptions: ContentCrawlerOptions = input.scrapingTool === 'raw-http'
79-
? createCheerioCrawlerOptions(input, proxy, false)
80-
: createPlaywrightCrawlerOptions(input, proxy, false);
113+
? createCheerioCrawlerOptions(input, false)
114+
: createPlaywrightCrawlerOptions(input, false);
81115

82116
return { input, searchCrawlerOptions, contentCrawlerOptions, contentScraperSettings };
83117
}
@@ -91,7 +125,7 @@ async function processInputInternal(
91125
) {
92126
const miniActor = getMiniActor();
93127
let input: Input;
94-
let searchCrawlerOptions: SearchCrawlerOptions = { crawlerOptions: {}, proxyOptions: {} };
128+
let searchCrawlerOptions: CheerioCrawlerOptions = {};
95129

96130
if (miniActor.runsSearch) {
97131
const processedRagWebBrowserInput = await processRagWebBrowserInput(
@@ -130,7 +164,7 @@ async function processInputInternal(
130164
async function processRagWebBrowserInput(input: Partial<RagWebBrowserInput>, standbyInit: boolean):
131165
Promise<{
132166
validatedRagBrowserInput: RagWebBrowserInput;
133-
searchCrawlerOptions: SearchCrawlerOptions
167+
searchCrawlerOptions: CheerioCrawlerOptions
134168
}> {
135169
/* eslint-disable no-param-reassign */
136170

@@ -199,15 +233,11 @@ async function processRagWebBrowserInput(input: Partial<RagWebBrowserInput>, sta
199233
input.dynamicContentWaitSecs = Math.round(input.requestTimeoutSecs / 2);
200234
}
201235

202-
const proxyOptions: ProxyOptions = { groups: [input.serpProxyGroup], checkAccess: false };
203-
const searchCrawlerOptions: SearchCrawlerOptions = {
204-
crawlerOptions: {
205-
keepAlive: standbyInit,
206-
maxRequestRetries: CRAWLER_MAX_REQUEST_RETRIES,
207-
proxyConfiguration: await getProxyConfiguration(proxyOptions),
208-
autoscaledPoolOptions: { desiredConcurrency: 1 },
209-
},
210-
proxyOptions,
236+
const searchCrawlerOptions: CheerioCrawlerOptions = {
237+
keepAlive: standbyInit,
238+
maxRequestRetries: CRAWLER_MAX_REQUEST_RETRIES,
239+
proxyConfiguration: requestProxyConfiguration,
240+
autoscaledPoolOptions: { desiredConcurrency: 1 },
211241
};
212242
const validatedRagBrowserInput = validateAndFillInput(input) as RagWebBrowserInput;
213243
return {
@@ -248,20 +278,27 @@ async function processUrlToMarkdownInput(input: Partial<UrlToMarkdownInput>): Pr
248278

249279
function createPlaywrightCrawlerOptions(
250280
input: Input,
251-
proxy: ProxyConfiguration | undefined,
252281
keepAlive = true,
253282
): ContentCrawlerOptions {
254283
return {
255284
type: ContentCrawlerTypes.PLAYWRIGHT,
256-
proxyOptions: input.proxyConfiguration,
257285
crawlerOptions: {
258286
headless: true,
259287
keepAlive,
288+
// The session pool is shared by every caller, so a session's cookies and its Apify Proxy
289+
// session id would be reused across requests that went out through different proxies -
290+
// one caller's cookies riding another's IP. A request here is one page, which gains
291+
// nothing from a session, so there is none.
292+
useSessionPool: false,
260293
maxRequestRetries: CRAWLER_MAX_REQUEST_RETRIES,
261-
proxyConfiguration: proxy,
294+
proxyConfiguration: requestProxyConfiguration,
262295
requestHandlerTimeoutSecs: CRAWLER_REQUEST_HANDLER_TIMEOUT_SECS,
263296
launchContext: {
264297
launcher: firefox,
298+
// Gives each page its own browser context, which is what lets a page carry the proxy
299+
// its request asked for. Without it BrowserPool reuses whichever browser is free and
300+
// every request goes through the proxy the first one launched with - silently.
301+
useIncognitoPages: true,
265302
},
266303
preNavigationHooks: [
267304
async ({ page }) => {
@@ -289,17 +326,16 @@ function createPlaywrightCrawlerOptions(
289326

290327
function createCheerioCrawlerOptions(
291328
input: Input,
292-
proxy: ProxyConfiguration | undefined,
293329
keepAlive = true,
294330
): ContentCrawlerOptions {
295331
return {
296332
type: ContentCrawlerTypes.CHEERIO,
297-
proxyOptions: input.proxyConfiguration,
298333
crawlerOptions: {
299334
keepAlive,
300335
maxRequestRetries: CRAWLER_MAX_REQUEST_RETRIES,
301-
proxyConfiguration: proxy,
336+
proxyConfiguration: requestProxyConfiguration,
302337
requestHandlerTimeoutSecs: CRAWLER_REQUEST_HANDLER_TIMEOUT_SECS,
338+
useSessionPool: false,
303339
autoscaledPoolOptions: {
304340
desiredConcurrency: input.desiredConcurrency,
305341
},

0 commit comments

Comments
 (0)