@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.
- package/README.md +1 -1
- package/dist/index.esm.js +762 -71
- package/dist/src/access/access-profile.contract.d.ts +4 -0
- package/dist/src/access/index.d.ts +1 -1
- package/dist/src/api-steps/send-request.use-case.d.ts +3 -2
- package/dist/src/browser-session/browser-profile.store.d.ts +52 -0
- package/dist/src/browser-session/browser.client.d.ts +8 -0
- package/dist/src/browser-session/index.d.ts +1 -0
- package/dist/src/crawl-events/crawl-event.contract.d.ts +8 -0
- package/dist/src/crawl-execution/bootstrap-session.use-case.d.ts +23 -2
- package/dist/src/crawl-execution/crawl-options.config.d.ts +27 -0
- package/dist/src/crawl-execution/rotating-runner.use-case.d.ts +9 -0
- package/dist/src/crawl-execution/run-crawl.use-case.d.ts +6 -2
- package/dist/src/crawl-execution/run-input-recipe.use-case.d.ts +11 -3
- package/dist/src/index.d.ts +5 -4
- package/dist/src/recipe-schema/index.d.ts +2 -2
- package/dist/src/recipe-schema/input-recipe.contract.d.ts +24 -1
- package/dist/src/record-sink/dedupe.policy.d.ts +19 -7
- package/dist/src/record-sink/index.d.ts +1 -0
- package/dist/src/step-flow/for-each.use-case.d.ts +4 -2
- package/dist/src/step-flow/host-throttle.policy.d.ts +49 -0
- package/dist/src/step-flow/index.d.ts +5 -0
- package/dist/src/step-flow/run-gate.policy.d.ts +12 -1
- package/dist/src/step-flow/step-runner.contract.d.ts +13 -0
- package/dist/src/step-flow/transport-retry.policy.d.ts +76 -0
- package/dist/src/web-steps/navigate.use-case.d.ts +3 -2
- package/dist/src/web-steps/run-web-step.use-case.d.ts +8 -1
- 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,
|
|
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
|
-
|
|
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
|
|
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
|
|
1290
|
-
gate: gate.nested()
|
|
1291
|
-
};
|
|
1502
|
+
const nested = gate.nested();
|
|
1292
1503
|
const iterate = async (item, release) => {
|
|
1504
|
+
let runner;
|
|
1293
1505
|
try {
|
|
1294
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
2453
|
-
*
|
|
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
|
-
|
|
2456
|
-
if (this.scope === 'off'
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
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,
|
|
4358
|
-
* the
|
|
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
|
-
|
|
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
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
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,
|
|
5717
|
-
*
|
|
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
|
-
|
|
5725
|
-
const response = await
|
|
5726
|
-
|
|
5727
|
-
|
|
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
|
|
5878
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
6158
|
-
const gate = new RunGate(
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
7106
|
+
const reports = [];
|
|
6440
7107
|
let sink;
|
|
6441
7108
|
try {
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
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
|