@opencraw/core 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/dist/index.esm.js +762 -71
  3. package/dist/src/access/access-profile.contract.d.ts +4 -0
  4. package/dist/src/access/index.d.ts +1 -1
  5. package/dist/src/api-steps/send-request.use-case.d.ts +3 -2
  6. package/dist/src/browser-session/browser-profile.store.d.ts +52 -0
  7. package/dist/src/browser-session/browser.client.d.ts +8 -0
  8. package/dist/src/browser-session/index.d.ts +1 -0
  9. package/dist/src/crawl-events/crawl-event.contract.d.ts +8 -0
  10. package/dist/src/crawl-execution/bootstrap-session.use-case.d.ts +23 -2
  11. package/dist/src/crawl-execution/crawl-options.config.d.ts +27 -0
  12. package/dist/src/crawl-execution/rotating-runner.use-case.d.ts +9 -0
  13. package/dist/src/crawl-execution/run-crawl.use-case.d.ts +6 -2
  14. package/dist/src/crawl-execution/run-input-recipe.use-case.d.ts +11 -3
  15. package/dist/src/index.d.ts +5 -4
  16. package/dist/src/recipe-schema/index.d.ts +2 -2
  17. package/dist/src/recipe-schema/input-recipe.contract.d.ts +24 -1
  18. package/dist/src/record-sink/dedupe.policy.d.ts +19 -7
  19. package/dist/src/record-sink/index.d.ts +1 -0
  20. package/dist/src/step-flow/for-each.use-case.d.ts +4 -2
  21. package/dist/src/step-flow/host-throttle.policy.d.ts +49 -0
  22. package/dist/src/step-flow/index.d.ts +5 -0
  23. package/dist/src/step-flow/run-gate.policy.d.ts +12 -1
  24. package/dist/src/step-flow/step-runner.contract.d.ts +13 -0
  25. package/dist/src/step-flow/transport-retry.policy.d.ts +76 -0
  26. package/dist/src/web-steps/navigate.use-case.d.ts +3 -2
  27. package/dist/src/web-steps/run-web-step.use-case.d.ts +8 -1
  28. package/package.json +1 -1
package/dist/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { randomInt, randomUUID } from 'node:crypto';
3
- import { readFile, mkdir, writeFile, stat, readdir } from 'node:fs/promises';
4
- import { resolve as resolve$1, dirname, extname, join as join$1 } from 'node:path';
3
+ import { readFile, mkdir, writeFile, rm, stat, readdir } from 'node:fs/promises';
4
+ import { resolve as resolve$1, join as join$1, dirname, extname } from 'node:path';
5
5
  import { firefox, webkit, chromium, request } from 'playwright';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { once } from 'node:events';
@@ -67,10 +67,20 @@ const pluginSchema = z.strictObject({
67
67
  options: z.record(z.string(), z.unknown()).optional()
68
68
  });
69
69
  const accessProfileSchema = z.union([directSchema, proxySchema, poolSchema, cdpSchema, pluginSchema]);
70
+ const hostRuleSchema = z.strictObject({
71
+ delayMs: z.int().nonnegative().optional(),
72
+ concurrency: z.int().min(1).max(256).optional()
73
+ });
74
+ const throttleConfigSchema = z.strictObject({
75
+ delayMs: z.int().nonnegative().optional(),
76
+ concurrency: z.int().min(1).max(256).optional(),
77
+ domains: z.record(z.string().regex(/^[\w.-]+$/, 'a domain such as example.com'), hostRuleSchema).optional()
78
+ });
70
79
  const accessConfigSchema = z.strictObject({
71
80
  $schema: z.string().optional(),
72
81
  profiles: z.record(z.string().regex(/^[\w-]+$/, 'a profile name is letters, digits, hyphens and underscores'), accessProfileSchema),
73
- default: z.string().optional()
82
+ default: z.string().optional(),
83
+ throttle: throttleConfigSchema.optional()
74
84
  }).refine(config => config.default === undefined || Object.hasOwn(config.profiles, config.default), {
75
85
  message: 'default names a profile that does not exist',
76
86
  path: ['default']
@@ -1179,6 +1189,17 @@ async function blockResources(context, types) {
1179
1189
  await route.continue();
1180
1190
  });
1181
1191
  }
1192
+ /**
1193
+ * What a context gets after it opened: the cookies to add and the resource
1194
+ * types to skip.
1195
+ *
1196
+ * @param context - The context.
1197
+ * @param options - The session options.
1198
+ */
1199
+ async function applySessionExtras(context, options) {
1200
+ if (options.cookies !== undefined && options.cookies.length > 0) await context.addCookies(options.cookies);
1201
+ if (options.blockResources !== undefined && options.blockResources.length > 0) await blockResources(context, new Set(options.blockResources));
1202
+ }
1182
1203
  /** A launched browser; sessions are opened from it and closed independently. */
1183
1204
  class BrowserClient {
1184
1205
  browser;
@@ -1238,8 +1259,7 @@ class BrowserClient {
1238
1259
  };
1239
1260
  const context = await this.browser.newContext(contextOptions);
1240
1261
  if (this.config.timeoutMs !== undefined) context.setDefaultTimeout(this.config.timeoutMs);
1241
- if (options.cookies !== undefined && options.cookies.length > 0) await context.addCookies(options.cookies);
1242
- if (options.blockResources !== undefined && options.blockResources.length > 0) await blockResources(context, new Set(options.blockResources));
1262
+ await applySessionExtras(context, options);
1243
1263
  const page = await context.newPage();
1244
1264
  return new BrowserSession(context, page);
1245
1265
  }
@@ -1248,13 +1268,206 @@ class BrowserClient {
1248
1268
  }
1249
1269
  }
1250
1270
 
1271
+ /** The file that marks a profile as in use, holding the owning process id. */
1272
+ const LOCK_FILE = '.opencraw.lock';
1273
+ /** A browser profile name: it becomes a directory, so no separators or dots. */
1274
+ const BROWSER_PROFILE_NAME = /^[\w-]+$/;
1275
+ /**
1276
+ * Browser profiles that persist between runs: each is a directory of a real
1277
+ * browser's user data (cookies, local storage, IndexedDB, cache, service
1278
+ * workers), so a login, a consent choice or a site's trust in a returning
1279
+ * visitor carries over to the next run. The browser equivalent of a user who
1280
+ * never clears their history.
1281
+ *
1282
+ * A profile directory can be open in one browser at a time. Within this
1283
+ * crawler, a second use waits for the first to close; the same owner (one
1284
+ * recipe run reopening after a rotation) takes it over instead. Another
1285
+ * crawler holding it, in this process or another, is reported, not waited
1286
+ * for: a lock file in the profile names the process, and one left by a
1287
+ * process that died is taken over. (Chromium's own profile lock is not
1288
+ * enough: headless builds do not take it.)
1289
+ */
1290
+ class BrowserProfiles {
1291
+ directory;
1292
+ config;
1293
+ held = new Map();
1294
+ waiting = new Map();
1295
+ /**
1296
+ * @param directory - Where the profiles live, one subdirectory each.
1297
+ * @param config - The crawler's browser settings (type, binary, headless, timeouts).
1298
+ */
1299
+ constructor(directory, config = {}) {
1300
+ this.directory = directory;
1301
+ this.config = config;
1302
+ }
1303
+ async take(name, owner) {
1304
+ for (;;) {
1305
+ const holder = this.held.get(name);
1306
+ if (holder === undefined) return;
1307
+ if (holder.owner === owner) {
1308
+ await holder.session.close();
1309
+ continue;
1310
+ }
1311
+ await new Promise(resolve => {
1312
+ const queue = this.waiting.get(name) ?? [];
1313
+ queue.push(resolve);
1314
+ this.waiting.set(name, queue);
1315
+ });
1316
+ }
1317
+ }
1318
+ free(name) {
1319
+ this.held.delete(name);
1320
+ this.waiting.get(name)?.shift()?.();
1321
+ }
1322
+ launch(path, options) {
1323
+ const type = this.config.browserType ?? DEFAULT_BROWSER_CONFIG.browserType;
1324
+ const launcher = type === 'firefox' ? firefox : type === 'webkit' ? webkit : chromium;
1325
+ return launcher.launchPersistentContext(path, {
1326
+ headless: this.config.headless ?? DEFAULT_BROWSER_CONFIG.headless,
1327
+ slowMo: this.config.slowMo,
1328
+ executablePath: this.config.executablePath,
1329
+ proxy: options.proxy ?? this.config.proxy,
1330
+ extraHTTPHeaders: options.headers,
1331
+ userAgent: options.userAgent,
1332
+ viewport: options.viewport,
1333
+ ignoreHTTPSErrors: this.config.ignoreHTTPSErrors === true || options.ignoreHTTPSErrors === true
1334
+ });
1335
+ }
1336
+ /**
1337
+ * The profile's directory.
1338
+ *
1339
+ * @param name - A profile name.
1340
+ * @returns The absolute path.
1341
+ */
1342
+ pathOf(name) {
1343
+ if (!BROWSER_PROFILE_NAME.test(name)) throw new Error(`browser profile "${name}": a name is letters, digits, hyphens and underscores`);
1344
+ return resolve$1(this.directory, name);
1345
+ }
1346
+ /**
1347
+ * Opens a profile in its own browser, waiting while another run of this
1348
+ * crawler uses it.
1349
+ *
1350
+ * @param name - The profile.
1351
+ * @param options - Proxy, headers, viewport, cookies to add. `storageState` is ignored: the profile has its own.
1352
+ * @param owner - Who opens it; the same owner reopening closes its previous session first.
1353
+ * @returns The session; closing it frees the profile.
1354
+ */
1355
+ async open(name, options, owner) {
1356
+ const path = this.pathOf(name);
1357
+ await this.take(name, owner);
1358
+ let context;
1359
+ let unlock = unlocked;
1360
+ try {
1361
+ await mkdir(path, {
1362
+ recursive: true
1363
+ });
1364
+ unlock = await lockProfile(path, name);
1365
+ context = await this.launch(path, options);
1366
+ } catch (error) {
1367
+ await unlock();
1368
+ this.free(name);
1369
+ const message = error instanceof Error ? error.message : String(error);
1370
+ if (/ProcessSingleton|SingletonLock|already in use/i.test(message)) throw new Error(`browser profile "${name}" is open in another browser (${path}); close it or use another profile`, {
1371
+ cause: error
1372
+ });
1373
+ throw error;
1374
+ }
1375
+ if (this.config.timeoutMs !== undefined) context.setDefaultTimeout(this.config.timeoutMs);
1376
+ await applySessionExtras(context, options);
1377
+ const page = context.pages()[0] ?? (await context.newPage());
1378
+ let closed = false;
1379
+ const session = new BrowserSession(context, page, async () => {
1380
+ if (closed) return;
1381
+ closed = true;
1382
+ try {
1383
+ await context.close();
1384
+ } finally {
1385
+ await unlock();
1386
+ this.free(name);
1387
+ }
1388
+ });
1389
+ this.held.set(name, {
1390
+ owner,
1391
+ session
1392
+ });
1393
+ return session;
1394
+ }
1395
+ }
1396
+ /**
1397
+ * Marks a profile as used by this process, refusing one a live process
1398
+ * holds and taking over one a dead process left behind.
1399
+ *
1400
+ * @param path - The profile directory.
1401
+ * @param name - The profile name, for the message.
1402
+ * @returns The unlock.
1403
+ * @throws Error when another crawler holds the profile.
1404
+ */
1405
+ async function lockProfile(path, name) {
1406
+ const file = join$1(path, LOCK_FILE);
1407
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1408
+ try {
1409
+ await writeFile(file, String(process.pid), {
1410
+ flag: 'wx'
1411
+ });
1412
+ return async () => {
1413
+ await rm(file, {
1414
+ force: true
1415
+ });
1416
+ };
1417
+ } catch (error) {
1418
+ if (error.code !== 'EEXIST') throw error;
1419
+ const holder = Number(await readHolder(file));
1420
+ if (Number.isSafeInteger(holder) && holder > 0 && isAlive(holder)) throw new Error(`browser profile "${name}" is open in another browser (${path}, process ${holder}); close it or use another profile`, {
1421
+ cause: error
1422
+ });
1423
+ await rm(file, {
1424
+ force: true
1425
+ });
1426
+ }
1427
+ }
1428
+ throw new Error(`browser profile "${name}": cannot lock ${path}`);
1429
+ }
1430
+ /** The unlock of a profile that was never locked. */
1431
+ async function unlocked() {}
1432
+ async function readHolder(file) {
1433
+ try {
1434
+ return await readFile(file, 'utf8');
1435
+ } catch {
1436
+ return '';
1437
+ }
1438
+ }
1439
+ function isAlive(pid) {
1440
+ try {
1441
+ process.kill(pid, 0);
1442
+ return true;
1443
+ } catch (error) {
1444
+ return error.code === 'EPERM';
1445
+ }
1446
+ }
1447
+
1448
+ /**
1449
+ * Disposes a runner, ignoring a failure: a tab whose browser already went
1450
+ * away (a rotation, a crash) has nothing left to close.
1451
+ *
1452
+ * @param runner - The runner.
1453
+ */
1454
+ async function disposeQuietly(runner) {
1455
+ try {
1456
+ await runner.dispose();
1457
+ } catch {
1458
+ // already gone
1459
+ }
1460
+ }
1461
+
1251
1462
  /**
1252
1463
  * Runs a body once per item of a list (`over`), or once per live element
1253
1464
  * matching `selector`, each in a fresh child scope with the item bound under
1254
1465
  * `as`; emits a record per iteration when asked.
1255
1466
  *
1256
- * With a concurrent gate, iterations run as permits allow and records come
1257
- * out in completion order; without one, in list order.
1467
+ * With a concurrent gate, iterations of a list run as permits allow and
1468
+ * records come out in completion order; without one, in list order. In web
1469
+ * mode each parallel iteration runs in a tab of its own (`runner.fork`); a
1470
+ * loop over live elements stays sequential, since its elements live on one page.
1258
1471
  *
1259
1472
  * @param step - The forEach step.
1260
1473
  * @param scope - The scope the list lives in.
@@ -1264,7 +1477,7 @@ class BrowserClient {
1264
1477
  async function runForEach(step, scope, walk) {
1265
1478
  const items = await itemsOf$1(step, scope, walk);
1266
1479
  const gate = walk.gate;
1267
- if (gate?.concurrent === true) return runPooled(step, scope, walk, items, gate);
1480
+ if (gate?.concurrent === true && step.selector === undefined && (walk.recipe.mode === 'api' || walk.runner.fork !== undefined)) return runPooled(step, scope, walk, items, gate);
1268
1481
  for (const item of items) {
1269
1482
  if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
1270
1483
  }
@@ -1286,17 +1499,21 @@ async function runPooled(step, scope, walk, items, gate) {
1286
1499
  let stopped = false;
1287
1500
  let failure;
1288
1501
  const tasks = [];
1289
- const overrides = {
1290
- gate: gate.nested()
1291
- };
1502
+ const nested = gate.nested();
1292
1503
  const iterate = async (item, release) => {
1504
+ let runner;
1293
1505
  try {
1294
- if ((await runIteration(step, scope, walk, item, overrides)) === 'stop') stopped = true;
1506
+ runner = walk.runner.fork === undefined ? walk.runner : await walk.runner.fork();
1507
+ if ((await runIteration(step, scope, walk, item, {
1508
+ gate: nested,
1509
+ runner
1510
+ })) === 'stop') stopped = true;
1295
1511
  } catch (error) {
1296
1512
  failure ??= {
1297
1513
  error
1298
1514
  };
1299
1515
  } finally {
1516
+ if (runner !== undefined && runner !== walk.runner) await disposeQuietly(runner);
1300
1517
  release();
1301
1518
  }
1302
1519
  };
@@ -1604,6 +1821,7 @@ function logThrough(walk) {
1604
1821
  class RunGate {
1605
1822
  permits;
1606
1823
  minIntervalMs;
1824
+ hosts;
1607
1825
  shared;
1608
1826
  inFlight = 0;
1609
1827
  waiting = [];
@@ -1611,11 +1829,13 @@ class RunGate {
1611
1829
  /**
1612
1830
  * @param permits - Iterations allowed in flight; 1 is sequential.
1613
1831
  * @param minIntervalMs - Minimum time between two request starts across the run.
1832
+ * @param hosts - The crawler's per-site throttle, shared with every other recipe.
1614
1833
  * @param shared - The throttle state to share (internal: `nested` gates keep their parent's).
1615
1834
  */
1616
- constructor(permits, minIntervalMs, shared) {
1835
+ constructor(permits, minIntervalMs, hosts, shared) {
1617
1836
  this.permits = permits;
1618
1837
  this.minIntervalMs = minIntervalMs;
1838
+ this.hosts = hosts;
1619
1839
  this.shared = shared;
1620
1840
  }
1621
1841
  /** Whether this gate lets more than one iteration run at once. */
@@ -1652,11 +1872,258 @@ class RunGate {
1652
1872
  state.lastStart = at;
1653
1873
  await sleep(at - now);
1654
1874
  }
1875
+ /**
1876
+ * Waits until a request to `url` may start: the recipe's interval, then its
1877
+ * site's turn in the crawler's per-site throttle.
1878
+ *
1879
+ * @param url - Where the request goes.
1880
+ * @returns The release of the site's lane: call it once the response arrived or the request failed.
1881
+ */
1882
+ async request(url) {
1883
+ await this.throttle();
1884
+ return this.hosts === undefined ? noop$1 : this.hosts.slot(url);
1885
+ }
1655
1886
  /** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
1656
1887
  nested() {
1657
- return new RunGate(1, this.minIntervalMs, this.shared ?? this);
1888
+ return new RunGate(1, this.minIntervalMs, this.hosts, this.shared ?? this);
1889
+ }
1890
+ }
1891
+ function noop$1() {}
1892
+
1893
+ /**
1894
+ * Spaces and bounds requests per site, shared by every recipe of a crawler,
1895
+ * so two recipes (or two parallel iterations) that hit one site add up to one
1896
+ * polite client rather than two. A recipe's own `limits.delayMs` still applies
1897
+ * on top, per recipe.
1898
+ *
1899
+ * Like a single-lane bridge with a traffic light: whoever arrives waits for
1900
+ * the car ahead to be far enough, and for a free lane.
1901
+ */
1902
+ class HostThrottle {
1903
+ config;
1904
+ buckets = new Map();
1905
+ domains;
1906
+ constructor(config = {}) {
1907
+ this.config = config;
1908
+ this.domains = Object.entries(config.domains ?? {}).map(([domain, rule]) => [domain.toLowerCase().replace(/^\.+/, ''), rule]).sort((first, second) => second[0].length - first[0].length);
1909
+ }
1910
+ bucketFor(url, always = false) {
1911
+ const host = hostOf(url);
1912
+ if (host === undefined) return undefined;
1913
+ const match = this.domains.find(([domain]) => host === domain || host.endsWith(`.${domain}`));
1914
+ const key = match?.[0] ?? host;
1915
+ let bucket = this.buckets.get(key);
1916
+ if (bucket === undefined) {
1917
+ const rule = {
1918
+ ...pick(this.config),
1919
+ ...match?.[1]
1920
+ };
1921
+ if (!always && !hasLimit(rule)) return undefined;
1922
+ bucket = {
1923
+ rule,
1924
+ inFlight: 0,
1925
+ waiting: [],
1926
+ lastStart: Promise.resolve(-Infinity),
1927
+ pausedTo: 0
1928
+ };
1929
+ this.buckets.set(key, bucket);
1930
+ }
1931
+ return bucket;
1932
+ }
1933
+ /** Whether any rule can hold a request back. */
1934
+ get active() {
1935
+ return hasLimit(this.config) || this.domains.some(([, rule]) => hasLimit(rule)) || this.buckets.size > 0;
1936
+ }
1937
+ /**
1938
+ * Waits until a request to `url` may start, then holds one of its site's
1939
+ * lanes until the returned release is called.
1940
+ *
1941
+ * @param url - Where the request goes; anything but `http(s):` passes at once.
1942
+ * @returns The release: call it once, when the response arrived or the request failed.
1943
+ */
1944
+ async slot(url) {
1945
+ const bucket = this.bucketFor(url);
1946
+ if (bucket === undefined) return noop;
1947
+ const concurrency = bucket.rule.concurrency ?? Infinity;
1948
+ if (bucket.inFlight >= concurrency) await new Promise(resolve => {
1949
+ bucket.waiting.push(resolve);
1950
+ });
1951
+ bucket.inFlight += 1;
1952
+ const previous = bucket.lastStart;
1953
+ const turn = (async () => {
1954
+ const after = await previous;
1955
+ // A pause may arrive while waiting (a Retry-After), so check it again after each sleep.
1956
+ for (;;) {
1957
+ const now = Date.now();
1958
+ const at = Math.max(now, after + (bucket.rule.delayMs ?? 0), bucket.pausedTo);
1959
+ if (at <= now) return now;
1960
+ await sleep(at - now);
1961
+ }
1962
+ })();
1963
+ bucket.lastStart = turn;
1964
+ await turn;
1965
+ let released = false;
1966
+ return () => {
1967
+ if (released) return;
1968
+ released = true;
1969
+ bucket.inFlight -= 1;
1970
+ bucket.waiting.shift()?.();
1971
+ };
1972
+ }
1973
+ /**
1974
+ * Holds every request to the site of `url` back until `untilMs` (a `Retry-After`).
1975
+ *
1976
+ * @param url - A URL of the site.
1977
+ * @param untilMs - An epoch time.
1978
+ */
1979
+ pause(url, untilMs) {
1980
+ const bucket = this.bucketFor(url, true);
1981
+ if (bucket !== undefined) bucket.pausedTo = Math.max(bucket.pausedTo, untilMs);
1982
+ }
1983
+ }
1984
+ function noop() {}
1985
+ function hostOf(url) {
1986
+ try {
1987
+ const parsed = new URL(url);
1988
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname.toLowerCase() : undefined;
1989
+ } catch {
1990
+ return undefined;
1658
1991
  }
1659
1992
  }
1993
+ function pick(config) {
1994
+ return {
1995
+ delayMs: config.delayMs,
1996
+ concurrency: config.concurrency
1997
+ };
1998
+ }
1999
+ function hasLimit(rule) {
2000
+ return (rule.delayMs ?? 0) > 0 || rule.concurrency !== undefined;
2001
+ }
2002
+
2003
+ /** Statuses a server uses for "not now": timeout, too early, too many requests, and the 5xx that pass. */
2004
+ const RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
2005
+ /** Three tries, one second then two apart, never a wait over 30 seconds. */
2006
+ const DEFAULT_RETRY_RULE = {
2007
+ attempts: 3,
2008
+ backoffMs: 1000,
2009
+ maxDelayMs: 30_000,
2010
+ statuses: [...RETRY_STATUSES]
2011
+ };
2012
+ /**
2013
+ * Errors that say the connection failed rather than the site answered:
2014
+ * resets, refusals, timeouts, a DNS lookup that could not run, a proxy that
2015
+ * dropped the tunnel. A name that does not resolve at all (`ENOTFOUND`,
2016
+ * `ERR_NAME_NOT_RESOLVED`) is not here: retrying a typo only wastes time.
2017
+ */
2018
+ const TRANSIENT_ERROR = /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|socket hang up|net::ERR_(?:CONNECTION_(?:RESET|REFUSED|CLOSED|ABORTED|TIMED_OUT)|TIMED_OUT|EMPTY_RESPONSE|NETWORK_CHANGED|INTERNET_DISCONNECTED|PROXY_CONNECTION_FAILED|TUNNEL_CONNECTION_FAILED|HTTP2_PROTOCOL_ERROR|NETWORK_IO_SUSPENDED)|NS_ERROR_NET_(?:RESET|INTERRUPT|TIMEOUT)|Timeout \d+ms exceeded/;
2019
+ /**
2020
+ * The retry rule a recipe runs with: its `limits.retry` over the crawler's
2021
+ * default over `DEFAULT_RETRY_RULE`.
2022
+ *
2023
+ * @param own - The recipe's `limits.retry`.
2024
+ * @param crawler - The crawler's `retry` option.
2025
+ * @returns The rule.
2026
+ */
2027
+ function resolveRetryRule(own, crawler) {
2028
+ return {
2029
+ ...DEFAULT_RETRY_RULE,
2030
+ ...definedOf(crawler),
2031
+ ...definedOf(own)
2032
+ };
2033
+ }
2034
+ /**
2035
+ * Whether an error is a connection failure worth another try.
2036
+ *
2037
+ * @param error - What the request threw.
2038
+ * @returns The reason, or `undefined`.
2039
+ */
2040
+ function transientError(error) {
2041
+ const message = error instanceof Error ? error.message : String(error);
2042
+ const match = TRANSIENT_ERROR.exec(message);
2043
+ return match === null ? undefined : {
2044
+ reason: match[0]
2045
+ };
2046
+ }
2047
+ /**
2048
+ * How long to wait before attempt `attempt + 1`: the server's `Retry-After`
2049
+ * when it gave one, else `backoffMs` doubling per attempt with a little
2050
+ * jitter; `undefined` when the server asks for longer than `maxDelayMs` (it
2051
+ * means "come back much later", which a crawl cannot wait for).
2052
+ *
2053
+ * @param rule - The retry rule.
2054
+ * @param attempt - The attempt that just failed, from 1.
2055
+ * @param retryAfter - The `Retry-After` header: seconds, or an HTTP date.
2056
+ * @param now - The current time, for dates.
2057
+ * @returns Milliseconds, or `undefined` for no retry.
2058
+ */
2059
+ function retryDelay(rule, attempt, retryAfter, now = Date.now()) {
2060
+ const asked = retryAfterMs(retryAfter, now);
2061
+ if (asked !== undefined) return asked > rule.maxDelayMs ? undefined : asked;
2062
+ const base = rule.backoffMs * 2 ** (attempt - 1);
2063
+ const jittered = base * (0.75 + Math.random() * 0.5);
2064
+ return Math.min(Math.round(jittered), rule.maxDelayMs);
2065
+ }
2066
+ /**
2067
+ * Sends a request through the gate (the recipe's rate, the site's lane), and
2068
+ * sends it again after a pause while it fails in a passing way, up to
2069
+ * `rule.attempts` tries in all. A `Retry-After` holds back every request to
2070
+ * that site, not only this one. Each retry is reported as `request:retry`.
2071
+ *
2072
+ * Like redialling a busy number: wait a moment, dial again, give up after a
2073
+ * few tries; and if the other end said "call back in a minute", wait that minute.
2074
+ *
2075
+ * @param url - Where the request goes.
2076
+ * @param attempt - How to send it and how to judge the outcome.
2077
+ * @param context - The recipe, gate, events and rule.
2078
+ * @returns What the last try gave.
2079
+ * @throws What the last try threw.
2080
+ */
2081
+ async function withTransportRetry(url, attempt, context) {
2082
+ const {
2083
+ rule
2084
+ } = context;
2085
+ for (let tries = 1;; tries += 1) {
2086
+ const release = await context.gate.request(url);
2087
+ let outcome;
2088
+ try {
2089
+ outcome = {
2090
+ value: await attempt.run()
2091
+ };
2092
+ } catch (error) {
2093
+ outcome = {
2094
+ error
2095
+ };
2096
+ } finally {
2097
+ release();
2098
+ }
2099
+ const transient = tries < rule.attempts ? attempt.problem(outcome) : undefined;
2100
+ const delay = transient === undefined ? undefined : retryDelay(rule, tries, transient.retryAfter);
2101
+ if (transient === undefined || delay === undefined) {
2102
+ if ('error' in outcome) throw outcome.error;
2103
+ return outcome.value;
2104
+ }
2105
+ if (transient.retryAfter !== undefined) context.gate.hosts?.pause(url, Date.now() + delay);
2106
+ context.events.emit({
2107
+ type: 'request:retry',
2108
+ recipeId: context.recipeId,
2109
+ url,
2110
+ attempt: tries + 1,
2111
+ reason: transient.reason,
2112
+ delayMs: delay
2113
+ });
2114
+ await sleep(delay);
2115
+ }
2116
+ }
2117
+ function retryAfterMs(header, now) {
2118
+ if (header === undefined || header.trim() === '') return undefined;
2119
+ const seconds = Number(header.trim());
2120
+ if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
2121
+ const date = Date.parse(header);
2122
+ return Number.isNaN(date) ? undefined : Math.max(0, date - now);
2123
+ }
2124
+ function definedOf(rule) {
2125
+ return Object.fromEntries(Object.entries(rule ?? {}).filter(([, value]) => value !== undefined));
2126
+ }
1660
2127
 
1661
2128
  /** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
1662
2129
  const DEFAULT_BLOCK_RULE = {
@@ -2229,6 +2696,10 @@ function traceLine(event) {
2229
2696
  {
2230
2697
  return `${indent(1)}↻ new access lease (attempt ${event.attempt})`;
2231
2698
  }
2699
+ case 'request:retry':
2700
+ {
2701
+ return `${indent(1)}↺ ${event.url}: ${event.reason}, try ${event.attempt} in ${event.delayMs} ms`;
2702
+ }
2232
2703
  case 'captcha:detected':
2233
2704
  {
2234
2705
  return `${indent(1)}⚿ captcha ${event.kind} on ${event.url}`;
@@ -2437,26 +2908,36 @@ async function existingKeys(target) {
2437
2908
  });
2438
2909
  }
2439
2910
 
2440
- /** Drops records whose key was already seen. First record wins; keyless records always pass. */
2911
+ /**
2912
+ * Drops records whose key was already seen. First record wins; keyless
2913
+ * records always pass. Recipes running in parallel each get their own view:
2914
+ * under `recipe` scope they never see each other's keys, under `run` scope
2915
+ * they share them (and whichever emits a key first keeps it).
2916
+ */
2441
2917
  class DedupePolicy {
2442
2918
  scope;
2443
- seen = new Set();
2919
+ shared = new Set();
2444
2920
  constructor(scope = 'run') {
2445
2921
  this.scope = scope;
2446
2922
  }
2447
- /** Called when an input recipe starts; forgets keys under `recipe` scope. */
2448
- startRecipe() {
2449
- if (this.scope === 'recipe') this.seen = new Set();
2450
- }
2451
2923
  /**
2452
- * @param record - A validated record.
2453
- * @returns `true` when the record repeats an earlier key and must be dropped.
2924
+ * The de-duplication one input recipe run uses.
2925
+ *
2926
+ * @returns Its view: keys shared with the run, its own, or none checked.
2454
2927
  */
2455
- isDuplicate(record) {
2456
- if (this.scope === 'off' || record.key === null) return false;
2457
- if (this.seen.has(record.key)) return true;
2458
- this.seen.add(record.key);
2459
- return false;
2928
+ forRecipe() {
2929
+ if (this.scope === 'off') return {
2930
+ isDuplicate: () => false
2931
+ };
2932
+ const seen = this.scope === 'run' ? this.shared : new Set();
2933
+ return {
2934
+ isDuplicate: record => {
2935
+ if (record.key === null) return false;
2936
+ if (seen.has(record.key)) return true;
2937
+ seen.add(record.key);
2938
+ return false;
2939
+ }
2940
+ };
2460
2941
  }
2461
2942
  }
2462
2943
 
@@ -4354,8 +4835,9 @@ function formatFromExtension(extension) {
4354
4835
 
4355
4836
  /**
4356
4837
  * Sends a `request` step: renders its templates, waits for the gate's throttle,
4357
- * sends, checks the response against the recipe's block rule, then binds it as
4358
- * the scope's current document (and under the step id).
4838
+ * sends (again, after a pause, while it fails in passing: `limits.retry`),
4839
+ * checks the response against the recipe's block rule, then binds it as the
4840
+ * scope's current document (and under the step id).
4359
4841
  *
4360
4842
  * @param step - The request step.
4361
4843
  * @param scope - The scope to render in and bind into.
@@ -4368,20 +4850,29 @@ function formatFromExtension(extension) {
4368
4850
  async function sendRequest(step, scope, client, recipe, gate, events) {
4369
4851
  const lookup = path => scope.lookup(path);
4370
4852
  const url = resolveUrl(renderText(step.url, lookup), scope.pageState?.url);
4371
- await gate.throttle();
4853
+ const request = {
4854
+ method: step.method,
4855
+ url,
4856
+ query: step.query === undefined ? undefined : renderMap(step.query, lookup),
4857
+ headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
4858
+ body: renderDeep(step.body, lookup),
4859
+ as: step.as,
4860
+ encoding: step.encoding,
4861
+ delimiter: step.delimiter,
4862
+ scalars: step.scalars,
4863
+ timeoutMs: recipe.limits?.timeoutMs
4864
+ };
4865
+ const rule = resolveRetryRule(recipe.limits?.retry);
4372
4866
  let response;
4373
4867
  try {
4374
- response = await client.send({
4375
- method: step.method,
4376
- url,
4377
- query: step.query === undefined ? undefined : renderMap(step.query, lookup),
4378
- headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
4379
- body: renderDeep(step.body, lookup),
4380
- as: step.as,
4381
- encoding: step.encoding,
4382
- delimiter: step.delimiter,
4383
- scalars: step.scalars,
4384
- timeoutMs: recipe.limits?.timeoutMs
4868
+ response = await withTransportRetry(url, {
4869
+ run: () => client.send(request),
4870
+ problem: outcome => 'error' in outcome ? problemOf(outcome.error, rule.statuses) : undefined
4871
+ }, {
4872
+ recipeId: recipe.id,
4873
+ gate,
4874
+ events,
4875
+ rule
4385
4876
  });
4386
4877
  } catch (error) {
4387
4878
  if (!(error instanceof HttpError)) throw error;
@@ -4431,6 +4922,14 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
4431
4922
  });
4432
4923
  if (step.id !== undefined) scope.set(step.id, documentValue(response.body));
4433
4924
  }
4925
+ /** A retry status (with the server's `Retry-After`), or a connection that failed. */
4926
+ function problemOf(error, statuses) {
4927
+ if (error instanceof HttpError) return statuses.includes(error.status) ? {
4928
+ reason: `HTTP ${error.status}`,
4929
+ retryAfter: error.headers['retry-after']
4930
+ } : undefined;
4931
+ return transientError(error);
4932
+ }
4434
4933
  /** The class names of the widgets `session.captcha` solves. Checked only when a recipe declares it. */
4435
4934
  const CAPTCHA_MARKUP = /\b(?:g-recaptcha|h-captcha|cf-turnstile)\b/;
4436
4935
  function bodyText(body) {
@@ -5713,18 +6212,34 @@ function readElements(elements) {
5713
6212
 
5714
6213
  /**
5715
6214
  * Runs a `goto` step: renders the URL (relative to the current page), waits for
5716
- * the gate's throttle (`delayMs`), navigates, records the page's real URL in the
5717
- * scope, and checks the response against the recipe's block rule.
6215
+ * the gate's throttle (`delayMs`), navigates (again, after a pause, while it
6216
+ * fails in passing: `limits.retry`), records the page's real URL in the scope,
6217
+ * and checks the response against the recipe's block rule.
5718
6218
  *
5719
6219
  * @throws BlockedError when the response is a block.
5720
6220
  */
5721
6221
  async function navigate(step, page, scope, recipe, gate, events) {
5722
6222
  const target = renderText(step.url, path => scope.lookup(path));
5723
6223
  const url = new URL(target, scope.pageState?.url ?? page.url()).href;
5724
- await gate.throttle();
5725
- const response = await page.goto(url, {
5726
- waitUntil: step.waitUntil,
5727
- timeout: recipe.limits?.timeoutMs
6224
+ const rule = resolveRetryRule(recipe.limits?.retry);
6225
+ const response = await withTransportRetry(url, {
6226
+ run: () => page.goto(url, {
6227
+ waitUntil: step.waitUntil,
6228
+ timeout: recipe.limits?.timeoutMs
6229
+ }),
6230
+ problem: outcome => {
6231
+ if ('error' in outcome) return transientError(outcome.error);
6232
+ const status = outcome.value?.status();
6233
+ return status !== undefined && rule.statuses.includes(status) ? {
6234
+ reason: `HTTP ${status}`,
6235
+ retryAfter: outcome.value?.headers()['retry-after']
6236
+ } : undefined;
6237
+ }
6238
+ }, {
6239
+ recipeId: recipe.id,
6240
+ gate,
6241
+ events,
6242
+ rule
5728
6243
  });
5729
6244
  scope.setPage({
5730
6245
  url: page.url()
@@ -5874,8 +6389,13 @@ class WebStepRunner {
5874
6389
  const link = this.page.locator(next.selector).first();
5875
6390
  if (!(await appears(link, NEXT_LINK_TIMEOUT_MS))) return null;
5876
6391
  const before = this.page.url();
5877
- await link.click();
5878
- await this.page.waitForLoadState();
6392
+ const release = await this.gate.request(before);
6393
+ try {
6394
+ await link.click();
6395
+ await this.page.waitForLoadState();
6396
+ } finally {
6397
+ release();
6398
+ }
5879
6399
  if (this.page.url() === before) await this.page.waitForTimeout(NEXT_LINK_TIMEOUT_MS / 4);
5880
6400
  this.events.emit({
5881
6401
  type: 'page:visit',
@@ -5889,6 +6409,23 @@ class WebStepRunner {
5889
6409
  url: this.page.url()
5890
6410
  };
5891
6411
  }
6412
+ /**
6413
+ * A runner on a new tab of the same context, for one parallel iteration:
6414
+ * it shares cookies, the gate and the captcha guard; disposing it closes the tab only.
6415
+ *
6416
+ * @returns The forked runner.
6417
+ */
6418
+ async fork() {
6419
+ const {
6420
+ context
6421
+ } = this.session;
6422
+ const page = await context.newPage();
6423
+ const viewport = this.recipe.session?.viewport;
6424
+ if (viewport !== undefined) await page.setViewportSize(viewport);
6425
+ return new WebStepRunner(new BrowserSession(context, page, async () => {
6426
+ await page.close();
6427
+ }), this.recipe, this.events, this.gate, this.captcha);
6428
+ }
5892
6429
  async elements(selector) {
5893
6430
  return snapshotElements(selector, this.page);
5894
6431
  }
@@ -5925,16 +6462,30 @@ function accessOptions(lease, headers) {
5925
6462
  * The bootstrap runs through the same access lease as the crawl that follows,
5926
6463
  * so a login and the requests that use its cookies come from one IP.
5927
6464
  *
6465
+ * With `session.browserProfile`, the bootstrap runs in that profile, and
6466
+ * without a bootstrap the profile's own cookies and storage are the state: an
6467
+ * api recipe picks up a login a browser left in the profile.
6468
+ *
5928
6469
  * @param recipe - The input recipe.
5929
6470
  * @param deps - Browser, hooks, events.
5930
6471
  * @param lease - The recipe run's access; direct when omitted.
5931
6472
  * @param captcha - Solves the bootstrap's captchas (a login form's).
6473
+ * @param owner - The recipe run, which a browser profile is held by.
5932
6474
  * @returns The state, or `undefined` when the recipe declares none.
5933
6475
  */
5934
- async function resolveStorageState(recipe, deps, lease, captcha) {
6476
+ async function resolveStorageState(recipe, deps, lease, captcha, owner = {}) {
5935
6477
  const saved = await readSavedState(recipe, deps);
6478
+ if (saved !== undefined) return saved;
5936
6479
  const session = recipe.session;
5937
- if (saved !== undefined || session?.bootstrap === undefined) return saved;
6480
+ if (session?.browserProfile !== undefined) {
6481
+ const browserSession = await openBrowserProfile(recipe, deps, lease, owner);
6482
+ try {
6483
+ return session.bootstrap === undefined ? await browserSession.storageState() : await runBootstrap(recipe, browserSession, deps, captcha);
6484
+ } finally {
6485
+ await browserSession.close();
6486
+ }
6487
+ }
6488
+ if (session?.bootstrap === undefined) return undefined;
5938
6489
  const browser = await deps.browser();
5939
6490
  const browserSession = await browser.newSession({
5940
6491
  cookies: session.cookies,
@@ -5948,6 +6499,27 @@ async function resolveStorageState(recipe, deps, lease, captcha) {
5948
6499
  await browserSession.close();
5949
6500
  }
5950
6501
  }
6502
+ /**
6503
+ * Opens the recipe's `session.browserProfile` with its session options and
6504
+ * the lease's proxy.
6505
+ *
6506
+ * @param recipe - A recipe with `session.browserProfile`.
6507
+ * @param deps - For `profiles`.
6508
+ * @param lease - The access lease.
6509
+ * @param owner - The recipe run.
6510
+ * @returns The session in the profile.
6511
+ */
6512
+ async function openBrowserProfile(recipe, deps, lease, owner) {
6513
+ const session = recipe.session;
6514
+ const name = session?.browserProfile ?? '';
6515
+ if (deps.profiles === undefined) throw new Error(`recipe "${recipe.id}" uses browser profile "${name}", but this crawler has no profiles directory (CrawlOptions.profilesDir)`);
6516
+ return deps.profiles.open(name, {
6517
+ cookies: session?.cookies,
6518
+ userAgent: session?.userAgent,
6519
+ viewport: session?.viewport,
6520
+ ...accessOptions(lease, session?.headers)
6521
+ }, owner);
6522
+ }
5951
6523
  /**
5952
6524
  * The storage state saved by an earlier bootstrap (`session.storageStatePath`), if the recipe names one.
5953
6525
  *
@@ -5977,7 +6549,7 @@ async function runBootstrap(recipe, browserSession, deps, captcha) {
5977
6549
  cookies: [],
5978
6550
  origins: []
5979
6551
  };
5980
- const runner = new WebStepRunner(browserSession, recipe, deps.events, undefined, captcha);
6552
+ const runner = new WebStepRunner(browserSession, recipe, deps.events, new RunGate(1, recipe.limits?.delayMs ?? 0, deps.hosts), captcha);
5981
6553
  const scope = new ExtractionScope();
5982
6554
  scope.set('vars', recipe.vars ?? {});
5983
6555
  scope.set('start', {
@@ -6082,6 +6654,66 @@ class RotatingRunner {
6082
6654
  throw error;
6083
6655
  }
6084
6656
  }
6657
+ /**
6658
+ * A runner for one parallel iteration, forked from whichever runner is
6659
+ * current when it runs a step: after a rotation it forks again from the new
6660
+ * one, since the old context is gone (or going). Blocks are noted and
6661
+ * rotated like the main runner's.
6662
+ *
6663
+ * @returns The iteration's runner.
6664
+ */
6665
+ async fork() {
6666
+ // `own`: a tab this iteration opened and must close; an api runner is shared and never disposed here.
6667
+ let forked;
6668
+ const disposeForked = async () => {
6669
+ if (forked?.own === true) await disposeQuietly(forked.runner);
6670
+ forked = undefined;
6671
+ };
6672
+ const current = async () => {
6673
+ if (forked?.generation === this.generation) return forked.runner;
6674
+ await disposeForked();
6675
+ const inner = this.inner;
6676
+ forked = inner.fork === undefined ? {
6677
+ generation: this.generation,
6678
+ runner: inner,
6679
+ own: false
6680
+ } : {
6681
+ generation: this.generation,
6682
+ runner: await inner.fork(),
6683
+ own: true
6684
+ };
6685
+ return forked.runner;
6686
+ };
6687
+ return {
6688
+ runLeaf: async (step, scope) => {
6689
+ const generation = this.generation;
6690
+ try {
6691
+ const runner = await current();
6692
+ await runner.runLeaf(step, scope);
6693
+ } catch (error) {
6694
+ this.note(error, generation);
6695
+ throw error;
6696
+ }
6697
+ },
6698
+ nextPage: async (next, scope) => {
6699
+ const generation = this.generation;
6700
+ try {
6701
+ const runner = await current();
6702
+ return await runner.nextPage(next, scope);
6703
+ } catch (error) {
6704
+ this.note(error, generation);
6705
+ throw error;
6706
+ }
6707
+ },
6708
+ elements: async (selector, scope) => {
6709
+ const runner = await current();
6710
+ if (runner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
6711
+ return runner.elements(selector, scope);
6712
+ },
6713
+ rotate: error => this.rotate(error),
6714
+ dispose: disposeForked
6715
+ };
6716
+ }
6085
6717
  async elements(selector, scope) {
6086
6718
  if (this.inner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
6087
6719
  return this.inner.elements(selector, scope);
@@ -6130,12 +6762,19 @@ class RotatingRunner {
6130
6762
  * the sink sees one record at a time and `maxRecords` is exact: once reached,
6131
6763
  * every later emit returns `stop` before mapping.
6132
6764
  *
6133
- * @param input - The input recipe.
6765
+ * @param recipe - The input recipe.
6134
6766
  * @param output - The output recipe it feeds.
6135
6767
  * @param deps - Shared browser, hooks, events, sink and de-duplication.
6136
6768
  * @returns What happened.
6137
6769
  */
6138
- async function runInputRecipe(input, output, deps) {
6770
+ async function runInputRecipe(recipe, output, deps) {
6771
+ const input = {
6772
+ ...recipe,
6773
+ limits: {
6774
+ ...recipe.limits,
6775
+ retry: resolveRetryRule(recipe.limits?.retry, deps.retry)
6776
+ }
6777
+ };
6139
6778
  const started = Date.now();
6140
6779
  const report = {
6141
6780
  recipeId: input.id,
@@ -6154,8 +6793,8 @@ async function runInputRecipe(input, output, deps) {
6154
6793
  failed: 0
6155
6794
  };
6156
6795
  const limits = input.limits ?? {};
6157
- // A web recipe drives one page, so only api mode runs iterations in parallel.
6158
- const gate = new RunGate(input.mode === 'web' ? 1 : limits.concurrency ?? 1, limits.delayMs ?? 0);
6796
+ // Parallel iterations: requests in api mode, tabs of the recipe's context in web mode.
6797
+ const gate = new RunGate(limits.concurrency ?? 1, limits.delayMs ?? 0, deps.hosts);
6159
6798
  let stopped = false;
6160
6799
  let chain = Promise.resolve();
6161
6800
  const unsubscribe = deps.events.subscribe(event => {
@@ -6170,7 +6809,7 @@ async function runInputRecipe(input, output, deps) {
6170
6809
  recipeId: input.id,
6171
6810
  mode: input.mode
6172
6811
  });
6173
- deps.dedupe.startRecipe();
6812
+ const dedupe = deps.dedupe.forRecipe();
6174
6813
  let runner;
6175
6814
  try {
6176
6815
  const onBlock = input.session?.onBlock;
@@ -6278,7 +6917,7 @@ async function runInputRecipe(input, output, deps) {
6278
6917
  url,
6279
6918
  key: record.key
6280
6919
  });
6281
- } else if (deps.dedupe.isDuplicate(record)) {
6920
+ } else if (dedupe.isDuplicate(record)) {
6282
6921
  report.duplicates += 1;
6283
6922
  deps.events.emit({
6284
6923
  type: 'record:duplicate',
@@ -6370,7 +7009,8 @@ async function openRunner(input, deps, context, lease) {
6370
7009
  lease
6371
7010
  });
6372
7011
  if (lease.cdp !== undefined) return openRemoteRunner(input, deps, context, lease, lease.cdp);
6373
- const storageState = await resolveStorageState(input, deps, lease, captcha);
7012
+ if (input.mode === 'web' && input.session?.browserProfile !== undefined) return openProfileRunner(input, deps, context, lease, captcha);
7013
+ const storageState = await resolveStorageState(input, deps, lease, captcha, context);
6374
7014
  const session = input.session;
6375
7015
  const access = accessOptions(lease, session?.headers);
6376
7016
  if (input.mode === 'web') {
@@ -6394,6 +7034,28 @@ async function openRunner(input, deps, context, lease) {
6394
7034
  });
6395
7035
  return new ApiStepRunner(client, input, deps.events, gate);
6396
7036
  }
7037
+ /**
7038
+ * A web runner in a persistent browser profile. The bootstrap runs in the
7039
+ * same browser as the crawl, and what both leave behind (cookies, storage)
7040
+ * stays in the profile for the next run.
7041
+ */
7042
+ async function openProfileRunner(input, deps, context, lease, captcha) {
7043
+ const saved = await readSavedState(input, deps);
7044
+ const browserSession = await openBrowserProfile(saved === undefined ? input : {
7045
+ ...input,
7046
+ session: {
7047
+ ...input.session,
7048
+ cookies: [...saved.cookies, ...(input.session?.cookies ?? [])]
7049
+ }
7050
+ }, deps, lease, context);
7051
+ try {
7052
+ if (saved === undefined && input.session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps, captcha);
7053
+ } catch (error) {
7054
+ await browserSession.close();
7055
+ throw error;
7056
+ }
7057
+ return new WebStepRunner(browserSession, input, deps.events, context.gate, captcha);
7058
+ }
6397
7059
  /**
6398
7060
  * A web runner in a remote browser. The bootstrap runs in the same remote
6399
7061
  * session as the crawl: providers tie the IP and fingerprint to the
@@ -6408,6 +7070,7 @@ async function openRemoteRunner(input, deps, context, lease, cdp) {
6408
7070
  lease
6409
7071
  });
6410
7072
  if (input.mode === 'api') throw new AccessConfigError(`recipe "${input.id}" runs in api mode, but access profile "${lease.profile}" is a remote browser; api recipes need a proxy profile`);
7073
+ if (input.session?.browserProfile !== undefined) throw new AccessConfigError(`recipe "${input.id}" uses browser profile "${input.session.browserProfile}", which needs a local browser, but access profile "${lease.profile}" is a remote browser`);
6411
7074
  const session = input.session;
6412
7075
  const storageState = await readSavedState(input, deps);
6413
7076
  const browserSession = await BrowserClient.connectOverCDP(cdp, {
@@ -6426,27 +7089,42 @@ async function openRemoteRunner(input, deps, context, lease, cdp) {
6426
7089
  }
6427
7090
 
6428
7091
  /**
6429
- * Runs every input recipe of a set, one after another, into one sink.
7092
+ * Runs every input recipe of a set into one sink, `parallel` at a time
7093
+ * (default one after another). Reports come back in the set's order whatever
7094
+ * order the recipes finish in. Under `onRecipeError: 'stop'`, a failed recipe
7095
+ * stops the ones not started yet; those already running finish.
6430
7096
  *
6431
7097
  * @param set - The bound recipes.
6432
7098
  * @param deps - Shared browser, hooks, events, sink and de-duplication.
6433
7099
  * @param onRecipeError - Whether a failed recipe stops the run.
7100
+ * @param parallel - How many input recipes run at once.
6434
7101
  * @returns The report.
6435
7102
  */
6436
- async function runCrawl(set, deps, onRecipeError) {
7103
+ async function runCrawl(set, deps, onRecipeError, parallel = 1) {
6437
7104
  const started = Date.now();
6438
7105
  await deps.sink.open(set.output);
6439
- const recipes = [];
7106
+ const reports = [];
6440
7107
  let sink;
6441
7108
  try {
6442
- for (const input of set.inputs) {
6443
- const report = await runInputRecipe(input, set.output, deps);
6444
- recipes.push(report);
6445
- if (onRecipeError === 'stop' && report.error !== undefined) break;
6446
- }
7109
+ let next = 0;
7110
+ let stopped = false;
7111
+ const lane = async () => {
7112
+ while (!stopped && next < set.inputs.length) {
7113
+ const index = next;
7114
+ next += 1;
7115
+ const report = await runInputRecipe(set.inputs[index], set.output, deps);
7116
+ reports[index] = report;
7117
+ if (onRecipeError === 'stop' && report.error !== undefined) stopped = true;
7118
+ }
7119
+ };
7120
+ const lanes = Math.max(1, Math.min(parallel, set.inputs.length));
7121
+ await Promise.all(Array.from({
7122
+ length: lanes
7123
+ }, lane));
6447
7124
  } finally {
6448
7125
  sink = await deps.sink.close();
6449
7126
  }
7127
+ const recipes = reports.filter(report => report !== undefined);
6450
7128
  return {
6451
7129
  outputId: set.output.id,
6452
7130
  recipes,
@@ -6471,6 +7149,8 @@ function createCrawler(options = {}) {
6471
7149
  const access = new AccessBroker(options.access, options.accessPlugins);
6472
7150
  const hooks = new HookRegistry(options.hooks);
6473
7151
  const captchaSolvers = new CaptchaSolverRegistry(options.captchaSolvers);
7152
+ const hosts = new HostThrottle(options.throttle ?? options.access?.throttle);
7153
+ const profiles = new BrowserProfiles(options.profilesDir ?? resolve$1(options.storageStateDir ?? '.', '.opencraw', 'profiles'), options.browser);
6474
7154
  const events = new EventBus(options.onEvent);
6475
7155
  let browser;
6476
7156
  const launch = () => {
@@ -6489,8 +7169,11 @@ function createCrawler(options = {}) {
6489
7169
  debug: options.debug === true,
6490
7170
  access,
6491
7171
  captchaSolvers,
7172
+ hosts,
7173
+ profiles,
7174
+ retry: options.retry,
6492
7175
  ignoreHTTPSErrors: options.browser?.ignoreHTTPSErrors
6493
- }, options.onRecipeError ?? 'continue'),
7176
+ }, options.onRecipeError ?? 'continue', options.parallel ?? 1),
6494
7177
  async close() {
6495
7178
  const launched = browser;
6496
7179
  browser = undefined;
@@ -6962,13 +7645,21 @@ const sessionSpecSchema = z.strictObject({
6962
7645
  access: sessionAccessSchema.optional(),
6963
7646
  blockedWhen: blockRuleSchema.optional(),
6964
7647
  onBlock: blockRotationSchema.optional(),
6965
- captcha: captchaSettingsSchema.optional()
7648
+ captcha: captchaSettingsSchema.optional(),
7649
+ browserProfile: z.string().regex(/^[\w-]+$/, 'a browser profile name is letters, digits, hyphens and underscores').optional()
7650
+ });
7651
+ const retryRuleSchema = z.strictObject({
7652
+ attempts: z.int().min(1).max(10).optional(),
7653
+ backoffMs: z.int().nonnegative().optional(),
7654
+ maxDelayMs: z.int().nonnegative().optional(),
7655
+ statuses: z.array(z.int().min(400).max(599)).optional()
6966
7656
  });
6967
7657
  const limitsSchema = z.strictObject({
6968
7658
  maxRecords: z.int().positive().optional(),
6969
7659
  delayMs: z.int().nonnegative().optional(),
6970
7660
  timeoutMs: z.int().positive().optional(),
6971
- concurrency: z.int().min(1).max(64).optional()
7661
+ concurrency: z.int().min(1).max(64).optional(),
7662
+ retry: retryRuleSchema.optional()
6972
7663
  });
6973
7664
  const inputRecipeSchema = z.strictObject({
6974
7665
  $schema: z.string().optional(),
@@ -7499,5 +8190,5 @@ function isSameOutput(document, output) {
7499
8190
  return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
7500
8191
  }
7501
8192
 
7502
- export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, CaptchaError, DEFAULT_CAPTCHA_SELECTOR, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, csvWorkbook, deckText, detectChallenge, detectDelimiter, fillDown, findDeckTables, findGridTables, findTables, htmlTableSheets, inputRecipeJsonSchema, inputRecipeSchema, isDeckDocument, isWorkbookDocument, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseCsv, parseInputRecipe, parseOutputRecipe, pdfText, readMarkdown, readPdf, readRecipeSource, readYaml, traceLine, tryParseJson, validateBinding, workbookText };
8193
+ export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, CaptchaError, DEFAULT_CAPTCHA_SELECTOR, DEFAULT_RETRY_RULE, HostThrottle, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, csvWorkbook, deckText, detectChallenge, detectDelimiter, fillDown, findDeckTables, findGridTables, findTables, htmlTableSheets, inputRecipeJsonSchema, inputRecipeSchema, isDeckDocument, isWorkbookDocument, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseCsv, parseInputRecipe, parseOutputRecipe, pdfText, readMarkdown, readPdf, readRecipeSource, readYaml, retryRuleSchema, throttleConfigSchema, traceLine, tryParseJson, validateBinding, workbookText };
7503
8194
  //# sourceMappingURL=index.esm.js.map