@opencraw/core 0.1.1 → 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 +4 -2
- package/dist/index.esm.js +3010 -638
- 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/extract-from-document.use-case.d.ts +16 -3
- package/dist/src/api-steps/index.d.ts +1 -1
- package/dist/src/api-steps/send-request.use-case.d.ts +5 -4
- 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/captcha/captcha-budget.model.d.ts +21 -0
- package/dist/src/captcha/captcha-detection.client.d.ts +28 -0
- package/dist/src/captcha/captcha-guard.use-case.d.ts +64 -0
- package/dist/src/captcha/captcha-solver-registry.store.d.ts +19 -0
- package/dist/src/captcha/captcha-solver.contract.d.ts +47 -0
- package/dist/src/captcha/captcha.error.d.ts +13 -0
- package/dist/src/captcha/index.d.ts +10 -0
- package/dist/src/captcha/resolve-captcha.use-case.d.ts +40 -0
- package/dist/src/crawl-events/crawl-event.contract.d.ts +48 -0
- package/dist/src/crawl-execution/bootstrap-session.use-case.d.ts +27 -3
- package/dist/src/crawl-execution/crawl-options.config.d.ts +30 -0
- package/dist/src/crawl-execution/crawl-report.model.d.ts +6 -0
- package/dist/src/crawl-execution/create-crawler.use-case.d.ts +2 -1
- 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 +14 -3
- package/dist/src/deck-document/deck-document.model.d.ts +58 -0
- package/dist/src/deck-document/deck-table.algorithm.d.ts +35 -0
- package/dist/src/deck-document/index.d.ts +6 -0
- package/dist/src/deck-document/read-pptx.client.d.ts +16 -0
- package/dist/src/extraction-scope/extraction-scope.model.d.ts +3 -1
- package/dist/src/http-session/http-response.contract.d.ts +13 -1
- package/dist/src/http-session/text-decoding.algorithm.d.ts +35 -0
- package/dist/src/index.d.ts +13 -4
- package/dist/src/markdown-document/index.d.ts +3 -0
- package/dist/src/markdown-document/read-markdown.client.d.ts +29 -0
- package/dist/src/pdf-document/index.d.ts +1 -1
- package/dist/src/pdf-document/row-assembly.algorithm.d.ts +10 -1
- package/dist/src/recipe-loading/recipe-binding.validator.d.ts +3 -1
- package/dist/src/recipe-schema/index.d.ts +3 -3
- package/dist/src/recipe-schema/input-recipe.contract.d.ts +57 -4
- package/dist/src/recipe-schema/recipe-kind.enum.d.ts +3 -2
- package/dist/src/recipe-schema/step.contract.d.ts +46 -2
- package/dist/src/record-sink/dedupe.policy.d.ts +19 -7
- package/dist/src/record-sink/index.d.ts +1 -0
- package/dist/src/selection/index.d.ts +1 -1
- package/dist/src/selection/json-text.algorithm.d.ts +30 -3
- 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 +18 -3
- package/dist/src/workbook-document/csv-parser.algorithm.d.ts +26 -0
- package/dist/src/workbook-document/csv-workbook.mapper.d.ts +24 -0
- package/dist/src/workbook-document/grid-table.algorithm.d.ts +53 -0
- package/dist/src/workbook-document/html-tables.mapper.d.ts +14 -0
- package/dist/src/workbook-document/index.d.ts +9 -0
- package/dist/src/workbook-document/read-xlsx.client.d.ts +18 -0
- package/dist/src/workbook-document/workbook-document.model.d.ts +51 -0
- package/dist/src/yaml-document/index.d.ts +3 -0
- package/dist/src/yaml-document/read-yaml.client.d.ts +25 -0
- package/package.json +16 -2
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,470 +1268,252 @@ class BrowserClient {
|
|
|
1248
1268
|
}
|
|
1249
1269
|
}
|
|
1250
1270
|
|
|
1251
|
-
/**
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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;
|
|
1256
1302
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
+
}
|
|
1263
1317
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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;
|
|
1270
1382
|
try {
|
|
1271
|
-
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1383
|
+
await context.close();
|
|
1384
|
+
} finally {
|
|
1385
|
+
await unlock();
|
|
1386
|
+
this.free(name);
|
|
1274
1387
|
}
|
|
1275
|
-
}
|
|
1388
|
+
});
|
|
1389
|
+
this.held.set(name, {
|
|
1390
|
+
owner,
|
|
1391
|
+
session
|
|
1392
|
+
});
|
|
1393
|
+
return session;
|
|
1276
1394
|
}
|
|
1277
1395
|
}
|
|
1278
|
-
|
|
1279
1396
|
/**
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
* tree the event happened. `step:start` yields nothing; `step:finish` carries
|
|
1283
|
-
* the duration, so every step prints once.
|
|
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.
|
|
1284
1399
|
*
|
|
1285
|
-
* @param
|
|
1286
|
-
* @
|
|
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.
|
|
1287
1404
|
*/
|
|
1288
|
-
function
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
{
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
{
|
|
1304
|
-
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
case 'page:visit':
|
|
1311
|
-
{
|
|
1312
|
-
return `${indent(1)}⇢ page ${event.number} ${event.url}${event.status === undefined || event.status >= 200 && event.status < 300 ? '' : ` [${event.status}]`}`;
|
|
1313
|
-
}
|
|
1314
|
-
case 'step:start':
|
|
1315
|
-
{
|
|
1316
|
-
return undefined;
|
|
1317
|
-
}
|
|
1318
|
-
case 'step:finish':
|
|
1319
|
-
{
|
|
1320
|
-
return `${indent(depthOf$1(event.path))}· ${stepLabel(event)} ${event.durationMs} ms`;
|
|
1321
|
-
}
|
|
1322
|
-
case 'step:retry':
|
|
1323
|
-
{
|
|
1324
|
-
return `${indent(depthOf$1(event.path))}↻ ${stepLabel(event)} retry ${event.attempt}: ${event.error}`;
|
|
1325
|
-
}
|
|
1326
|
-
case 'step:skip':
|
|
1327
|
-
{
|
|
1328
|
-
return `${indent(depthOf$1(event.path))}↷ ${stepLabel(event)} skipped: ${event.error}`;
|
|
1329
|
-
}
|
|
1330
|
-
case 'step:branch':
|
|
1331
|
-
{
|
|
1332
|
-
return `${indent(depthOf$1(event.path))}⑂ ${event.path} ${event.branch}`;
|
|
1333
|
-
}
|
|
1334
|
-
case 'record:emit':
|
|
1335
|
-
{
|
|
1336
|
-
return `${indent(1)}✚ record ${event.key ?? '(no key)'}`;
|
|
1337
|
-
}
|
|
1338
|
-
case 'record:reject':
|
|
1339
|
-
{
|
|
1340
|
-
return `${indent(1)}✖ record rejected: ${event.field}: ${event.reason}`;
|
|
1341
|
-
}
|
|
1342
|
-
case 'record:duplicate':
|
|
1343
|
-
{
|
|
1344
|
-
return `${indent(1)}≡ duplicate ${event.key}`;
|
|
1345
|
-
}
|
|
1346
|
-
case 'record:skipped':
|
|
1347
|
-
{
|
|
1348
|
-
return `${indent(1)}⤼ skipped ${event.key}`;
|
|
1349
|
-
}
|
|
1350
|
-
case 'warning':
|
|
1351
|
-
{
|
|
1352
|
-
return `${indent(1)}! ${event.message}`;
|
|
1353
|
-
}
|
|
1354
|
-
case 'error':
|
|
1355
|
-
{
|
|
1356
|
-
return `${indent(1)}✖ ${event.message}`;
|
|
1357
|
-
}
|
|
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
|
+
}
|
|
1358
1427
|
}
|
|
1428
|
+
throw new Error(`browser profile "${name}": cannot lock ${path}`);
|
|
1359
1429
|
}
|
|
1360
|
-
/**
|
|
1361
|
-
function
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
+
}
|
|
1366
1438
|
}
|
|
1367
|
-
function
|
|
1368
|
-
|
|
1439
|
+
function isAlive(pid) {
|
|
1440
|
+
try {
|
|
1441
|
+
process.kill(pid, 0);
|
|
1442
|
+
return true;
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
return error.code === 'EPERM';
|
|
1445
|
+
}
|
|
1369
1446
|
}
|
|
1370
1447
|
|
|
1371
|
-
/**
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
|
1378
1459
|
}
|
|
1379
1460
|
}
|
|
1380
1461
|
|
|
1381
|
-
/**
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
if (!this.has(name)) throw new UnknownHookError(name, this.names());
|
|
1403
|
-
return this.hooks[name];
|
|
1462
|
+
/**
|
|
1463
|
+
* Runs a body once per item of a list (`over`), or once per live element
|
|
1464
|
+
* matching `selector`, each in a fresh child scope with the item bound under
|
|
1465
|
+
* `as`; emits a record per iteration when asked.
|
|
1466
|
+
*
|
|
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.
|
|
1471
|
+
*
|
|
1472
|
+
* @param step - The forEach step.
|
|
1473
|
+
* @param scope - The scope the list lives in.
|
|
1474
|
+
* @param walk - Runs a step list; also carries the emit callback.
|
|
1475
|
+
* @returns `stop` when the crawl reached its record limit.
|
|
1476
|
+
*/
|
|
1477
|
+
async function runForEach(step, scope, walk) {
|
|
1478
|
+
const items = await itemsOf$1(step, scope, walk);
|
|
1479
|
+
const gate = walk.gate;
|
|
1480
|
+
if (gate?.concurrent === true && step.selector === undefined && (walk.recipe.mode === 'api' || walk.runner.fork !== undefined)) return runPooled(step, scope, walk, items, gate);
|
|
1481
|
+
for (const item of items) {
|
|
1482
|
+
if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
|
|
1404
1483
|
}
|
|
1484
|
+
return 'continue';
|
|
1405
1485
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
const
|
|
1410
|
-
return
|
|
1411
|
-
|
|
1412
|
-
open: async () => {},
|
|
1413
|
-
write: async record => {
|
|
1414
|
-
records.push(record);
|
|
1415
|
-
},
|
|
1416
|
-
has: async key => records.some(record => record.key === key),
|
|
1417
|
-
close: async () => ({
|
|
1418
|
-
written: records.length
|
|
1419
|
-
})
|
|
1420
|
-
};
|
|
1486
|
+
async function runIteration(step, scope, walk, item, overrides) {
|
|
1487
|
+
const child = scope.child();
|
|
1488
|
+
child.set(step.as, item);
|
|
1489
|
+
const outcome = await walk.runSteps(step.steps, child, `${walk.path}.steps`, overrides);
|
|
1490
|
+
if (outcome === 'stop' || step.emit === undefined) return outcome;
|
|
1491
|
+
return walk.onEmit(child, step.emit === true ? undefined : step.emit.output);
|
|
1421
1492
|
}
|
|
1422
|
-
|
|
1423
1493
|
/**
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
* @param path - The file to write; created with its directories, truncated on open unless `append`.
|
|
1428
|
-
* @param options - Append mode.
|
|
1429
|
-
* @returns The sink.
|
|
1430
|
-
*/
|
|
1431
|
-
function jsonLinesSink(path, options = {}) {
|
|
1432
|
-
const target = resolve$1(path);
|
|
1433
|
-
const append = options.append === true;
|
|
1434
|
-
const keys = new Set();
|
|
1435
|
-
let stream;
|
|
1436
|
-
let written = 0;
|
|
1437
|
-
return {
|
|
1438
|
-
async open() {
|
|
1439
|
-
await mkdir(dirname(target), {
|
|
1440
|
-
recursive: true
|
|
1441
|
-
});
|
|
1442
|
-
const existing = append ? await existingKeys(target) : [];
|
|
1443
|
-
for (const key of existing) keys.add(key);
|
|
1444
|
-
stream = createWriteStream(target, {
|
|
1445
|
-
encoding: 'utf8',
|
|
1446
|
-
flags: append ? 'a' : 'w'
|
|
1447
|
-
});
|
|
1448
|
-
await once(stream, 'open');
|
|
1449
|
-
},
|
|
1450
|
-
async write(record) {
|
|
1451
|
-
if (stream === undefined) throw new Error('jsonLinesSink: write before open');
|
|
1452
|
-
const line = `${JSON.stringify({
|
|
1453
|
-
...record.data,
|
|
1454
|
-
_source: record.source,
|
|
1455
|
-
...(append && record.key !== null && {
|
|
1456
|
-
_key: record.key
|
|
1457
|
-
})
|
|
1458
|
-
})}\n`;
|
|
1459
|
-
if (!stream.write(line)) await once(stream, 'drain');
|
|
1460
|
-
if (record.key !== null) keys.add(record.key);
|
|
1461
|
-
written += 1;
|
|
1462
|
-
},
|
|
1463
|
-
async close() {
|
|
1464
|
-
if (stream !== undefined) {
|
|
1465
|
-
stream.end();
|
|
1466
|
-
await once(stream, 'finish');
|
|
1467
|
-
stream = undefined;
|
|
1468
|
-
}
|
|
1469
|
-
return {
|
|
1470
|
-
written,
|
|
1471
|
-
location: target
|
|
1472
|
-
};
|
|
1473
|
-
},
|
|
1474
|
-
async has(key) {
|
|
1475
|
-
return keys.has(key);
|
|
1476
|
-
}
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
|
-
/** The `_key` of every line already in the file; none when the file does not exist. */
|
|
1480
|
-
async function existingKeys(target) {
|
|
1481
|
-
let content;
|
|
1482
|
-
try {
|
|
1483
|
-
content = await readFile(target, 'utf8');
|
|
1484
|
-
} catch {
|
|
1485
|
-
return [];
|
|
1486
|
-
}
|
|
1487
|
-
return content.split('\n').flatMap(line => {
|
|
1488
|
-
if (line.trim() === '') return [];
|
|
1489
|
-
try {
|
|
1490
|
-
const key = JSON.parse(line)._key;
|
|
1491
|
-
return typeof key === 'string' ? [key] : [];
|
|
1492
|
-
} catch {
|
|
1493
|
-
return [];
|
|
1494
|
-
}
|
|
1495
|
-
});
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
/** Drops records whose key was already seen. First record wins; keyless records always pass. */
|
|
1499
|
-
class DedupePolicy {
|
|
1500
|
-
scope;
|
|
1501
|
-
seen = new Set();
|
|
1502
|
-
constructor(scope = 'run') {
|
|
1503
|
-
this.scope = scope;
|
|
1504
|
-
}
|
|
1505
|
-
/** Called when an input recipe starts; forgets keys under `recipe` scope. */
|
|
1506
|
-
startRecipe() {
|
|
1507
|
-
if (this.scope === 'recipe') this.seen = new Set();
|
|
1508
|
-
}
|
|
1509
|
-
/**
|
|
1510
|
-
* @param record - A validated record.
|
|
1511
|
-
* @returns `true` when the record repeats an earlier key and must be dropped.
|
|
1512
|
-
*/
|
|
1513
|
-
isDuplicate(record) {
|
|
1514
|
-
if (this.scope === 'off' || record.key === null) return false;
|
|
1515
|
-
if (this.seen.has(record.key)) return true;
|
|
1516
|
-
this.seen.add(record.key);
|
|
1517
|
-
return false;
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
/**
|
|
1522
|
-
* Evaluates a JSONPath expression on a decoded JSON document.
|
|
1523
|
-
*
|
|
1524
|
-
* @param document - The JSON value.
|
|
1525
|
-
* @param path - A JSONPath such as `$.items[*].url`.
|
|
1526
|
-
* @returns Every match, in document order.
|
|
1527
|
-
*/
|
|
1528
|
-
function selectJson(document, path) {
|
|
1529
|
-
return JSONPath({
|
|
1530
|
-
path,
|
|
1531
|
-
json: document,
|
|
1532
|
-
wrap: true,
|
|
1533
|
-
resultType: 'value'
|
|
1534
|
-
});
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
/**
|
|
1538
|
-
* Evaluates a regular expression on text: the extract kind for values that
|
|
1539
|
-
* live in inline scripts, attributes or prose rather than in elements or JSON.
|
|
1540
|
-
*
|
|
1541
|
-
* @param text - The document text.
|
|
1542
|
-
* @param pattern - A regular expression source; group 1 is returned when the
|
|
1543
|
-
* pattern has a capturing group, else the whole match.
|
|
1544
|
-
* @returns Every match, in document order.
|
|
1545
|
-
* @throws When the pattern is not a valid regular expression.
|
|
1546
|
-
*/
|
|
1547
|
-
function selectRegex(text, pattern) {
|
|
1548
|
-
let expression;
|
|
1549
|
-
try {
|
|
1550
|
-
expression = new RegExp(pattern, 'gs');
|
|
1551
|
-
} catch (error) {
|
|
1552
|
-
throw new Error(`invalid pattern ${pattern}: ${error.message}`, {
|
|
1553
|
-
cause: error
|
|
1554
|
-
});
|
|
1555
|
-
}
|
|
1556
|
-
const values = Array.from(text.matchAll(expression), match => match[1] ?? match[0]);
|
|
1557
|
-
return values;
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
const DOCUMENT = /^\s*(?:<!doctype|<html)/i;
|
|
1561
|
-
/**
|
|
1562
|
-
* Runs a CSS selector on static HTML. A whole document is parsed as one; anything
|
|
1563
|
-
* else (a table row, a list item taken with `take: "html"`) is parsed as a
|
|
1564
|
-
* fragment, so cells and rows outside a table survive instead of being dropped.
|
|
1565
|
-
*
|
|
1566
|
-
* @param html - The markup (a whole document or a fragment).
|
|
1567
|
-
* @param selector - A CSS selector.
|
|
1568
|
-
* @returns Every match, in document order.
|
|
1569
|
-
*/
|
|
1570
|
-
function selectHtml(html, selector) {
|
|
1571
|
-
const api = DOCUMENT.test(html) ? load(html) : load(html, undefined, false);
|
|
1572
|
-
return api(selector).map((_, element) => ({
|
|
1573
|
-
api,
|
|
1574
|
-
element: api(element)
|
|
1575
|
-
})).toArray();
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
/**
|
|
1579
|
-
* The value of an HTML match.
|
|
1580
|
-
*
|
|
1581
|
-
* @param match - A selected element.
|
|
1582
|
-
* @param take - What to take; `text` collapses whitespace.
|
|
1583
|
-
* @returns The value; `undefined` for a missing attribute.
|
|
1584
|
-
*/
|
|
1585
|
-
function takeFromHtml(match, take) {
|
|
1586
|
-
if (take === 'text') return collapse(match.element.text());
|
|
1587
|
-
if (take === 'html') return match.element.html() ?? '';
|
|
1588
|
-
if (take === 'value') return match.element.val() ?? match.element.attr('value');
|
|
1589
|
-
if (take === 'json') return match.api.html(match.element);
|
|
1590
|
-
return match.element.attr(take.slice('attr:'.length));
|
|
1591
|
-
}
|
|
1592
|
-
/**
|
|
1593
|
-
* The value of a JSON match.
|
|
1594
|
-
*
|
|
1595
|
-
* @param node - A JSONPath result.
|
|
1596
|
-
* @param take - `json` keeps the node; `text` stringifies scalars.
|
|
1597
|
-
* @returns The value.
|
|
1598
|
-
*/
|
|
1599
|
-
function takeFromJson(node, take) {
|
|
1600
|
-
if (take === 'json') return node;
|
|
1601
|
-
if (node === null || node === undefined) return undefined;
|
|
1602
|
-
if (typeof node === 'object') return JSON.stringify(node);
|
|
1603
|
-
return String(node);
|
|
1604
|
-
}
|
|
1605
|
-
/**
|
|
1606
|
-
* Text as a human reads it: runs of whitespace collapsed, ends trimmed.
|
|
1607
|
-
*
|
|
1608
|
-
* @param text - Raw text content.
|
|
1609
|
-
* @returns The collapsed text.
|
|
1610
|
-
*/
|
|
1611
|
-
function collapse(text) {
|
|
1612
|
-
return text.replaceAll(/\s+/g, ' ').trim();
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
/**
|
|
1616
|
-
* JSON that arrives as text: a `<script type="application/ld+json">` body, a
|
|
1617
|
-
* `data-*` attribute, a fetched document read as text. Sites wrap inline JSON
|
|
1618
|
-
* in comment guards, which are stripped before parsing.
|
|
1619
|
-
*/
|
|
1620
|
-
/** Comment guards sites wrap inline JSON-LD in: a CDATA marker inside a block comment, or an HTML comment. */
|
|
1621
|
-
const GUARDS = /^\s*(?:\/\*\s*<!\[CDATA\[\s*\*\/|<!\[CDATA\[|<!--)\s*|\s*(?:\/\*\s*\]\]>\s*\*\/|\]\]>|-->)\s*$/g;
|
|
1622
|
-
/**
|
|
1623
|
-
* Parses text as JSON, guards stripped.
|
|
1624
|
-
*
|
|
1625
|
-
* @param text - The text.
|
|
1626
|
-
* @returns The value, or `undefined` when it is not JSON.
|
|
1627
|
-
*/
|
|
1628
|
-
function tryParseJson(text) {
|
|
1629
|
-
try {
|
|
1630
|
-
return JSON.parse(text.replaceAll(GUARDS, ''));
|
|
1631
|
-
} catch {
|
|
1632
|
-
return undefined;
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
/**
|
|
1636
|
-
* Parses text that must be JSON.
|
|
1637
|
-
*
|
|
1638
|
-
* @param text - The text.
|
|
1639
|
-
* @param id - What the text is, for the error.
|
|
1640
|
-
* @returns The value.
|
|
1641
|
-
* @throws Error when it is not JSON.
|
|
1642
|
-
*/
|
|
1643
|
-
function parseJsonText(text, id) {
|
|
1644
|
-
const parsed = tryParseJson(text);
|
|
1645
|
-
if (parsed === undefined) throw new Error(`"${id}" is text but not JSON`);
|
|
1646
|
-
return parsed;
|
|
1647
|
-
}
|
|
1648
|
-
/**
|
|
1649
|
-
* The data a value holds, whatever shape it arrived in: JSON text is parsed, a
|
|
1650
|
-
* list of texts becomes the list of its parsable entries, and an entry that
|
|
1651
|
-
* parses to a list is spliced in. Data that is not text is kept as is.
|
|
1652
|
-
*
|
|
1653
|
-
* @param value - A bound value: data, text, or a list of either.
|
|
1654
|
-
* @returns A list of items.
|
|
1655
|
-
*/
|
|
1656
|
-
function dataItemsOf(value) {
|
|
1657
|
-
if (typeof value === 'string') return itemsOf$1(tryParseJson(value));
|
|
1658
|
-
if (Array.isArray(value)) return value.flatMap(entry => typeof entry === 'string' ? itemsOf$1(tryParseJson(entry)) : [entry]);
|
|
1659
|
-
return itemsOf$1(value);
|
|
1660
|
-
}
|
|
1661
|
-
function itemsOf$1(parsed) {
|
|
1662
|
-
if (parsed === undefined || parsed === null) return [];
|
|
1663
|
-
return Array.isArray(parsed) ? parsed : [parsed];
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
/**
|
|
1667
|
-
* Runs a body once per item of a list (`over`), or once per live element
|
|
1668
|
-
* matching `selector`, each in a fresh child scope with the item bound under
|
|
1669
|
-
* `as`; emits a record per iteration when asked.
|
|
1670
|
-
*
|
|
1671
|
-
* With a concurrent gate, iterations run as permits allow and records come
|
|
1672
|
-
* out in completion order; without one, in list order.
|
|
1673
|
-
*
|
|
1674
|
-
* @param step - The forEach step.
|
|
1675
|
-
* @param scope - The scope the list lives in.
|
|
1676
|
-
* @param walk - Runs a step list; also carries the emit callback.
|
|
1677
|
-
* @returns `stop` when the crawl reached its record limit.
|
|
1678
|
-
*/
|
|
1679
|
-
async function runForEach(step, scope, walk) {
|
|
1680
|
-
const items = await itemsOf(step, scope, walk);
|
|
1681
|
-
const gate = walk.gate;
|
|
1682
|
-
if (gate?.concurrent === true) return runPooled(step, scope, walk, items, gate);
|
|
1683
|
-
for (const item of items) {
|
|
1684
|
-
if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
|
|
1685
|
-
}
|
|
1686
|
-
return 'continue';
|
|
1687
|
-
}
|
|
1688
|
-
async function runIteration(step, scope, walk, item, overrides) {
|
|
1689
|
-
const child = scope.child();
|
|
1690
|
-
child.set(step.as, item);
|
|
1691
|
-
const outcome = await walk.runSteps(step.steps, child, `${walk.path}.steps`, overrides);
|
|
1692
|
-
if (outcome === 'stop' || step.emit === undefined) return outcome;
|
|
1693
|
-
return walk.onEmit(child, step.emit === true ? undefined : step.emit.output);
|
|
1694
|
-
}
|
|
1695
|
-
/**
|
|
1696
|
-
* Starts iterations as the gate hands out permits. A `stop` or a failure stops
|
|
1697
|
-
* new iterations; the ones in flight finish first, so the runner is never
|
|
1698
|
-
* disposed under them. The first failure is rethrown afterwards.
|
|
1494
|
+
* Starts iterations as the gate hands out permits. A `stop` or a failure stops
|
|
1495
|
+
* new iterations; the ones in flight finish first, so the runner is never
|
|
1496
|
+
* disposed under them. The first failure is rethrown afterwards.
|
|
1699
1497
|
*/
|
|
1700
1498
|
async function runPooled(step, scope, walk, items, gate) {
|
|
1701
1499
|
let stopped = false;
|
|
1702
1500
|
let failure;
|
|
1703
1501
|
const tasks = [];
|
|
1704
|
-
const
|
|
1705
|
-
gate: gate.nested()
|
|
1706
|
-
};
|
|
1502
|
+
const nested = gate.nested();
|
|
1707
1503
|
const iterate = async (item, release) => {
|
|
1504
|
+
let runner;
|
|
1708
1505
|
try {
|
|
1709
|
-
|
|
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;
|
|
1710
1511
|
} catch (error) {
|
|
1711
1512
|
failure ??= {
|
|
1712
1513
|
error
|
|
1713
1514
|
};
|
|
1714
1515
|
} finally {
|
|
1516
|
+
if (runner !== undefined && runner !== walk.runner) await disposeQuietly(runner);
|
|
1715
1517
|
release();
|
|
1716
1518
|
}
|
|
1717
1519
|
};
|
|
@@ -1728,7 +1530,7 @@ async function runPooled(step, scope, walk, items, gate) {
|
|
|
1728
1530
|
if (failure !== undefined) throw failure.error;
|
|
1729
1531
|
return stopped ? 'stop' : 'continue';
|
|
1730
1532
|
}
|
|
1731
|
-
async function itemsOf(step, scope, walk) {
|
|
1533
|
+
async function itemsOf$1(step, scope, walk) {
|
|
1732
1534
|
if (step.selector !== undefined) {
|
|
1733
1535
|
if (walk.runner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
|
|
1734
1536
|
return walk.runner.elements(renderText(step.selector, path => scope.lookup(path)), scope);
|
|
@@ -2019,6 +1821,7 @@ function logThrough(walk) {
|
|
|
2019
1821
|
class RunGate {
|
|
2020
1822
|
permits;
|
|
2021
1823
|
minIntervalMs;
|
|
1824
|
+
hosts;
|
|
2022
1825
|
shared;
|
|
2023
1826
|
inFlight = 0;
|
|
2024
1827
|
waiting = [];
|
|
@@ -2026,11 +1829,13 @@ class RunGate {
|
|
|
2026
1829
|
/**
|
|
2027
1830
|
* @param permits - Iterations allowed in flight; 1 is sequential.
|
|
2028
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.
|
|
2029
1833
|
* @param shared - The throttle state to share (internal: `nested` gates keep their parent's).
|
|
2030
1834
|
*/
|
|
2031
|
-
constructor(permits, minIntervalMs, shared) {
|
|
1835
|
+
constructor(permits, minIntervalMs, hosts, shared) {
|
|
2032
1836
|
this.permits = permits;
|
|
2033
1837
|
this.minIntervalMs = minIntervalMs;
|
|
1838
|
+
this.hosts = hosts;
|
|
2034
1839
|
this.shared = shared;
|
|
2035
1840
|
}
|
|
2036
1841
|
/** Whether this gate lets more than one iteration run at once. */
|
|
@@ -2054,57 +1859,1353 @@ class RunGate {
|
|
|
2054
1859
|
this.inFlight -= 1;
|
|
2055
1860
|
this.waiting.shift()?.();
|
|
2056
1861
|
};
|
|
2057
|
-
}
|
|
2058
|
-
/**
|
|
2059
|
-
* Waits until a request may start: `minIntervalMs` after the previous start,
|
|
2060
|
-
* whichever loop started it. Returns at once when the interval has passed.
|
|
2061
|
-
*/
|
|
2062
|
-
async throttle() {
|
|
2063
|
-
const state = this.shared ?? this;
|
|
2064
|
-
if (state.minIntervalMs <= 0) return;
|
|
2065
|
-
const now = Date.now();
|
|
2066
|
-
const at = Math.max(now, state.lastStart + state.minIntervalMs);
|
|
2067
|
-
state.lastStart = at;
|
|
2068
|
-
await sleep(at - now);
|
|
2069
|
-
}
|
|
2070
|
-
/**
|
|
2071
|
-
|
|
2072
|
-
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Waits until a request may start: `minIntervalMs` after the previous start,
|
|
1865
|
+
* whichever loop started it. Returns at once when the interval has passed.
|
|
1866
|
+
*/
|
|
1867
|
+
async throttle() {
|
|
1868
|
+
const state = this.shared ?? this;
|
|
1869
|
+
if (state.minIntervalMs <= 0) return;
|
|
1870
|
+
const now = Date.now();
|
|
1871
|
+
const at = Math.max(now, state.lastStart + state.minIntervalMs);
|
|
1872
|
+
state.lastStart = at;
|
|
1873
|
+
await sleep(at - now);
|
|
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
|
+
}
|
|
1886
|
+
/** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
|
|
1887
|
+
nested() {
|
|
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;
|
|
1991
|
+
}
|
|
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
|
+
}
|
|
2127
|
+
|
|
2128
|
+
/** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
|
|
2129
|
+
const DEFAULT_BLOCK_RULE = {
|
|
2130
|
+
status: [403, 429],
|
|
2131
|
+
header: {
|
|
2132
|
+
'x-amzn-waf-action': 'challenge'
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
/**
|
|
2136
|
+
* Whether a response is a block.
|
|
2137
|
+
*
|
|
2138
|
+
* @param response - What came back.
|
|
2139
|
+
* @param rule - The recipe's `session.blockedWhen`; `DEFAULT_BLOCK_RULE` when omitted.
|
|
2140
|
+
* @returns The error to throw, or `undefined` when the response is not a block.
|
|
2141
|
+
*/
|
|
2142
|
+
async function detectBlock(response, rule = DEFAULT_BLOCK_RULE) {
|
|
2143
|
+
if (rule.status?.includes(response.status) === true) return new BlockedError(response.url, response.status, `HTTP ${response.status}`);
|
|
2144
|
+
const headers = Object.entries(rule.header ?? {});
|
|
2145
|
+
for (const [name, pattern] of headers) {
|
|
2146
|
+
const value = response.headers[name.toLowerCase()];
|
|
2147
|
+
if (value !== undefined && new RegExp(pattern, 'i').test(value)) return new BlockedError(response.url, response.status, `${name.toLowerCase()}: ${value}`);
|
|
2148
|
+
}
|
|
2149
|
+
if (rule.text !== undefined && response.text !== undefined) {
|
|
2150
|
+
let body = '';
|
|
2151
|
+
try {
|
|
2152
|
+
body = await response.text();
|
|
2153
|
+
} catch {
|
|
2154
|
+
// a body that cannot be read (a redirect, a download) cannot match
|
|
2155
|
+
}
|
|
2156
|
+
const pattern = new RegExp(rule.text, 'i');
|
|
2157
|
+
if (pattern.test(body)) return new BlockedError(response.url, response.status, `body matches /${rule.text}/i`);
|
|
2158
|
+
}
|
|
2159
|
+
return undefined;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* A challenge the engine could not get past: the solver failed, the page did
|
|
2164
|
+
* not confirm it, or the budget ran out. It is a block, so a recipe with
|
|
2165
|
+
* `onBlock.rotate` retries the step on a new access lease (a new IP often
|
|
2166
|
+
* means an easier challenge, or none), then the step's error policy applies.
|
|
2167
|
+
*/
|
|
2168
|
+
class CaptchaError extends BlockedError {
|
|
2169
|
+
kind;
|
|
2170
|
+
attempts;
|
|
2171
|
+
constructor(url, kind, attempts, reason) {
|
|
2172
|
+
super(url, 0, `captcha (${kind}) ${reason}`);
|
|
2173
|
+
this.kind = kind;
|
|
2174
|
+
this.attempts = attempts;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/** The captcha solvers a crawler was given, by name. */
|
|
2179
|
+
class CaptchaSolverRegistry {
|
|
2180
|
+
solvers = new Map();
|
|
2181
|
+
order = [];
|
|
2182
|
+
constructor(solvers = []) {
|
|
2183
|
+
for (const solver of solvers) {
|
|
2184
|
+
if (this.solvers.has(solver.name)) throw new Error(`two captcha solvers are named "${solver.name}"`);
|
|
2185
|
+
this.solvers.set(solver.name, solver);
|
|
2186
|
+
this.order.push(solver.name);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
/** The registered names. */
|
|
2190
|
+
get names() {
|
|
2191
|
+
return [...this.order];
|
|
2192
|
+
}
|
|
2193
|
+
has(name) {
|
|
2194
|
+
return this.solvers.has(name);
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* The solver of that name.
|
|
2198
|
+
*
|
|
2199
|
+
* @param name - As a recipe names it.
|
|
2200
|
+
* @returns The solver.
|
|
2201
|
+
* @throws Error naming what is registered when it is not.
|
|
2202
|
+
*/
|
|
2203
|
+
resolve(name) {
|
|
2204
|
+
const solver = this.solvers.get(name);
|
|
2205
|
+
if (solver === undefined) throw new Error(`captcha solver "${name}" is not registered (registered: ${this.names.join(', ') || 'none'}); give it to createCrawler({ captchaSolvers }) or export it from the plugins module`);
|
|
2206
|
+
return solver;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/** Default solves a recipe run may spend. */
|
|
2211
|
+
const DEFAULT_MAX_SOLVES = 10;
|
|
2212
|
+
/**
|
|
2213
|
+
* The solves a recipe run may still spend. Every solve costs money: a detector
|
|
2214
|
+
* that matches the wrong element would drain a balance without it. Shared by
|
|
2215
|
+
* every runner of the run, rotations included.
|
|
2216
|
+
*/
|
|
2217
|
+
class CaptchaBudget {
|
|
2218
|
+
max;
|
|
2219
|
+
used = 0;
|
|
2220
|
+
constructor(max = DEFAULT_MAX_SOLVES) {
|
|
2221
|
+
this.max = max;
|
|
2222
|
+
}
|
|
2223
|
+
/** Solves spent so far. */
|
|
2224
|
+
get spent() {
|
|
2225
|
+
return this.used;
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Spends one solve.
|
|
2229
|
+
*
|
|
2230
|
+
* @returns Whether one was left.
|
|
2231
|
+
*/
|
|
2232
|
+
take() {
|
|
2233
|
+
if (this.used >= this.max) return false;
|
|
2234
|
+
this.used += 1;
|
|
2235
|
+
return true;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
/** The widgets detection looks for when a recipe names none: reCAPTCHA v2, hCaptcha and Turnstile, as a container or as their iframe. */
|
|
2240
|
+
const DEFAULT_CAPTCHA_SELECTOR = ['.g-recaptcha', 'iframe[src*="recaptcha/api2/anchor"]', 'iframe[src*="recaptcha/enterprise/anchor"]', '.h-captcha', 'iframe[src*="hcaptcha.com"]', '.cf-turnstile', 'iframe[src*="challenges.cloudflare.com"]'].join(', ');
|
|
2241
|
+
/** reCAPTCHA v3 runs without a widget: its script is loaded with the site key as `render`. */
|
|
2242
|
+
const RECAPTCHA_V3_SCRIPT = 'script[src*="recaptcha/api.js?render="], script[src*="recaptcha/enterprise.js?render="]';
|
|
2243
|
+
/** Matches past this many are not looked at: a page does not show more challenges than that. */
|
|
2244
|
+
const MAX_CANDIDATES = 10;
|
|
2245
|
+
/**
|
|
2246
|
+
* The first visible challenge on the page, if any. An element counts only
|
|
2247
|
+
* when visible: sites keep hidden widgets around after a solve, and an
|
|
2248
|
+
* invisible reCAPTCHA shows nothing until it challenges.
|
|
2249
|
+
*
|
|
2250
|
+
* @param page - The live page.
|
|
2251
|
+
* @param selector - Where challenges are; `DEFAULT_CAPTCHA_SELECTOR` when omitted.
|
|
2252
|
+
* @param options - `v3`: also report a reCAPTCHA v3 script (a `captcha` step asks for it; the automatic checks do not, since v3 never blocks a page by itself).
|
|
2253
|
+
* @returns The challenge, or `undefined`.
|
|
2254
|
+
*/
|
|
2255
|
+
async function detectChallenge(page, selector = DEFAULT_CAPTCHA_SELECTOR, options = {}) {
|
|
2256
|
+
const matches = page.locator(selector);
|
|
2257
|
+
const count = Math.min(await matches.count(), MAX_CANDIDATES);
|
|
2258
|
+
for (let index = 0; index < count; index += 1) {
|
|
2259
|
+
const element = matches.nth(index);
|
|
2260
|
+
if (!(await element.isVisible())) continue;
|
|
2261
|
+
const facts = await element.evaluate(readWidget);
|
|
2262
|
+
return challengeOf(facts, page.url(), `${selector} >> nth=${index}`);
|
|
2263
|
+
}
|
|
2264
|
+
if (options.v3 !== true) return undefined;
|
|
2265
|
+
const script = page.locator(RECAPTCHA_V3_SCRIPT).first();
|
|
2266
|
+
if ((await script.count()) === 0) return undefined;
|
|
2267
|
+
const src = (await script.getAttribute('src')) ?? '';
|
|
2268
|
+
const siteKey = new URL(src, page.url()).searchParams.get('render') ?? undefined;
|
|
2269
|
+
return {
|
|
2270
|
+
kind: 'recaptcha-v3',
|
|
2271
|
+
url: page.url(),
|
|
2272
|
+
...(siteKey !== undefined && siteKey !== 'explicit' && {
|
|
2273
|
+
siteKey
|
|
2274
|
+
})
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* Whether the page is clear of challenges, tolerating a page that is
|
|
2279
|
+
* navigating (a solve often submits a form): an evaluation cut short by the
|
|
2280
|
+
* navigation counts as not clear yet.
|
|
2281
|
+
*
|
|
2282
|
+
* @param page - The live page.
|
|
2283
|
+
* @param selector - Where challenges are.
|
|
2284
|
+
* @returns Whether no challenge is visible.
|
|
2285
|
+
*/
|
|
2286
|
+
async function isClear(page, selector) {
|
|
2287
|
+
try {
|
|
2288
|
+
return (await detectChallenge(page, selector)) === undefined;
|
|
2289
|
+
} catch {
|
|
2290
|
+
return false;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
/** Runs inside the page. Keep it self-contained; it is serialised. */
|
|
2294
|
+
function readWidget(element) {
|
|
2295
|
+
const source = element.getAttribute('src') ?? element.querySelector('iframe')?.getAttribute('src') ?? '';
|
|
2296
|
+
return {
|
|
2297
|
+
tag: element.tagName.toLowerCase(),
|
|
2298
|
+
className: element.getAttribute('class') ?? '',
|
|
2299
|
+
src: source,
|
|
2300
|
+
siteKey: element.dataset.sitekey,
|
|
2301
|
+
action: element.dataset.action
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
function challengeOf(facts, url, selector) {
|
|
2305
|
+
const siteKey = facts.siteKey ?? siteKeyIn(facts.src, url);
|
|
2306
|
+
return {
|
|
2307
|
+
kind: kindOf(facts),
|
|
2308
|
+
url,
|
|
2309
|
+
selector,
|
|
2310
|
+
...(siteKey !== undefined && {
|
|
2311
|
+
siteKey
|
|
2312
|
+
}),
|
|
2313
|
+
...(facts.action !== undefined && {
|
|
2314
|
+
action: facts.action
|
|
2315
|
+
})
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
function kindOf(facts) {
|
|
2319
|
+
const hint = `${facts.className} ${facts.src}`.toLowerCase();
|
|
2320
|
+
if (hint.includes('recaptcha')) return 'recaptcha-v2';
|
|
2321
|
+
if (hint.includes('hcaptcha')) return 'hcaptcha';
|
|
2322
|
+
if (hint.includes('turnstile') || hint.includes('challenges.cloudflare.com')) return 'turnstile';
|
|
2323
|
+
return facts.tag === 'img' || facts.tag === 'canvas' ? 'image' : 'unknown';
|
|
2324
|
+
}
|
|
2325
|
+
/** A widget iframe carries its site key in the query: `k` (reCAPTCHA) or `sitekey` (hCaptcha). */
|
|
2326
|
+
function siteKeyIn(src, base) {
|
|
2327
|
+
if (src === '') return undefined;
|
|
2328
|
+
try {
|
|
2329
|
+
const parameters = new URL(src, base).searchParams;
|
|
2330
|
+
const hashParameters = new URLSearchParams(new URL(src, base).hash.slice(1));
|
|
2331
|
+
return parameters.get('k') ?? parameters.get('sitekey') ?? hashParameters.get('sitekey') ?? undefined;
|
|
2332
|
+
} catch {
|
|
2333
|
+
return undefined;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/** Default solves tried per challenge. */
|
|
2338
|
+
const DEFAULT_CAPTCHA_ATTEMPTS = 3;
|
|
2339
|
+
/** Default time one solve may take: token services take 10 to 60 seconds. */
|
|
2340
|
+
const DEFAULT_CAPTCHA_TIMEOUT_MS = 120_000;
|
|
2341
|
+
/** How long the page has to confirm a solve. */
|
|
2342
|
+
const VERIFY_TIMEOUT_MS = 10_000;
|
|
2343
|
+
const VERIFY_POLL_MS = 250;
|
|
2344
|
+
/**
|
|
2345
|
+
* Gets past one challenge: asks the solver, then checks the page (a solver's
|
|
2346
|
+
* `solved` is a claim; the challenge must be gone and/or the `verify` element
|
|
2347
|
+
* must appear), and tries again with what the page shows next, up to
|
|
2348
|
+
* `attempts`. Each try spends one solve of the run's budget.
|
|
2349
|
+
*
|
|
2350
|
+
* A failed try re-detects the challenge (a widget re-renders after a wrong
|
|
2351
|
+
* answer). When it is gone without the page confirming, the page is reloaded
|
|
2352
|
+
* for a fresh one; when a reload shows none, there is nothing left to solve.
|
|
2353
|
+
*
|
|
2354
|
+
* @param plan - The challenge, the solver and the limits.
|
|
2355
|
+
* @throws CaptchaError when every try failed, or the budget is spent.
|
|
2356
|
+
*/
|
|
2357
|
+
async function resolveCaptcha(plan) {
|
|
2358
|
+
const {
|
|
2359
|
+
recipeId,
|
|
2360
|
+
page,
|
|
2361
|
+
solver,
|
|
2362
|
+
events,
|
|
2363
|
+
budget
|
|
2364
|
+
} = plan;
|
|
2365
|
+
let challenge = plan.challenge;
|
|
2366
|
+
let reason = 'no attempt ran';
|
|
2367
|
+
for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
|
|
2368
|
+
if (!budget.take()) {
|
|
2369
|
+
events.emit({
|
|
2370
|
+
type: 'captcha:budget',
|
|
2371
|
+
recipeId,
|
|
2372
|
+
url: challenge.url,
|
|
2373
|
+
kind: challenge.kind,
|
|
2374
|
+
max: budget.max
|
|
2375
|
+
});
|
|
2376
|
+
throw new CaptchaError(challenge.url, challenge.kind, attempt - 1, `left unsolved: the run's ${budget.max} solves are spent (session.captcha.maxSolves)`);
|
|
2377
|
+
}
|
|
2378
|
+
events.emit({
|
|
2379
|
+
type: 'captcha:solve',
|
|
2380
|
+
recipeId,
|
|
2381
|
+
url: challenge.url,
|
|
2382
|
+
kind: challenge.kind,
|
|
2383
|
+
solver: solver.name,
|
|
2384
|
+
attempt
|
|
2385
|
+
});
|
|
2386
|
+
const started = Date.now();
|
|
2387
|
+
const outcome = await solveOnce(plan, challenge, attempt);
|
|
2388
|
+
if (outcome.status === 'solved' && (await confirmed(page, challenge, plan))) {
|
|
2389
|
+
events.emit({
|
|
2390
|
+
type: 'captcha:solved',
|
|
2391
|
+
recipeId,
|
|
2392
|
+
url: challenge.url,
|
|
2393
|
+
kind: challenge.kind,
|
|
2394
|
+
solver: solver.name,
|
|
2395
|
+
attempt,
|
|
2396
|
+
durationMs: Date.now() - started
|
|
2397
|
+
});
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
reason = outcome.status === 'failed' ? outcome.reason : 'the page still shows the challenge';
|
|
2401
|
+
events.emit({
|
|
2402
|
+
type: 'captcha:failed',
|
|
2403
|
+
recipeId,
|
|
2404
|
+
url: challenge.url,
|
|
2405
|
+
kind: challenge.kind,
|
|
2406
|
+
solver: solver.name,
|
|
2407
|
+
attempt,
|
|
2408
|
+
reason
|
|
2409
|
+
});
|
|
2410
|
+
if (attempt === plan.attempts) break;
|
|
2411
|
+
const next = await nextChallenge(page, challenge, plan.selector);
|
|
2412
|
+
if (next === undefined) return;
|
|
2413
|
+
challenge = next;
|
|
2414
|
+
}
|
|
2415
|
+
throw new CaptchaError(challenge.url, challenge.kind, plan.attempts, `not solved after ${plan.attempts} attempt${plan.attempts === 1 ? '' : 's'}: ${reason}`);
|
|
2416
|
+
}
|
|
2417
|
+
/** Runs the solver once, bounded by the timeout; a throw or a malformed answer is a failure. */
|
|
2418
|
+
async function solveOnce(plan, challenge, attempt) {
|
|
2419
|
+
const controller = new AbortController();
|
|
2420
|
+
let timer;
|
|
2421
|
+
const timeout = new Promise(resolve => {
|
|
2422
|
+
timer = setTimeout(() => {
|
|
2423
|
+
controller.abort();
|
|
2424
|
+
resolve({
|
|
2425
|
+
status: 'failed',
|
|
2426
|
+
reason: `the solver took longer than ${plan.timeoutMs} ms`
|
|
2427
|
+
});
|
|
2428
|
+
}, plan.timeoutMs);
|
|
2429
|
+
});
|
|
2430
|
+
const log = (level, message, meta) => {
|
|
2431
|
+
plan.events.emit({
|
|
2432
|
+
type: level === 'error' ? 'error' : 'warning',
|
|
2433
|
+
recipeId: plan.recipeId,
|
|
2434
|
+
message: `[${plan.solver.name}] ${message}`,
|
|
2435
|
+
meta
|
|
2436
|
+
});
|
|
2437
|
+
};
|
|
2438
|
+
const solving = (async () => {
|
|
2439
|
+
try {
|
|
2440
|
+
return outcomeOf(await plan.solver.solve(challenge, {
|
|
2441
|
+
page: plan.page,
|
|
2442
|
+
lease: plan.lease,
|
|
2443
|
+
attempt,
|
|
2444
|
+
signal: controller.signal,
|
|
2445
|
+
log
|
|
2446
|
+
}));
|
|
2447
|
+
} catch (error) {
|
|
2448
|
+
return {
|
|
2449
|
+
status: 'failed',
|
|
2450
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
})();
|
|
2454
|
+
try {
|
|
2455
|
+
return await Promise.race([solving, timeout]);
|
|
2456
|
+
} finally {
|
|
2457
|
+
clearTimeout(timer);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
function outcomeOf(value) {
|
|
2461
|
+
if (typeof value !== 'object' || value === null) return {
|
|
2462
|
+
status: 'failed',
|
|
2463
|
+
reason: 'the solver returned no outcome'
|
|
2464
|
+
};
|
|
2465
|
+
const outcome = value;
|
|
2466
|
+
if (outcome.status === 'solved') return {
|
|
2467
|
+
status: 'solved'
|
|
2468
|
+
};
|
|
2469
|
+
return {
|
|
2470
|
+
status: 'failed',
|
|
2471
|
+
reason: typeof outcome.reason === 'string' ? outcome.reason : 'the solver reported a failure'
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Whether the page confirms the solve: the challenge is gone (unless
|
|
2476
|
+
* `verify.gone` is `false`, or the challenge has no widget, as with reCAPTCHA
|
|
2477
|
+
* v3) and the `verify.selector` element is visible, within ten seconds.
|
|
2478
|
+
*/
|
|
2479
|
+
async function confirmed(page, challenge, plan) {
|
|
2480
|
+
const needGone = plan.verify?.gone !== false && challenge.selector !== undefined;
|
|
2481
|
+
const shown = plan.verify?.selector;
|
|
2482
|
+
if (!needGone && shown === undefined) return true;
|
|
2483
|
+
const deadline = Date.now() + VERIFY_TIMEOUT_MS;
|
|
2484
|
+
for (;;) {
|
|
2485
|
+
const gone = !needGone || (await isClear(page, plan.selector));
|
|
2486
|
+
const visible = shown === undefined || (await isVisible(page, shown));
|
|
2487
|
+
if (gone && visible) return true;
|
|
2488
|
+
if (Date.now() >= deadline) return false;
|
|
2489
|
+
await page.waitForTimeout(VERIFY_POLL_MS);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
async function isVisible(page, selector) {
|
|
2493
|
+
try {
|
|
2494
|
+
return await page.locator(selector).first().isVisible();
|
|
2495
|
+
} catch {
|
|
2496
|
+
return false;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
/** The challenge to try next: what the page shows now, else what a reload shows, else none. A widgetless challenge is tried as it is. */
|
|
2500
|
+
async function nextChallenge(page, previous, selector) {
|
|
2501
|
+
if (previous.selector === undefined) return previous;
|
|
2502
|
+
const current = await detectChallenge(page, selector);
|
|
2503
|
+
if (current !== undefined) return current;
|
|
2504
|
+
await page.reload();
|
|
2505
|
+
return detectChallenge(page, selector);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/**
|
|
2509
|
+
* Where a web runner meets captchas: after each navigation, click and key
|
|
2510
|
+
* press (`session.captcha`), on a block page (`onBlock.solve`), and at a
|
|
2511
|
+
* `captcha` step.
|
|
2512
|
+
*/
|
|
2513
|
+
class CaptchaGuard {
|
|
2514
|
+
options;
|
|
2515
|
+
constructor(options) {
|
|
2516
|
+
this.options = options;
|
|
2517
|
+
}
|
|
2518
|
+
settings() {
|
|
2519
|
+
const captcha = this.options.recipe.session?.captcha;
|
|
2520
|
+
if (captcha === undefined) return undefined;
|
|
2521
|
+
return {
|
|
2522
|
+
solver: captcha.solver,
|
|
2523
|
+
selector: captcha.detect?.selector ?? DEFAULT_CAPTCHA_SELECTOR,
|
|
2524
|
+
verify: captcha.verify,
|
|
2525
|
+
attempts: captcha.attempts ?? DEFAULT_CAPTCHA_ATTEMPTS,
|
|
2526
|
+
timeoutMs: captcha.timeoutMs ?? DEFAULT_CAPTCHA_TIMEOUT_MS
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
async solve(page, challenge, settings) {
|
|
2530
|
+
const {
|
|
2531
|
+
recipe,
|
|
2532
|
+
events,
|
|
2533
|
+
solvers,
|
|
2534
|
+
budget,
|
|
2535
|
+
lease
|
|
2536
|
+
} = this.options;
|
|
2537
|
+
events.emit({
|
|
2538
|
+
type: 'captcha:detected',
|
|
2539
|
+
recipeId: recipe.id,
|
|
2540
|
+
url: challenge.url,
|
|
2541
|
+
kind: challenge.kind,
|
|
2542
|
+
siteKey: challenge.siteKey
|
|
2543
|
+
});
|
|
2544
|
+
await resolveCaptcha({
|
|
2545
|
+
recipeId: recipe.id,
|
|
2546
|
+
page,
|
|
2547
|
+
challenge,
|
|
2548
|
+
solver: solvers.resolve(settings.solver),
|
|
2549
|
+
selector: settings.selector,
|
|
2550
|
+
verify: settings.verify,
|
|
2551
|
+
attempts: settings.attempts,
|
|
2552
|
+
timeoutMs: settings.timeoutMs,
|
|
2553
|
+
budget,
|
|
2554
|
+
events,
|
|
2555
|
+
lease
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
/** Whether a block page is searched for a challenge before the block counts. */
|
|
2559
|
+
get solvesBlocks() {
|
|
2560
|
+
const session = this.options.recipe.session;
|
|
2561
|
+
return session?.onBlock?.solve === true && session.captcha !== undefined;
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* The automatic check: with `session.captcha`, solves the challenge the page
|
|
2565
|
+
* shows, if any. Without it, nothing is looked for.
|
|
2566
|
+
*
|
|
2567
|
+
* @param page - The live page.
|
|
2568
|
+
* @throws CaptchaError when the challenge could not be solved.
|
|
2569
|
+
*/
|
|
2570
|
+
async check(page) {
|
|
2571
|
+
const settings = this.settings();
|
|
2572
|
+
if (settings === undefined) return;
|
|
2573
|
+
const challenge = await detectChallenge(page, settings.selector);
|
|
2574
|
+
if (challenge !== undefined) await this.solve(page, challenge, settings);
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* A block page under `onBlock.solve`: solves the challenge it shows. A block
|
|
2578
|
+
* without a challenge stays a block.
|
|
2579
|
+
*
|
|
2580
|
+
* @param page - The page showing the block.
|
|
2581
|
+
* @param blocked - The block.
|
|
2582
|
+
* @throws BlockedError (`blocked`) when the page shows no challenge; CaptchaError when it could not be solved.
|
|
2583
|
+
*/
|
|
2584
|
+
async solveBlock(page, blocked) {
|
|
2585
|
+
const settings = this.settings();
|
|
2586
|
+
if (settings === undefined) throw blocked;
|
|
2587
|
+
const challenge = await detectChallenge(page, settings.selector);
|
|
2588
|
+
if (challenge === undefined) throw blocked;
|
|
2589
|
+
await this.solve(page, challenge, settings);
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* A `captcha` step: solves the challenge the page shows, reCAPTCHA v3
|
|
2593
|
+
* included; a page without one is fine.
|
|
2594
|
+
*
|
|
2595
|
+
* @param page - The live page.
|
|
2596
|
+
* @param step - The step.
|
|
2597
|
+
* @throws CaptchaError when the challenge could not be solved.
|
|
2598
|
+
*/
|
|
2599
|
+
async step(page, step) {
|
|
2600
|
+
const base = this.settings();
|
|
2601
|
+
const solver = step.solver ?? base?.solver;
|
|
2602
|
+
if (solver === undefined) throw new Error('a captcha step needs a solver: name one ("solver") or add session.captcha');
|
|
2603
|
+
const settings = {
|
|
2604
|
+
solver,
|
|
2605
|
+
selector: step.selector ?? base?.selector ?? DEFAULT_CAPTCHA_SELECTOR,
|
|
2606
|
+
verify: step.verify ?? base?.verify,
|
|
2607
|
+
attempts: step.attempts ?? base?.attempts ?? DEFAULT_CAPTCHA_ATTEMPTS,
|
|
2608
|
+
timeoutMs: step.timeoutMs ?? base?.timeoutMs ?? DEFAULT_CAPTCHA_TIMEOUT_MS
|
|
2609
|
+
};
|
|
2610
|
+
const challenge = await detectChallenge(page, settings.selector, {
|
|
2611
|
+
v3: true
|
|
2612
|
+
});
|
|
2613
|
+
if (challenge !== undefined) await this.solve(page, challenge, settings);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* Every solver name a recipe uses (`session.captcha` and its `captcha`
|
|
2618
|
+
* steps, the bootstrap's included), to check them before the run starts.
|
|
2619
|
+
*
|
|
2620
|
+
* @param recipe - The input recipe.
|
|
2621
|
+
* @returns The names, without repeats.
|
|
2622
|
+
*/
|
|
2623
|
+
function captchaSolverNames(recipe) {
|
|
2624
|
+
const names = new Set();
|
|
2625
|
+
const fallback = recipe.session?.captcha?.solver;
|
|
2626
|
+
if (fallback !== undefined) names.add(fallback);
|
|
2627
|
+
const visit = steps => {
|
|
2628
|
+
for (const step of steps) {
|
|
2629
|
+
if (step.type === 'captcha') names.add(step.solver ?? fallback ?? '');
|
|
2630
|
+
if ('steps' in step) visit(step.steps);
|
|
2631
|
+
if (step.type === 'if') visit(step.else ?? []);
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
visit(recipe.steps);
|
|
2635
|
+
visit(recipe.session?.bootstrap?.steps ?? []);
|
|
2636
|
+
names.delete('');
|
|
2637
|
+
return [...names];
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/** Fans crawl events out to listeners. A listener that throws never breaks the crawl. */
|
|
2641
|
+
class EventBus {
|
|
2642
|
+
listeners = new Set();
|
|
2643
|
+
constructor(listener) {
|
|
2644
|
+
if (listener !== undefined) this.listeners.add(listener);
|
|
2645
|
+
}
|
|
2646
|
+
/** @returns A function that removes the listener. */
|
|
2647
|
+
subscribe(listener) {
|
|
2648
|
+
this.listeners.add(listener);
|
|
2649
|
+
return () => {
|
|
2650
|
+
this.listeners.delete(listener);
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
emit(input) {
|
|
2654
|
+
const event = {
|
|
2655
|
+
...input,
|
|
2656
|
+
at: new Date().toISOString()
|
|
2657
|
+
};
|
|
2658
|
+
for (const listener of this.listeners) {
|
|
2659
|
+
try {
|
|
2660
|
+
listener(event);
|
|
2661
|
+
} catch {
|
|
2662
|
+
// A faulty listener is the caller's problem, not the crawl's.
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/**
|
|
2669
|
+
* One line of a crawl trace: the route a recipe takes (pages visited, steps
|
|
2670
|
+
* run, records produced, policies fired), indented by how deep in the step
|
|
2671
|
+
* tree the event happened. `step:start` yields nothing; `step:finish` carries
|
|
2672
|
+
* the duration, so every step prints once.
|
|
2673
|
+
*
|
|
2674
|
+
* @param event - Any crawl event.
|
|
2675
|
+
* @returns The line, or `undefined` for events a trace does not show.
|
|
2676
|
+
*/
|
|
2677
|
+
function traceLine(event) {
|
|
2678
|
+
switch (event.type) {
|
|
2679
|
+
case 'recipe:start':
|
|
2680
|
+
{
|
|
2681
|
+
return `▶ ${event.recipeId} (${event.mode})`;
|
|
2682
|
+
}
|
|
2683
|
+
case 'recipe:finish':
|
|
2684
|
+
{
|
|
2685
|
+
return `■ ${event.recipeId}: ${event.emitted} emitted, ${event.rejected} rejected, ${event.duplicates} duplicates, ${event.skipped > 0 ? `${event.skipped} skipped, ` : ''}${(event.stepsSkipped ?? 0) > 0 ? `${event.stepsSkipped} steps skipped, ` : ''}${event.pages} pages, ${event.durationMs} ms${event.error === undefined ? '' : `\n ✖ stopped: ${event.error}`}`;
|
|
2686
|
+
}
|
|
2687
|
+
case 'access:lease':
|
|
2688
|
+
{
|
|
2689
|
+
return `${indent(1)}⇄ access ${event.profile} (${event.kind}${event.server === undefined ? '' : ` ${event.server}`}${event.session === undefined ? '' : `, session ${event.session}`})`;
|
|
2690
|
+
}
|
|
2691
|
+
case 'access:blocked':
|
|
2692
|
+
{
|
|
2693
|
+
return `${indent(1)}⛔ blocked ${event.url}: ${event.reason}`;
|
|
2694
|
+
}
|
|
2695
|
+
case 'access:rotate':
|
|
2696
|
+
{
|
|
2697
|
+
return `${indent(1)}↻ new access lease (attempt ${event.attempt})`;
|
|
2698
|
+
}
|
|
2699
|
+
case 'request:retry':
|
|
2700
|
+
{
|
|
2701
|
+
return `${indent(1)}↺ ${event.url}: ${event.reason}, try ${event.attempt} in ${event.delayMs} ms`;
|
|
2702
|
+
}
|
|
2703
|
+
case 'captcha:detected':
|
|
2704
|
+
{
|
|
2705
|
+
return `${indent(1)}⚿ captcha ${event.kind} on ${event.url}`;
|
|
2706
|
+
}
|
|
2707
|
+
case 'captcha:solve':
|
|
2708
|
+
{
|
|
2709
|
+
return undefined;
|
|
2710
|
+
}
|
|
2711
|
+
case 'captcha:solved':
|
|
2712
|
+
{
|
|
2713
|
+
return `${indent(1)}✓ captcha solved by ${event.solver} (attempt ${event.attempt}, ${event.durationMs} ms)`;
|
|
2714
|
+
}
|
|
2715
|
+
case 'captcha:failed':
|
|
2716
|
+
{
|
|
2717
|
+
return `${indent(1)}✗ captcha attempt ${event.attempt} failed: ${event.reason}`;
|
|
2718
|
+
}
|
|
2719
|
+
case 'captcha:budget':
|
|
2720
|
+
{
|
|
2721
|
+
return `${indent(1)}⛔ captcha left unsolved: the run's ${event.max} solves are spent`;
|
|
2722
|
+
}
|
|
2723
|
+
case 'page:visit':
|
|
2724
|
+
{
|
|
2725
|
+
return `${indent(1)}⇢ page ${event.number} ${event.url}${event.status === undefined || event.status >= 200 && event.status < 300 ? '' : ` [${event.status}]`}`;
|
|
2726
|
+
}
|
|
2727
|
+
case 'step:start':
|
|
2728
|
+
{
|
|
2729
|
+
return undefined;
|
|
2730
|
+
}
|
|
2731
|
+
case 'step:finish':
|
|
2732
|
+
{
|
|
2733
|
+
return `${indent(depthOf$1(event.path))}· ${stepLabel(event)} ${event.durationMs} ms`;
|
|
2734
|
+
}
|
|
2735
|
+
case 'step:retry':
|
|
2736
|
+
{
|
|
2737
|
+
return `${indent(depthOf$1(event.path))}↻ ${stepLabel(event)} retry ${event.attempt}: ${event.error}`;
|
|
2738
|
+
}
|
|
2739
|
+
case 'step:skip':
|
|
2740
|
+
{
|
|
2741
|
+
return `${indent(depthOf$1(event.path))}↷ ${stepLabel(event)} skipped: ${event.error}`;
|
|
2742
|
+
}
|
|
2743
|
+
case 'step:branch':
|
|
2744
|
+
{
|
|
2745
|
+
return `${indent(depthOf$1(event.path))}⑂ ${event.path} ${event.branch}`;
|
|
2746
|
+
}
|
|
2747
|
+
case 'record:emit':
|
|
2748
|
+
{
|
|
2749
|
+
return `${indent(1)}✚ record ${event.key ?? '(no key)'}`;
|
|
2750
|
+
}
|
|
2751
|
+
case 'record:reject':
|
|
2752
|
+
{
|
|
2753
|
+
return `${indent(1)}✖ record rejected: ${event.field}: ${event.reason}`;
|
|
2754
|
+
}
|
|
2755
|
+
case 'record:duplicate':
|
|
2756
|
+
{
|
|
2757
|
+
return `${indent(1)}≡ duplicate ${event.key}`;
|
|
2758
|
+
}
|
|
2759
|
+
case 'record:skipped':
|
|
2760
|
+
{
|
|
2761
|
+
return `${indent(1)}⤼ skipped ${event.key}`;
|
|
2762
|
+
}
|
|
2763
|
+
case 'warning':
|
|
2764
|
+
{
|
|
2765
|
+
return `${indent(1)}! ${event.message}`;
|
|
2766
|
+
}
|
|
2767
|
+
case 'error':
|
|
2768
|
+
{
|
|
2769
|
+
return `${indent(1)}✖ ${event.message}`;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
/** How deep a step path such as `steps.8.steps.2` or `steps.1.else.0` sits: one level per nested `steps` or `else`. */
|
|
2774
|
+
function depthOf$1(path) {
|
|
2775
|
+
return path.split('.').filter(segment => ['steps', 'else'].includes(segment)).length;
|
|
2776
|
+
}
|
|
2777
|
+
function stepLabel(event) {
|
|
2778
|
+
return `${event.path} ${event.stepType}${event.stepId === undefined ? '' : ` ${event.stepId}`}`;
|
|
2779
|
+
}
|
|
2780
|
+
function indent(depth) {
|
|
2781
|
+
return ' '.repeat(depth);
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/** A recipe named a hook nothing registered. */
|
|
2785
|
+
class UnknownHookError extends Error {
|
|
2786
|
+
hookName;
|
|
2787
|
+
name = 'UnknownHookError';
|
|
2788
|
+
constructor(hookName, known) {
|
|
2789
|
+
super(`unknown hook "${hookName}"${known.length === 0 ? ' (no hooks registered)' : `; registered: ${known.join(', ')}`}`);
|
|
2790
|
+
this.hookName = hookName;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/** The hooks a crawler was created with, resolved by name at run time. */
|
|
2795
|
+
class HookRegistry {
|
|
2796
|
+
hooks = {};
|
|
2797
|
+
constructor(hooks = {}) {
|
|
2798
|
+
for (const [name, hook] of Object.entries(hooks)) this.register(name, hook);
|
|
2799
|
+
}
|
|
2800
|
+
register(name, hook) {
|
|
2801
|
+
this.hooks[name] = hook;
|
|
2802
|
+
}
|
|
2803
|
+
has(name) {
|
|
2804
|
+
return Object.hasOwn(this.hooks, name);
|
|
2805
|
+
}
|
|
2806
|
+
names() {
|
|
2807
|
+
return Object.keys(this.hooks);
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* @param name - The name a recipe used.
|
|
2811
|
+
* @returns The hook.
|
|
2812
|
+
* @throws UnknownHookError when nothing was registered under that name.
|
|
2813
|
+
*/
|
|
2814
|
+
resolve(name) {
|
|
2815
|
+
if (!this.has(name)) throw new UnknownHookError(name, this.names());
|
|
2816
|
+
return this.hooks[name];
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/** @returns A new in-memory sink. */
|
|
2821
|
+
function memorySink() {
|
|
2822
|
+
const records = [];
|
|
2823
|
+
return {
|
|
2824
|
+
records,
|
|
2825
|
+
open: async () => {},
|
|
2826
|
+
write: async record => {
|
|
2827
|
+
records.push(record);
|
|
2828
|
+
},
|
|
2829
|
+
has: async key => records.some(record => record.key === key),
|
|
2830
|
+
close: async () => ({
|
|
2831
|
+
written: records.length
|
|
2832
|
+
})
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* A sink that writes one JSON object per line to a file (JSON Lines). Each line
|
|
2838
|
+
* is the record's `data` plus a `_source` member (and `_key` in append mode).
|
|
2839
|
+
*
|
|
2840
|
+
* @param path - The file to write; created with its directories, truncated on open unless `append`.
|
|
2841
|
+
* @param options - Append mode.
|
|
2842
|
+
* @returns The sink.
|
|
2843
|
+
*/
|
|
2844
|
+
function jsonLinesSink(path, options = {}) {
|
|
2845
|
+
const target = resolve$1(path);
|
|
2846
|
+
const append = options.append === true;
|
|
2847
|
+
const keys = new Set();
|
|
2848
|
+
let stream;
|
|
2849
|
+
let written = 0;
|
|
2850
|
+
return {
|
|
2851
|
+
async open() {
|
|
2852
|
+
await mkdir(dirname(target), {
|
|
2853
|
+
recursive: true
|
|
2854
|
+
});
|
|
2855
|
+
const existing = append ? await existingKeys(target) : [];
|
|
2856
|
+
for (const key of existing) keys.add(key);
|
|
2857
|
+
stream = createWriteStream(target, {
|
|
2858
|
+
encoding: 'utf8',
|
|
2859
|
+
flags: append ? 'a' : 'w'
|
|
2860
|
+
});
|
|
2861
|
+
await once(stream, 'open');
|
|
2862
|
+
},
|
|
2863
|
+
async write(record) {
|
|
2864
|
+
if (stream === undefined) throw new Error('jsonLinesSink: write before open');
|
|
2865
|
+
const line = `${JSON.stringify({
|
|
2866
|
+
...record.data,
|
|
2867
|
+
_source: record.source,
|
|
2868
|
+
...(append && record.key !== null && {
|
|
2869
|
+
_key: record.key
|
|
2870
|
+
})
|
|
2871
|
+
})}\n`;
|
|
2872
|
+
if (!stream.write(line)) await once(stream, 'drain');
|
|
2873
|
+
if (record.key !== null) keys.add(record.key);
|
|
2874
|
+
written += 1;
|
|
2875
|
+
},
|
|
2876
|
+
async close() {
|
|
2877
|
+
if (stream !== undefined) {
|
|
2878
|
+
stream.end();
|
|
2879
|
+
await once(stream, 'finish');
|
|
2880
|
+
stream = undefined;
|
|
2881
|
+
}
|
|
2882
|
+
return {
|
|
2883
|
+
written,
|
|
2884
|
+
location: target
|
|
2885
|
+
};
|
|
2886
|
+
},
|
|
2887
|
+
async has(key) {
|
|
2888
|
+
return keys.has(key);
|
|
2889
|
+
}
|
|
2890
|
+
};
|
|
2891
|
+
}
|
|
2892
|
+
/** The `_key` of every line already in the file; none when the file does not exist. */
|
|
2893
|
+
async function existingKeys(target) {
|
|
2894
|
+
let content;
|
|
2895
|
+
try {
|
|
2896
|
+
content = await readFile(target, 'utf8');
|
|
2897
|
+
} catch {
|
|
2898
|
+
return [];
|
|
2899
|
+
}
|
|
2900
|
+
return content.split('\n').flatMap(line => {
|
|
2901
|
+
if (line.trim() === '') return [];
|
|
2902
|
+
try {
|
|
2903
|
+
const key = JSON.parse(line)._key;
|
|
2904
|
+
return typeof key === 'string' ? [key] : [];
|
|
2905
|
+
} catch {
|
|
2906
|
+
return [];
|
|
2907
|
+
}
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
|
|
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
|
+
*/
|
|
2917
|
+
class DedupePolicy {
|
|
2918
|
+
scope;
|
|
2919
|
+
shared = new Set();
|
|
2920
|
+
constructor(scope = 'run') {
|
|
2921
|
+
this.scope = scope;
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* The de-duplication one input recipe run uses.
|
|
2925
|
+
*
|
|
2926
|
+
* @returns Its view: keys shared with the run, its own, or none checked.
|
|
2927
|
+
*/
|
|
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
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
/**
|
|
2945
|
+
* Evaluates a JSONPath expression on a decoded JSON document.
|
|
2946
|
+
*
|
|
2947
|
+
* @param document - The JSON value.
|
|
2948
|
+
* @param path - A JSONPath such as `$.items[*].url`.
|
|
2949
|
+
* @returns Every match, in document order.
|
|
2950
|
+
*/
|
|
2951
|
+
function selectJson(document, path) {
|
|
2952
|
+
return JSONPath({
|
|
2953
|
+
path,
|
|
2954
|
+
json: document,
|
|
2955
|
+
wrap: true,
|
|
2956
|
+
resultType: 'value'
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/**
|
|
2961
|
+
* Evaluates a regular expression on text: the extract kind for values that
|
|
2962
|
+
* live in inline scripts, attributes or prose rather than in elements or JSON.
|
|
2963
|
+
*
|
|
2964
|
+
* @param text - The document text.
|
|
2965
|
+
* @param pattern - A regular expression source; group 1 is returned when the
|
|
2966
|
+
* pattern has a capturing group, else the whole match.
|
|
2967
|
+
* @returns Every match, in document order.
|
|
2968
|
+
* @throws When the pattern is not a valid regular expression.
|
|
2969
|
+
*/
|
|
2970
|
+
function selectRegex(text, pattern) {
|
|
2971
|
+
let expression;
|
|
2972
|
+
try {
|
|
2973
|
+
expression = new RegExp(pattern, 'gs');
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
throw new Error(`invalid pattern ${pattern}: ${error.message}`, {
|
|
2976
|
+
cause: error
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
const values = Array.from(text.matchAll(expression), match => match[1] ?? match[0]);
|
|
2980
|
+
return values;
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
const DOCUMENT = /^\s*(?:<!doctype|<html)/i;
|
|
2984
|
+
/**
|
|
2985
|
+
* Runs a CSS selector on static HTML. A whole document is parsed as one; anything
|
|
2986
|
+
* else (a table row, a list item taken with `take: "html"`) is parsed as a
|
|
2987
|
+
* fragment, so cells and rows outside a table survive instead of being dropped.
|
|
2988
|
+
*
|
|
2989
|
+
* @param html - The markup (a whole document or a fragment).
|
|
2990
|
+
* @param selector - A CSS selector.
|
|
2991
|
+
* @returns Every match, in document order.
|
|
2992
|
+
*/
|
|
2993
|
+
function selectHtml(html, selector) {
|
|
2994
|
+
const api = DOCUMENT.test(html) ? load(html) : load(html, undefined, false);
|
|
2995
|
+
return api(selector).map((_, element) => ({
|
|
2996
|
+
api,
|
|
2997
|
+
element: api(element)
|
|
2998
|
+
})).toArray();
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/**
|
|
3002
|
+
* The value of an HTML match.
|
|
3003
|
+
*
|
|
3004
|
+
* @param match - A selected element.
|
|
3005
|
+
* @param take - What to take; `text` collapses whitespace.
|
|
3006
|
+
* @returns The value; `undefined` for a missing attribute.
|
|
3007
|
+
*/
|
|
3008
|
+
function takeFromHtml(match, take) {
|
|
3009
|
+
if (take === 'text') return collapse(match.element.text());
|
|
3010
|
+
if (take === 'html') return match.element.html() ?? '';
|
|
3011
|
+
if (take === 'value') return match.element.val() ?? match.element.attr('value');
|
|
3012
|
+
if (take === 'json') return match.api.html(match.element);
|
|
3013
|
+
return match.element.attr(take.slice('attr:'.length));
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* The value of a JSON match.
|
|
3017
|
+
*
|
|
3018
|
+
* @param node - A JSONPath result.
|
|
3019
|
+
* @param take - `json` keeps the node; `text` stringifies scalars.
|
|
3020
|
+
* @returns The value.
|
|
3021
|
+
*/
|
|
3022
|
+
function takeFromJson(node, take) {
|
|
3023
|
+
if (take === 'json') return node;
|
|
3024
|
+
if (node === null || node === undefined) return undefined;
|
|
3025
|
+
if (typeof node === 'object') return JSON.stringify(node);
|
|
3026
|
+
return String(node);
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
3029
|
+
* Text as a human reads it: runs of whitespace collapsed, ends trimmed.
|
|
3030
|
+
*
|
|
3031
|
+
* @param text - Raw text content.
|
|
3032
|
+
* @returns The collapsed text.
|
|
3033
|
+
*/
|
|
3034
|
+
function collapse(text) {
|
|
3035
|
+
return text.replaceAll(/\s+/g, ' ').trim();
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
/**
|
|
3039
|
+
* JSON that arrives as text: a `<script type="application/ld+json">` body, a
|
|
3040
|
+
* `data-*` attribute, a fetched document read as text. Sites wrap JSON in
|
|
3041
|
+
* things that are not JSON: comment guards around inline JSON-LD, prefixes
|
|
3042
|
+
* that stop a page from loading an API as a script, a JSONP callback, an
|
|
3043
|
+
* assignment in an inline script. Those wrappers are removed, but only after
|
|
3044
|
+
* the text failed to parse as it is, and what is left must still be strict
|
|
3045
|
+
* JSON: nothing is evaluated.
|
|
3046
|
+
*/
|
|
3047
|
+
/** Comment guards sites wrap inline JSON-LD in: a CDATA marker inside a block comment, or an HTML comment. */
|
|
3048
|
+
const GUARDS = /^\s*(?:\/\*\s*<!\[CDATA\[\s*\*\/|<!\[CDATA\[|<!--)\s*|\s*(?:\/\*\s*\]\]>\s*\*\/|\]\]>|-->)\s*$/g;
|
|
3049
|
+
/** Anti-hijacking prefixes: `)]}'` (with or without a comma), `while(1);`, `for(;;);`. */
|
|
3050
|
+
const XSSI_PREFIX = /^\s*(?:\)\]\}'\s*,?|while\s*\(\s*1\s*\)\s*;|for\s*\(\s*;\s*;\s*\)\s*;)/;
|
|
3051
|
+
/** A JSONP call: `callback({...});`, the callback an identifier path. */
|
|
3052
|
+
const JSONP = /^\s*[$A-Z_][\w$]*(?:\.[$A-Z_][\w$]*)*\s*\(([\s\S]*)\)\s*(?:;\s*)?$/i;
|
|
3053
|
+
/** An assignment in an inline script: `window.__STATE__ = {...};`, with `var`, `let` or `const` or none. */
|
|
3054
|
+
const ASSIGNMENT = /^\s*(?:(?:var|let|const)\s+)?[$A-Z_a-z][\w$]*(?:\.[$A-Z_a-z][\w$]*|\[["'][^"']*["']\])*\s*=([\s\S]*)$/;
|
|
3055
|
+
/**
|
|
3056
|
+
* Parses text as JSON, or as JSON inside one of the wrappers sites put around
|
|
3057
|
+
* it: comment guards, an anti-hijacking prefix, a JSONP call, an assignment.
|
|
3058
|
+
* Valid JSON is always read as it is; a wrapper is only removed when that
|
|
3059
|
+
* fails.
|
|
3060
|
+
*
|
|
3061
|
+
* @param text - The text.
|
|
3062
|
+
* @returns The value, or the error the text as it is gave.
|
|
3063
|
+
*/
|
|
3064
|
+
function parseJsonLike(text) {
|
|
3065
|
+
const direct = parseJson$1(text);
|
|
3066
|
+
if ('value' in direct) return direct;
|
|
3067
|
+
const unguarded = text.replaceAll(GUARDS, '');
|
|
3068
|
+
const assigned = ASSIGNMENT.exec(unguarded)?.[1].trim().replace(/;$/, '');
|
|
3069
|
+
const candidates = [unguarded, unguarded.replace(XSSI_PREFIX, ''), JSONP.exec(unguarded)?.[1], assigned];
|
|
3070
|
+
for (const candidate of candidates) {
|
|
3071
|
+
if (candidate === undefined || candidate === text) continue;
|
|
3072
|
+
const parsed = parseJson$1(candidate);
|
|
3073
|
+
if ('value' in parsed) return parsed;
|
|
3074
|
+
}
|
|
3075
|
+
return direct;
|
|
3076
|
+
}
|
|
3077
|
+
/**
|
|
3078
|
+
* Parses JSON Lines (NDJSON): one JSON value per non-blank line.
|
|
3079
|
+
*
|
|
3080
|
+
* @param text - The text.
|
|
3081
|
+
* @param source - Where it came from, for the error.
|
|
3082
|
+
* @returns The values, in order.
|
|
3083
|
+
* @throws Error naming the source and the line that does not parse.
|
|
3084
|
+
*/
|
|
3085
|
+
function parseJsonLines(text, source) {
|
|
3086
|
+
const values = [];
|
|
3087
|
+
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
|
3088
|
+
if (line.trim() === '') continue;
|
|
3089
|
+
const parsed = parseJson$1(line);
|
|
3090
|
+
if ('error' in parsed) throw new Error(`${source}: line ${index + 1} is not JSON (${parsed.error.message})`, {
|
|
3091
|
+
cause: parsed.error
|
|
3092
|
+
});
|
|
3093
|
+
values.push(parsed.value);
|
|
3094
|
+
}
|
|
3095
|
+
return values;
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Parses text as JSON, wrappers removed (see {@link parseJsonLike}).
|
|
3099
|
+
*
|
|
3100
|
+
* @param text - The text.
|
|
3101
|
+
* @returns The value, or `undefined` when it is not JSON.
|
|
3102
|
+
*/
|
|
3103
|
+
function tryParseJson(text) {
|
|
3104
|
+
const parsed = parseJsonLike(text);
|
|
3105
|
+
return 'value' in parsed ? parsed.value : undefined;
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Parses text that must be JSON.
|
|
3109
|
+
*
|
|
3110
|
+
* @param text - The text.
|
|
3111
|
+
* @param id - What the text is, for the error.
|
|
3112
|
+
* @returns The value.
|
|
3113
|
+
* @throws Error when it is not JSON.
|
|
3114
|
+
*/
|
|
3115
|
+
function parseJsonText(text, id) {
|
|
3116
|
+
const parsed = tryParseJson(text);
|
|
3117
|
+
if (parsed === undefined) throw new Error(`"${id}" is text but not JSON`);
|
|
3118
|
+
return parsed;
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* The data a value holds, whatever shape it arrived in: JSON text is parsed, a
|
|
3122
|
+
* list of texts becomes the list of its parsable entries, and an entry that
|
|
3123
|
+
* parses to a list is spliced in. Data that is not text is kept as is.
|
|
3124
|
+
*
|
|
3125
|
+
* @param value - A bound value: data, text, or a list of either.
|
|
3126
|
+
* @returns A list of items.
|
|
3127
|
+
*/
|
|
3128
|
+
function dataItemsOf(value) {
|
|
3129
|
+
if (typeof value === 'string') return itemsOf(tryParseJson(value));
|
|
3130
|
+
if (Array.isArray(value)) return value.flatMap(entry => typeof entry === 'string' ? itemsOf(tryParseJson(entry)) : [entry]);
|
|
3131
|
+
return itemsOf(value);
|
|
3132
|
+
}
|
|
3133
|
+
function itemsOf(parsed) {
|
|
3134
|
+
if (parsed === undefined || parsed === null) return [];
|
|
3135
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
3136
|
+
}
|
|
3137
|
+
function parseJson$1(text) {
|
|
3138
|
+
try {
|
|
3139
|
+
return {
|
|
3140
|
+
value: JSON.parse(text)
|
|
3141
|
+
};
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
return {
|
|
3144
|
+
error: error
|
|
3145
|
+
};
|
|
2073
3146
|
}
|
|
2074
3147
|
}
|
|
2075
3148
|
|
|
2076
|
-
/** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
|
|
2077
|
-
const DEFAULT_BLOCK_RULE = {
|
|
2078
|
-
status: [403, 429],
|
|
2079
|
-
header: {
|
|
2080
|
-
'x-amzn-waf-action': 'challenge'
|
|
2081
|
-
}
|
|
2082
|
-
};
|
|
2083
3149
|
/**
|
|
2084
|
-
*
|
|
3150
|
+
* The text a `regex` extract reads: per visible slide, its title, its text
|
|
3151
|
+
* boxes in reading order, its tables' rows (cells separated by a tab) and its
|
|
3152
|
+
* notes after `Notes:`; slides separated by a blank line.
|
|
2085
3153
|
*
|
|
2086
|
-
* @param
|
|
2087
|
-
* @
|
|
2088
|
-
* @returns The error to throw, or `undefined` when the response is not a block.
|
|
3154
|
+
* @param document - The deck.
|
|
3155
|
+
* @returns The text.
|
|
2089
3156
|
*/
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
3157
|
+
function deckText(document) {
|
|
3158
|
+
return document.slides.filter(slide => !slide.hidden).map(slide => [...slide.shapes.map(shape => shape.text), ...slide.tables.flatMap(table => table.rows.map(row => row.map(String).join('\t'))), ...(slide.notes === '' ? [] : [`Notes: ${slide.notes}`])].join('\n')).join('\n\n');
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* Whether a value bound in scope is a read deck (so `extract … from` can take it).
|
|
3162
|
+
*
|
|
3163
|
+
* @param value - Anything.
|
|
3164
|
+
* @returns Whether it is a {@link DeckDocument}.
|
|
3165
|
+
*/
|
|
3166
|
+
function isDeckDocument(value) {
|
|
3167
|
+
return typeof value === 'object' && value !== null && value.kind === 'deck' && Array.isArray(value.slides);
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
/**
|
|
3171
|
+
* Reads a `.pptx` presentation into a deck document, through
|
|
3172
|
+
* `@opencraw/office-reader`: every slide's text boxes with their positions,
|
|
3173
|
+
* its tables with their merged cells, its charts' cached data and its notes.
|
|
3174
|
+
* The reader is imported on first use, so recipes that never read a
|
|
3175
|
+
* presentation never load it.
|
|
3176
|
+
*
|
|
3177
|
+
* @param bytes - The file.
|
|
3178
|
+
* @param source - Where it came from, for messages.
|
|
3179
|
+
* @returns The deck.
|
|
3180
|
+
* @throws Error naming the source, and saying what to do, for a file that is
|
|
3181
|
+
* not a readable presentation (a legacy `.ppt`, a password-protected file, an `.odp`…).
|
|
3182
|
+
*/
|
|
3183
|
+
async function readPptxDeck(bytes, source) {
|
|
3184
|
+
const {
|
|
3185
|
+
readPptx,
|
|
3186
|
+
OfficeReadError
|
|
3187
|
+
} = await import('@opencraw/office-reader/pptx');
|
|
3188
|
+
try {
|
|
3189
|
+
const deck = await readPptx(bytes);
|
|
3190
|
+
return {
|
|
3191
|
+
kind: 'deck',
|
|
3192
|
+
width: deck.width,
|
|
3193
|
+
height: deck.height,
|
|
3194
|
+
slides: deck.slides.map(slide => ({
|
|
3195
|
+
...slide,
|
|
3196
|
+
tables: slide.tables.map(table => ({
|
|
3197
|
+
name: table.name,
|
|
3198
|
+
rows: table.rows,
|
|
3199
|
+
merges: table.merges
|
|
3200
|
+
}))
|
|
3201
|
+
}))
|
|
3202
|
+
};
|
|
3203
|
+
} catch (error) {
|
|
3204
|
+
if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
|
|
3205
|
+
cause: error
|
|
3206
|
+
});
|
|
3207
|
+
throw error;
|
|
2106
3208
|
}
|
|
2107
|
-
return undefined;
|
|
2108
3209
|
}
|
|
2109
3210
|
|
|
2110
3211
|
/** Runs closer than this share of the font size join into one cell. */
|
|
@@ -2128,7 +3229,17 @@ const ROW_OVERLAP = 0.4;
|
|
|
2128
3229
|
* @returns The rows.
|
|
2129
3230
|
*/
|
|
2130
3231
|
function assembleRows(runs) {
|
|
2131
|
-
|
|
3232
|
+
return rowsOfCells(joinCells(runs));
|
|
3233
|
+
}
|
|
3234
|
+
/**
|
|
3235
|
+
* Groups finished cells into rows, top to bottom: cells whose vertical extents
|
|
3236
|
+
* overlap share a row. For cells that need no joining, such as a slide's text
|
|
3237
|
+
* boxes, each already a cell.
|
|
3238
|
+
*
|
|
3239
|
+
* @param cells - The cells, in any order.
|
|
3240
|
+
* @returns The rows.
|
|
3241
|
+
*/
|
|
3242
|
+
function rowsOfCells(cells) {
|
|
2132
3243
|
const ordered = [...cells].sort((a, b) => middle(b) - middle(a) || a.x - b.x);
|
|
2133
3244
|
const rows = [];
|
|
2134
3245
|
let top = 0;
|
|
@@ -2305,7 +3416,7 @@ function isPdfDocument(value) {
|
|
|
2305
3416
|
function findTables(document, query) {
|
|
2306
3417
|
const tables = [];
|
|
2307
3418
|
for (const page of document.pages) {
|
|
2308
|
-
const starts = page.rows.flatMap((row, index) => query.header.test(plain(row)) ? [index] : []);
|
|
3419
|
+
const starts = page.rows.flatMap((row, index) => query.header.test(plain$1(row)) ? [index] : []);
|
|
2309
3420
|
for (const [position, start] of starts.entries()) {
|
|
2310
3421
|
const body = bodyOf(page.rows.slice(start + 1, starts[position + 1] ?? page.rows.length), query.until);
|
|
2311
3422
|
tables.push(readTable(page.number, page.rows[start], body, query));
|
|
@@ -2315,7 +3426,7 @@ function findTables(document, query) {
|
|
|
2315
3426
|
}
|
|
2316
3427
|
/** The rows under a header, up to the first one `until` matches. */
|
|
2317
3428
|
function bodyOf(rows, until) {
|
|
2318
|
-
const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain(row)));
|
|
3429
|
+
const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain$1(row)));
|
|
2319
3430
|
return end === -1 ? [...rows] : rows.slice(0, end);
|
|
2320
3431
|
}
|
|
2321
3432
|
function readTable(page, headerRow, body, query) {
|
|
@@ -2330,7 +3441,7 @@ function readTable(page, headerRow, body, query) {
|
|
|
2330
3441
|
page,
|
|
2331
3442
|
title: headers[0]?.text ?? '',
|
|
2332
3443
|
header: headers.map(header => header.text),
|
|
2333
|
-
rows: groups.map(group => named(joinLines(group, headers.length), headers, query.columns))
|
|
3444
|
+
rows: groups.map(group => named$1(joinLines(group, headers.length), headers, query.columns))
|
|
2334
3445
|
};
|
|
2335
3446
|
}
|
|
2336
3447
|
/**
|
|
@@ -2498,53 +3609,585 @@ function valuesOf(row, bands, width) {
|
|
|
2498
3609
|
}
|
|
2499
3610
|
return values;
|
|
2500
3611
|
}
|
|
2501
|
-
function named(values, headers, columns) {
|
|
2502
|
-
if (columns === undefined) return Object.fromEntries(headers.map((header, index) => [header.text, values[index]]));
|
|
2503
|
-
const record = {};
|
|
2504
|
-
for (const [key, pattern] of Object.entries(columns)) {
|
|
2505
|
-
const index = headers.findIndex(header => pattern.test(header.text));
|
|
2506
|
-
if (index !== -1) record[key] = values[index];
|
|
2507
|
-
}
|
|
2508
|
-
return record;
|
|
3612
|
+
function named$1(values, headers, columns) {
|
|
3613
|
+
if (columns === undefined) return Object.fromEntries(headers.map((header, index) => [header.text, values[index]]));
|
|
3614
|
+
const record = {};
|
|
3615
|
+
for (const [key, pattern] of Object.entries(columns)) {
|
|
3616
|
+
const index = headers.findIndex(header => pattern.test(header.text));
|
|
3617
|
+
if (index !== -1) record[key] = values[index];
|
|
3618
|
+
}
|
|
3619
|
+
return record;
|
|
3620
|
+
}
|
|
3621
|
+
/** Distances closer than this, in points, are a tie. */
|
|
3622
|
+
const TIE = 1;
|
|
3623
|
+
/**
|
|
3624
|
+
* The anchor a line belongs to: the nearest by vertical gap. On a tie (evenly
|
|
3625
|
+
* spaced lines) the anchor below wins: text reads top down, so a wrapped
|
|
3626
|
+
* cell's first line comes before the row it belongs to.
|
|
3627
|
+
*/
|
|
3628
|
+
function nearest(line, candidates) {
|
|
3629
|
+
const row = line.row;
|
|
3630
|
+
const gap = candidate => Math.max(0, candidate.bottom - row.top, row.bottom - candidate.top);
|
|
3631
|
+
const [first, ...rest] = candidates;
|
|
3632
|
+
if (first === undefined) throw new Error('no row to attach a line to');
|
|
3633
|
+
let best = first;
|
|
3634
|
+
for (const candidate of rest) {
|
|
3635
|
+
const difference = gap(candidate.row) - gap(best.row);
|
|
3636
|
+
if (difference < -TIE || Math.abs(difference) <= TIE && candidate.row.bottom < best.row.bottom) best = candidate;
|
|
3637
|
+
}
|
|
3638
|
+
return best;
|
|
3639
|
+
}
|
|
3640
|
+
function overlapOf(header, band) {
|
|
3641
|
+
return Math.max(0, Math.min(header.x + header.width, band.end) - Math.max(header.x, band.start));
|
|
3642
|
+
}
|
|
3643
|
+
function plain$1(row) {
|
|
3644
|
+
return row.cells.map(cell => cell.text).join(' ');
|
|
3645
|
+
}
|
|
3646
|
+
function joinText(first, second) {
|
|
3647
|
+
return first === '' ? second : second === '' ? first : `${first} ${second}`;
|
|
3648
|
+
}
|
|
3649
|
+
function median(values) {
|
|
3650
|
+
const ordered = [...values].sort((a, b) => a - b);
|
|
3651
|
+
return ordered[Math.floor(ordered.length / 2)] ?? 0;
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
/**
|
|
3655
|
+
* The text a `regex` extract reads: the visible rows of the visible sheets,
|
|
3656
|
+
* cells separated by a tab, sheets separated by a blank line.
|
|
3657
|
+
*
|
|
3658
|
+
* @param document - The workbook.
|
|
3659
|
+
* @returns The text.
|
|
3660
|
+
*/
|
|
3661
|
+
function workbookText(document) {
|
|
3662
|
+
return document.sheets.filter(sheet => sheet.hidden !== true).map(sheet => visibleRows(sheet).map(row => row.map(String).join('\t')).join('\n')).join('\n\n');
|
|
3663
|
+
}
|
|
3664
|
+
/**
|
|
3665
|
+
* Whether a value bound in scope is a read workbook (so `extract … from` can take it).
|
|
3666
|
+
*
|
|
3667
|
+
* @param value - Anything.
|
|
3668
|
+
* @returns Whether it is a {@link WorkbookDocument}.
|
|
3669
|
+
*/
|
|
3670
|
+
function isWorkbookDocument(value) {
|
|
3671
|
+
return typeof value === 'object' && value !== null && value.kind === 'workbook' && Array.isArray(value.sheets);
|
|
3672
|
+
}
|
|
3673
|
+
function visibleRows(sheet) {
|
|
3674
|
+
if (sheet.hiddenRows === undefined || sheet.hiddenRows.length === 0) return sheet.rows;
|
|
3675
|
+
const hidden = new Set(sheet.hiddenRows);
|
|
3676
|
+
return sheet.rows.filter((_row, index) => !hidden.has(index));
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
/** The delimiters detection chooses between, in order of preference on a tie. */
|
|
3680
|
+
const CSV_DELIMITERS = [',', ';', '\t', '|'];
|
|
3681
|
+
/** How much of a file delimiter detection looks at. */
|
|
3682
|
+
const SAMPLE_CHARS = 64 * 1024;
|
|
3683
|
+
/** How many lines of the sample delimiter detection scores. */
|
|
3684
|
+
const SAMPLE_LINES = 100;
|
|
3685
|
+
/**
|
|
3686
|
+
* Parses CSV text (RFC 4180, tolerant): a field in double quotes may hold the
|
|
3687
|
+
* delimiter, line breaks and `""` for a quote; a quote inside an unquoted
|
|
3688
|
+
* field is taken literally (`1.0 Hybrid "Cross"`); CRLF, LF and CR all end a
|
|
3689
|
+
* record. Rows are kept as read: ragged rows stay ragged, nothing is trimmed.
|
|
3690
|
+
*
|
|
3691
|
+
* @param text - The decoded file.
|
|
3692
|
+
* @param delimiter - One character.
|
|
3693
|
+
* @returns The rows; a trailing empty line adds no row.
|
|
3694
|
+
*/
|
|
3695
|
+
function parseCsv(text, delimiter) {
|
|
3696
|
+
const rows = [];
|
|
3697
|
+
let row = [];
|
|
3698
|
+
let field = '';
|
|
3699
|
+
let index = 0;
|
|
3700
|
+
while (index < text.length) {
|
|
3701
|
+
const char = text[index];
|
|
3702
|
+
if (char === '"' && field === '') {
|
|
3703
|
+
const quoted = readQuoted(text, index + 1);
|
|
3704
|
+
field = quoted.value;
|
|
3705
|
+
index = quoted.next;
|
|
3706
|
+
} else if (char === delimiter) {
|
|
3707
|
+
row.push(field);
|
|
3708
|
+
field = '';
|
|
3709
|
+
index += 1;
|
|
3710
|
+
} else if (char === '\n' || char === '\r') {
|
|
3711
|
+
row.push(field);
|
|
3712
|
+
rows.push(row);
|
|
3713
|
+
row = [];
|
|
3714
|
+
field = '';
|
|
3715
|
+
index += char === '\r' && text[index + 1] === '\n' ? 2 : 1;
|
|
3716
|
+
} else {
|
|
3717
|
+
const end = plainEnd(text, index + 1, delimiter);
|
|
3718
|
+
field += text.slice(index, end);
|
|
3719
|
+
index = end;
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
if (field !== '' || row.length > 0) {
|
|
3723
|
+
row.push(field);
|
|
3724
|
+
rows.push(row);
|
|
3725
|
+
}
|
|
3726
|
+
return rows;
|
|
3727
|
+
}
|
|
3728
|
+
/**
|
|
3729
|
+
* Chooses the delimiter of a CSV: the candidate whose field count (above one)
|
|
3730
|
+
* is the most consistent over the first lines, so a title line or two above
|
|
3731
|
+
* the header does not mislead it. A file of one column gets `,`.
|
|
3732
|
+
*
|
|
3733
|
+
* `;` with decimal commas (`Panda;15.950,00`), the usual European export,
|
|
3734
|
+
* scores `;`: a comma split gives rows of uneven width.
|
|
3735
|
+
*
|
|
3736
|
+
* @param text - The decoded file.
|
|
3737
|
+
* @returns The delimiter.
|
|
3738
|
+
*/
|
|
3739
|
+
function detectDelimiter(text) {
|
|
3740
|
+
const sample = text.slice(0, SAMPLE_CHARS);
|
|
3741
|
+
const truncated = text.length > SAMPLE_CHARS;
|
|
3742
|
+
let best = {
|
|
3743
|
+
delimiter: ',',
|
|
3744
|
+
score: 0
|
|
3745
|
+
};
|
|
3746
|
+
for (const delimiter of CSV_DELIMITERS) {
|
|
3747
|
+
const rows = parseCsv(sample, delimiter).slice(0, SAMPLE_LINES).filter(row => row.length > 1 || row[0] !== '');
|
|
3748
|
+
// The sample may cut the last line short.
|
|
3749
|
+
if (truncated && rows.length > 1) rows.pop();
|
|
3750
|
+
const score = consistency(rows);
|
|
3751
|
+
if (score > best.score) best = {
|
|
3752
|
+
delimiter,
|
|
3753
|
+
score
|
|
3754
|
+
};
|
|
3755
|
+
}
|
|
3756
|
+
return best.delimiter;
|
|
3757
|
+
}
|
|
3758
|
+
/** The share of rows with the most common width above one, with that width breaking ties; 0 when no row splits. */
|
|
3759
|
+
function consistency(rows) {
|
|
3760
|
+
const counts = new Map();
|
|
3761
|
+
for (const row of rows) counts.set(row.length, (counts.get(row.length) ?? 0) + 1);
|
|
3762
|
+
let width = 1;
|
|
3763
|
+
let agreeing = 0;
|
|
3764
|
+
for (const [length, count] of counts) {
|
|
3765
|
+
if (!(length > 1 && (count > agreeing || count === agreeing && length > width))) {
|
|
3766
|
+
continue;
|
|
3767
|
+
}
|
|
3768
|
+
width = length;
|
|
3769
|
+
agreeing = count;
|
|
3770
|
+
}
|
|
3771
|
+
return width > 1 ? agreeing / rows.length * 1000 + width : 0;
|
|
3772
|
+
}
|
|
3773
|
+
/** Reads a quoted field starting after its opening quote. */
|
|
3774
|
+
function readQuoted(text, start) {
|
|
3775
|
+
let value = '';
|
|
3776
|
+
let index = start;
|
|
3777
|
+
for (;;) {
|
|
3778
|
+
const quote = text.indexOf('"', index);
|
|
3779
|
+
if (quote === -1) return {
|
|
3780
|
+
value: value + text.slice(index),
|
|
3781
|
+
next: text.length
|
|
3782
|
+
};
|
|
3783
|
+
value += text.slice(index, quote);
|
|
3784
|
+
if (text[quote + 1] !== '"') return {
|
|
3785
|
+
value,
|
|
3786
|
+
next: quote + 1
|
|
3787
|
+
};
|
|
3788
|
+
value += '"';
|
|
3789
|
+
index = quote + 2;
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
/** Where a run of plain characters (no delimiter, no line break) ends. */
|
|
3793
|
+
function plainEnd(text, start, delimiter) {
|
|
3794
|
+
const stops = new Set([delimiter, '\n', '\r']);
|
|
3795
|
+
let index = start;
|
|
3796
|
+
while (index < text.length) {
|
|
3797
|
+
if (stops.has(text[index])) break;
|
|
3798
|
+
index += 1;
|
|
3799
|
+
}
|
|
3800
|
+
return index;
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
/**
|
|
3804
|
+
* Reads decoded CSV text into a workbook of one sheet, named after the file.
|
|
3805
|
+
*
|
|
3806
|
+
* @param text - The decoded file.
|
|
3807
|
+
* @param options - The sheet name, the encoding it was decoded from (for a
|
|
3808
|
+
* probe to report) and a delimiter; without one it is detected.
|
|
3809
|
+
* @returns The workbook.
|
|
3810
|
+
* @throws Error when the delimiter given is not one character.
|
|
3811
|
+
*/
|
|
3812
|
+
function csvWorkbook(text, options) {
|
|
3813
|
+
if (options.delimiter !== undefined && [...options.delimiter].length !== 1) throw new Error(`a CSV delimiter is one character; got "${options.delimiter}"`);
|
|
3814
|
+
const delimiter = options.delimiter ?? detectDelimiter(text);
|
|
3815
|
+
return {
|
|
3816
|
+
kind: 'workbook',
|
|
3817
|
+
sheets: [{
|
|
3818
|
+
name: options.name,
|
|
3819
|
+
rows: parseCsv(text, delimiter)
|
|
3820
|
+
}],
|
|
3821
|
+
csv: {
|
|
3822
|
+
encoding: options.encoding,
|
|
3823
|
+
delimiter
|
|
3824
|
+
}
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* The name a CSV's sheet takes: the file name without its extension
|
|
3829
|
+
* (`…/prezzo_alle_8.csv` → `prezzo_alle_8`), else `csv`.
|
|
3830
|
+
*
|
|
3831
|
+
* @param url - Where the file came from.
|
|
3832
|
+
* @returns The name.
|
|
3833
|
+
*/
|
|
3834
|
+
function sheetNameOf(url) {
|
|
3835
|
+
let path;
|
|
3836
|
+
try {
|
|
3837
|
+
path = new URL(url).pathname;
|
|
3838
|
+
} catch {
|
|
3839
|
+
path = url;
|
|
3840
|
+
}
|
|
3841
|
+
const file = path.split('/').at(-1) ?? '';
|
|
3842
|
+
let name;
|
|
3843
|
+
try {
|
|
3844
|
+
name = decodeURIComponent(file);
|
|
3845
|
+
} catch {
|
|
3846
|
+
name = file;
|
|
3847
|
+
}
|
|
3848
|
+
name = name.replace(/\.[^.]*$/, '');
|
|
3849
|
+
return name === '' ? 'csv' : name;
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
/**
|
|
3853
|
+
* Reads an `.xlsx` workbook into a workbook document, through
|
|
3854
|
+
* `@opencraw/office-reader`: every worksheet's cells, with hidden sheets,
|
|
3855
|
+
* hidden rows and merged ranges. Numbers and booleans keep their type (a
|
|
3856
|
+
* cell's `13955.625` is unambiguous; as text, a locale guess could read it as
|
|
3857
|
+
* thirteen million), dates become ISO text, errors their text, empty cells
|
|
3858
|
+
* `''`. Formulas give their cached value. The reader is imported on first use,
|
|
3859
|
+
* so recipes that never read a spreadsheet never load it.
|
|
3860
|
+
*
|
|
3861
|
+
* @param bytes - The file.
|
|
3862
|
+
* @param source - Where it came from, for messages.
|
|
3863
|
+
* @returns The workbook.
|
|
3864
|
+
* @throws Error naming the source, and saying what to do, for a file that is
|
|
3865
|
+
* not a readable workbook (a legacy `.xls`, a password-protected file, an `.ods`…).
|
|
3866
|
+
*/
|
|
3867
|
+
async function readXlsxWorkbook(bytes, source) {
|
|
3868
|
+
const {
|
|
3869
|
+
readXlsx,
|
|
3870
|
+
OfficeReadError
|
|
3871
|
+
} = await import('@opencraw/office-reader/xlsx');
|
|
3872
|
+
try {
|
|
3873
|
+
const book = await readXlsx(bytes);
|
|
3874
|
+
return {
|
|
3875
|
+
kind: 'workbook',
|
|
3876
|
+
sheets: book.sheets.map(sheet => ({
|
|
3877
|
+
name: sheet.name,
|
|
3878
|
+
rows: sheet.rows.map(row => row.map(cell => workbookCell(cell))),
|
|
3879
|
+
hidden: sheet.hidden,
|
|
3880
|
+
hiddenRows: sheet.hiddenRows,
|
|
3881
|
+
merges: sheet.merges
|
|
3882
|
+
}))
|
|
3883
|
+
};
|
|
3884
|
+
} catch (error) {
|
|
3885
|
+
if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
|
|
3886
|
+
cause: error
|
|
3887
|
+
});
|
|
3888
|
+
throw error;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
/** A typed spreadsheet value as a workbook cell. */
|
|
3892
|
+
function workbookCell(value) {
|
|
3893
|
+
if (value === null) return '';
|
|
3894
|
+
if (value instanceof Date) return isoText(value);
|
|
3895
|
+
if (typeof value === 'object') return value.error;
|
|
3896
|
+
return value;
|
|
3897
|
+
}
|
|
3898
|
+
/** A date as ISO text: the day alone at midnight, the time alone for a time of day (Excel's day zero, 1899), both otherwise. */
|
|
3899
|
+
function isoText(date) {
|
|
3900
|
+
const iso = date.toISOString();
|
|
3901
|
+
if (date.getUTCFullYear() < 1900) return iso.slice(11, 19);
|
|
3902
|
+
return iso.slice(11, 19) === '00:00:00' ? iso.slice(0, 10) : iso.slice(0, 19);
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/**
|
|
3906
|
+
* Finds every table whose header row matches, in every sheet the query
|
|
3907
|
+
* selects, and reads its rows by column. Unlike a PDF, a grid needs no
|
|
3908
|
+
* geometry: column *i* of a row belongs to header *i*.
|
|
3909
|
+
*
|
|
3910
|
+
* Merged ranges are filled first (the file stores their value in the top-left
|
|
3911
|
+
* cell only), so a brand merged down its models' rows reads on every row, and
|
|
3912
|
+
* a group header merged across its sub-columns names each of them. Empty rows
|
|
3913
|
+
* are skipped.
|
|
3914
|
+
*
|
|
3915
|
+
* @param document - The workbook.
|
|
3916
|
+
* @param query - Which tables, and how to name their columns.
|
|
3917
|
+
* @returns The tables, sheet by sheet, top to bottom.
|
|
3918
|
+
*/
|
|
3919
|
+
function findGridTables(document, query) {
|
|
3920
|
+
const tables = [];
|
|
3921
|
+
for (const sheet of document.sheets) {
|
|
3922
|
+
if (sheet.hidden === true && query.includeHidden !== true) continue;
|
|
3923
|
+
if (query.sheet !== undefined && !query.sheet.test(sheet.name)) continue;
|
|
3924
|
+
tables.push(...sheetTables(sheet, query));
|
|
3925
|
+
}
|
|
3926
|
+
return tables;
|
|
3927
|
+
}
|
|
3928
|
+
/**
|
|
3929
|
+
* Fills blank cells in the given columns with the value of the row above,
|
|
3930
|
+
* within one table: pivot exports write a group's name on its first row only.
|
|
3931
|
+
*
|
|
3932
|
+
* @param rows - The table's rows, in order.
|
|
3933
|
+
* @param keys - The columns to fill.
|
|
3934
|
+
* @returns The rows, filled (new objects; the input is not changed).
|
|
3935
|
+
*/
|
|
3936
|
+
function fillDown(rows, keys) {
|
|
3937
|
+
const last = new Map();
|
|
3938
|
+
return rows.map(row => {
|
|
3939
|
+
const filled = {
|
|
3940
|
+
...row
|
|
3941
|
+
};
|
|
3942
|
+
for (const key of keys) {
|
|
3943
|
+
const value = filled[key];
|
|
3944
|
+
if (value === undefined || value === '') {
|
|
3945
|
+
const above = last.get(key);
|
|
3946
|
+
if (above !== undefined) filled[key] = above;
|
|
3947
|
+
} else {
|
|
3948
|
+
last.set(key, value);
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
return filled;
|
|
3952
|
+
});
|
|
3953
|
+
}
|
|
3954
|
+
function sheetTables(sheet, query) {
|
|
3955
|
+
const hidden = new Set(query.includeHidden === true ? [] : sheet.hiddenRows);
|
|
3956
|
+
const visible = sheet.rows.flatMap((_row, index) => hidden.has(index) ? [] : [index]);
|
|
3957
|
+
const grid = filledGrid(sheet);
|
|
3958
|
+
const plainRows = new Map(visible.map(index => [index, plain(sheet.rows[index])]));
|
|
3959
|
+
const starts = visible.filter(index => plainRows.get(index) !== '' && query.header.test(plainRows.get(index) ?? ''));
|
|
3960
|
+
const headerRows = query.headerRows ?? 1;
|
|
3961
|
+
return starts.map((start, position) => {
|
|
3962
|
+
const at = visible.indexOf(start);
|
|
3963
|
+
const headerIndexes = visible.slice(at, at + headerRows);
|
|
3964
|
+
const next = starts[position + 1] ?? Infinity;
|
|
3965
|
+
const body = [];
|
|
3966
|
+
const below = visible.slice(at + headerRows);
|
|
3967
|
+
for (const index of below) {
|
|
3968
|
+
if (index >= next) break;
|
|
3969
|
+
if (query.until?.test(plainRows.get(index) ?? '') === true) break;
|
|
3970
|
+
if (plainRows.get(index) !== '') body.push(index);
|
|
3971
|
+
}
|
|
3972
|
+
const columns = columnsOf(grid, headerIndexes, body);
|
|
3973
|
+
const rows = body.map(index => named(grid[index] ?? [], columns, query.columns));
|
|
3974
|
+
return {
|
|
3975
|
+
sheet: sheet.name,
|
|
3976
|
+
title: sheet.rows[start].map(text => clean(text)).find(text => text !== '') ?? '',
|
|
3977
|
+
header: columns.map(column => column.key),
|
|
3978
|
+
rows: query.fillDown === undefined ? rows : fillDown(rows, query.fillDown)
|
|
3979
|
+
};
|
|
3980
|
+
});
|
|
3981
|
+
}
|
|
3982
|
+
/**
|
|
3983
|
+
* The table's columns: each column's key joins the distinct texts its header
|
|
3984
|
+
* rows hold (`Insgesamt` over `August 2026` gives `Insgesamt August 2026`). A
|
|
3985
|
+
* column with no header text but data below is keyed by its letter (`A`); one
|
|
3986
|
+
* with neither is dropped. A key seen before gets a counter (`Price 2`).
|
|
3987
|
+
*/
|
|
3988
|
+
function columnsOf(grid, headerIndexes, body) {
|
|
3989
|
+
const width = Math.max(0, ...[...headerIndexes, ...body].map(index => grid[index]?.length ?? 0));
|
|
3990
|
+
const seen = new Map();
|
|
3991
|
+
const columns = [];
|
|
3992
|
+
for (let index = 0; index < width; index += 1) {
|
|
3993
|
+
const parts = [];
|
|
3994
|
+
for (const row of headerIndexes) {
|
|
3995
|
+
const text = clean(grid[row]?.[index] ?? '');
|
|
3996
|
+
if (text !== '' && !parts.includes(text)) parts.push(text);
|
|
3997
|
+
}
|
|
3998
|
+
const hasData = body.some(row => clean(grid[row]?.[index] ?? '') !== '');
|
|
3999
|
+
if (!hasData && parts.length === 0) continue;
|
|
4000
|
+
const base = parts.length === 0 ? columnLetter(index) : parts.join(' ');
|
|
4001
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
4002
|
+
seen.set(base, count);
|
|
4003
|
+
columns.push({
|
|
4004
|
+
index,
|
|
4005
|
+
key: count === 1 ? base : `${base} ${count}`
|
|
4006
|
+
});
|
|
4007
|
+
}
|
|
4008
|
+
return columns;
|
|
4009
|
+
}
|
|
4010
|
+
function named(row, columns, patterns) {
|
|
4011
|
+
const value = column => {
|
|
4012
|
+
const cell = row[column.index] ?? '';
|
|
4013
|
+
return typeof cell === 'string' ? cell.trim() : cell;
|
|
4014
|
+
};
|
|
4015
|
+
if (patterns === undefined) return Object.fromEntries(columns.map(column => [column.key, value(column)]));
|
|
4016
|
+
const record = {};
|
|
4017
|
+
for (const [key, pattern] of Object.entries(patterns)) {
|
|
4018
|
+
const column = columns.find(candidate => pattern.test(candidate.key));
|
|
4019
|
+
if (column !== undefined) record[key] = value(column);
|
|
4020
|
+
}
|
|
4021
|
+
return record;
|
|
4022
|
+
}
|
|
4023
|
+
/** The sheet's rows with every merged range's value copied into the cells it covers. */
|
|
4024
|
+
function filledGrid(sheet) {
|
|
4025
|
+
if (sheet.merges === undefined || sheet.merges.length === 0) return sheet.rows;
|
|
4026
|
+
const grid = sheet.rows.map(row => [...row]);
|
|
4027
|
+
for (const reference of sheet.merges) {
|
|
4028
|
+
const range = rangeOf(reference);
|
|
4029
|
+
if (range === undefined) continue;
|
|
4030
|
+
const value = sheet.rows[range.top]?.[range.left] ?? '';
|
|
4031
|
+
for (let row = range.top; row <= range.bottom; row += 1) {
|
|
4032
|
+
grid[row] ??= [];
|
|
4033
|
+
for (let column = range.left; column <= range.right; column += 1) grid[row][column] = value;
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
return grid;
|
|
4037
|
+
}
|
|
4038
|
+
/** `B10:B13` as 0-based bounds; `undefined` for anything else. */
|
|
4039
|
+
function rangeOf(reference) {
|
|
4040
|
+
const [from, to = from] = reference.split(':', 2);
|
|
4041
|
+
const start = cellOf(from);
|
|
4042
|
+
const end = cellOf(to);
|
|
4043
|
+
if (start === undefined || end === undefined) return undefined;
|
|
4044
|
+
return {
|
|
4045
|
+
top: Math.min(start.row, end.row),
|
|
4046
|
+
left: Math.min(start.column, end.column),
|
|
4047
|
+
bottom: Math.max(start.row, end.row),
|
|
4048
|
+
right: Math.max(start.column, end.column)
|
|
4049
|
+
};
|
|
4050
|
+
}
|
|
4051
|
+
function cellOf(reference) {
|
|
4052
|
+
const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
|
|
4053
|
+
if (match === null) return undefined;
|
|
4054
|
+
const letters = match[1].toUpperCase();
|
|
4055
|
+
let column = 0;
|
|
4056
|
+
for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
|
|
4057
|
+
return {
|
|
4058
|
+
row: Number(match[2]) - 1,
|
|
4059
|
+
column: column - 1
|
|
4060
|
+
};
|
|
4061
|
+
}
|
|
4062
|
+
/** `0` → `A`, `25` → `Z`, `26` → `AA`. */
|
|
4063
|
+
function columnLetter(index) {
|
|
4064
|
+
let letters = '';
|
|
4065
|
+
for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) letters = String.fromCodePoint(65 + (rest - 1) % 26) + letters;
|
|
4066
|
+
return letters;
|
|
2509
4067
|
}
|
|
2510
|
-
/**
|
|
2511
|
-
|
|
4068
|
+
/** A row as a header pattern sees it: its non-empty cells, whitespace collapsed, joined by spaces. */
|
|
4069
|
+
function plain(row) {
|
|
4070
|
+
return (row ?? []).map(text => clean(text)).filter(text => text !== '').join(' ');
|
|
4071
|
+
}
|
|
4072
|
+
function clean(cell) {
|
|
4073
|
+
return String(cell).replaceAll(/\s+/g, ' ').trim();
|
|
4074
|
+
}
|
|
4075
|
+
|
|
2512
4076
|
/**
|
|
2513
|
-
*
|
|
2514
|
-
*
|
|
2515
|
-
*
|
|
4077
|
+
* Every `<table>` of an HTML document as a sheet (`table 1`, `table 2`…, in
|
|
4078
|
+
* document order), so the workbook table reader works on web pages and
|
|
4079
|
+
* rendered Markdown: rows in order (`thead`, `tbody`, `tfoot` alike), `th` and
|
|
4080
|
+
* `td` alike, cell text with whitespace collapsed, `colspan` and `rowspan` as
|
|
4081
|
+
* merged ranges. A table inside a table is a sheet of its own, and its rows
|
|
4082
|
+
* are not its parent's.
|
|
4083
|
+
*
|
|
4084
|
+
* @param html - The document.
|
|
4085
|
+
* @returns The tables.
|
|
2516
4086
|
*/
|
|
2517
|
-
function
|
|
2518
|
-
const
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
const
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
4087
|
+
function htmlTableSheets(html) {
|
|
4088
|
+
const $ = load(html);
|
|
4089
|
+
const tables = $('table').get();
|
|
4090
|
+
return tables.map((table, index) => {
|
|
4091
|
+
const all = $(table).find('tr').get();
|
|
4092
|
+
const rows = all.filter(row => $(row).closest('table').get(0) === table);
|
|
4093
|
+
const grid = [];
|
|
4094
|
+
const merges = [];
|
|
4095
|
+
for (const [rowIndex, row] of rows.entries()) {
|
|
4096
|
+
grid[rowIndex] ??= [];
|
|
4097
|
+
let column = 0;
|
|
4098
|
+
const cells = $(row).children('th, td').get();
|
|
4099
|
+
for (const cell of cells) {
|
|
4100
|
+
while (grid[rowIndex][column] !== undefined) column += 1;
|
|
4101
|
+
const columnSpan = span($(cell).attr('colspan'));
|
|
4102
|
+
const rowSpan = span($(cell).attr('rowspan'));
|
|
4103
|
+
for (let down = 0; down < rowSpan; down += 1) {
|
|
4104
|
+
grid[rowIndex + down] ??= [];
|
|
4105
|
+
for (let across = 0; across < columnSpan; across += 1) grid[rowIndex + down][column + across] = '';
|
|
4106
|
+
}
|
|
4107
|
+
grid[rowIndex][column] = $(cell).text().replaceAll(/\s+/g, ' ').trim();
|
|
4108
|
+
if (columnSpan > 1 || rowSpan > 1) merges.push(`${letter(column)}${rowIndex + 1}:${letter(column + columnSpan - 1)}${rowIndex + rowSpan}`);
|
|
4109
|
+
column += columnSpan;
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
return {
|
|
4113
|
+
name: `table ${index + 1}`,
|
|
4114
|
+
rows: grid.slice(0, rows.length).map(row => Array.from(row, cell => cell ?? '')),
|
|
4115
|
+
merges
|
|
4116
|
+
};
|
|
4117
|
+
});
|
|
2531
4118
|
}
|
|
2532
|
-
function
|
|
2533
|
-
|
|
4119
|
+
function span(value) {
|
|
4120
|
+
const number = Math.trunc(Number(value ?? '1'));
|
|
4121
|
+
return Number.isFinite(number) && number > 0 ? Math.min(number, 1000) : 1;
|
|
2534
4122
|
}
|
|
2535
|
-
function
|
|
2536
|
-
|
|
4123
|
+
function letter(index) {
|
|
4124
|
+
let letters = '';
|
|
4125
|
+
for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) letters = String.fromCodePoint(65 + (rest - 1) % 26) + letters;
|
|
4126
|
+
return letters;
|
|
2537
4127
|
}
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
4128
|
+
|
|
4129
|
+
/**
|
|
4130
|
+
* Finds tables in a deck: native tables through the workbook table reader
|
|
4131
|
+
* (merged cells filled, a header over several rows joined), or, with
|
|
4132
|
+
* `shapes`, text boxes laid out as a table through the PDF table reader (a box
|
|
4133
|
+
* is a cell, boxes whose heights overlap a row, columns from where the body's
|
|
4134
|
+
* boxes start). Hidden slides are skipped unless `includeHidden`.
|
|
4135
|
+
*
|
|
4136
|
+
* @param document - The deck.
|
|
4137
|
+
* @param query - Which tables, on which slides, and how to name their columns.
|
|
4138
|
+
* @returns The tables, slide by slide.
|
|
4139
|
+
*/
|
|
4140
|
+
function findDeckTables(document, query) {
|
|
4141
|
+
const slides = document.slides.filter(slide => (query.includeHidden === true || !slide.hidden) && (query.slide === undefined || query.slide.test(slide.title ?? '')));
|
|
4142
|
+
if (query.shapes === true) return shapeTables(document, slides, query);
|
|
4143
|
+
return slides.flatMap(slide => findGridTables({
|
|
4144
|
+
sheets: slide.tables
|
|
4145
|
+
}, query).map(table => ({
|
|
4146
|
+
slide: slide.number,
|
|
4147
|
+
slideTitle: slide.title ?? '',
|
|
4148
|
+
title: table.title,
|
|
4149
|
+
header: table.header,
|
|
4150
|
+
rows: table.rows
|
|
4151
|
+
})));
|
|
4152
|
+
}
|
|
4153
|
+
/** Text boxes as a PDF of one page per slide, y flipped (PDF counts from the bottom), each box one cell. */
|
|
4154
|
+
function shapeTables(document, slides, query) {
|
|
4155
|
+
const pdf = {
|
|
4156
|
+
pages: slides.map(slide => ({
|
|
4157
|
+
number: slide.number,
|
|
4158
|
+
width: document.width,
|
|
4159
|
+
height: document.height,
|
|
4160
|
+
rows: rowsOfCells(slide.shapes.map(shape => ({
|
|
4161
|
+
x: shape.x,
|
|
4162
|
+
y: document.height - shape.y - shape.height,
|
|
4163
|
+
width: shape.width,
|
|
4164
|
+
height: shape.height,
|
|
4165
|
+
text: shape.text.replaceAll(/\s+/g, ' ').trim()
|
|
4166
|
+
})))
|
|
4167
|
+
}))
|
|
4168
|
+
};
|
|
4169
|
+
const titles = new Map(slides.map(slide => [slide.number, slide.title ?? '']));
|
|
4170
|
+
return findTables(pdf, {
|
|
4171
|
+
header: query.header,
|
|
4172
|
+
until: query.until,
|
|
4173
|
+
columns: query.columns,
|
|
4174
|
+
align: query.align
|
|
4175
|
+
}).map(table => ({
|
|
4176
|
+
slide: table.page,
|
|
4177
|
+
slideTitle: titles.get(table.page) ?? '',
|
|
4178
|
+
title: table.title,
|
|
4179
|
+
header: table.header,
|
|
4180
|
+
rows: query.fillDown === undefined ? table.rows : fillDown(table.rows, query.fillDown)
|
|
4181
|
+
}));
|
|
2541
4182
|
}
|
|
2542
4183
|
|
|
2543
4184
|
/**
|
|
2544
4185
|
* Runs an `extract` step against a static document: the value bound under
|
|
2545
4186
|
* `from`, else the scope's current document. `css` reads HTML, `jsonpath`
|
|
2546
|
-
* reads JSON (or a read PDF
|
|
2547
|
-
*
|
|
4187
|
+
* reads JSON (or a read PDF, workbook or deck as data), `table` reads the
|
|
4188
|
+
* tables of a PDF, a workbook (a spreadsheet, a CSV), a deck (a presentation)
|
|
4189
|
+
* or HTML (its `<table>`s), `regex` reads any document as text; `xpath` needs
|
|
4190
|
+
* a live page and is refused here.
|
|
2548
4191
|
*
|
|
2549
4192
|
* A `jsonpath` extract whose `from` is text parses that text as JSON, and a
|
|
2550
4193
|
* list of texts (every `<script type="application/ld+json">` of a page) becomes
|
|
@@ -2563,19 +4206,18 @@ function extractFromDocument(step, scope) {
|
|
|
2563
4206
|
switch (step.kind) {
|
|
2564
4207
|
case 'jsonpath':
|
|
2565
4208
|
{
|
|
2566
|
-
if (document.kind
|
|
2567
|
-
values = selectJson(document.kind === '
|
|
4209
|
+
if (document.kind === 'html' || document.kind === 'text') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
|
|
4210
|
+
values = selectJson(document.kind === 'json' ? document.data : document, selector).map(node => takeFromJson(node, take));
|
|
2568
4211
|
break;
|
|
2569
4212
|
}
|
|
2570
4213
|
case 'table':
|
|
2571
4214
|
{
|
|
2572
|
-
|
|
2573
|
-
values = findTables(document, tableQuery(step, selector));
|
|
4215
|
+
values = readTables(document, step, selector);
|
|
2574
4216
|
break;
|
|
2575
4217
|
}
|
|
2576
4218
|
case 'css':
|
|
2577
4219
|
{
|
|
2578
|
-
if (document.kind !== 'html') throw new Error(`css needs an HTML document; the current document is ${document.kind}`);
|
|
4220
|
+
if (document.kind !== 'html') throw new Error(`css needs an HTML document; the current document is ${document.kind}${['workbook', 'pdf', 'deck'].includes(document.kind) ? ' (read it with kind "table", "regex" or "jsonpath")' : ''}`);
|
|
2579
4221
|
values = selectHtml(document.html, selector).map(match => takeFromHtml(match, take));
|
|
2580
4222
|
break;
|
|
2581
4223
|
}
|
|
@@ -2607,20 +4249,82 @@ function extractFromDocument(step, scope) {
|
|
|
2607
4249
|
function renderSelector(selector, scope) {
|
|
2608
4250
|
return hasPlaceholder(selector) ? renderText(selector, path => scope.lookup(path)) : selector;
|
|
2609
4251
|
}
|
|
4252
|
+
/**
|
|
4253
|
+
* The tables a `table` extract finds in a document: a PDF's, a workbook's, a
|
|
4254
|
+
* deck's, or an HTML document's `<table>`s (a fetched page, rendered Markdown,
|
|
4255
|
+
* a live page's content).
|
|
4256
|
+
*
|
|
4257
|
+
* @param document - The document.
|
|
4258
|
+
* @param step - The extract step.
|
|
4259
|
+
* @param scope - Where its selector renders.
|
|
4260
|
+
* @returns The tables.
|
|
4261
|
+
*/
|
|
4262
|
+
function tablesIn(document, step, scope) {
|
|
4263
|
+
return readTables(document, step, renderSelector(step.selector, scope));
|
|
4264
|
+
}
|
|
4265
|
+
function readTables(document, step, selector) {
|
|
4266
|
+
const query = tableQuery(step, selector);
|
|
4267
|
+
if (document.kind === 'html') {
|
|
4268
|
+
refuseOptions(step, ['sheet', 'slide', 'shapes'], 'workbooks and decks', 'HTML');
|
|
4269
|
+
return findGridTables({
|
|
4270
|
+
sheets: htmlTableSheets(document.html)
|
|
4271
|
+
}, {
|
|
4272
|
+
...query,
|
|
4273
|
+
headerRows: step.headerRows,
|
|
4274
|
+
fillDown: step.fillDown
|
|
4275
|
+
});
|
|
4276
|
+
}
|
|
4277
|
+
if (document.kind === 'workbook') {
|
|
4278
|
+
refuseOptions(step, ['slide', 'shapes'], 'decks (presentations)', 'a workbook');
|
|
4279
|
+
return findGridTables(document, {
|
|
4280
|
+
...query,
|
|
4281
|
+
sheet: optionalPattern(step.sheet, 'sheet'),
|
|
4282
|
+
headerRows: step.headerRows,
|
|
4283
|
+
fillDown: step.fillDown,
|
|
4284
|
+
includeHidden: step.includeHidden
|
|
4285
|
+
});
|
|
4286
|
+
}
|
|
4287
|
+
if (document.kind === 'deck') {
|
|
4288
|
+
refuseOptions(step, ['sheet'], 'workbooks (spreadsheets, CSV)', 'a deck');
|
|
4289
|
+
return findDeckTables(document, {
|
|
4290
|
+
...query,
|
|
4291
|
+
slide: optionalPattern(step.slide, 'slide'),
|
|
4292
|
+
shapes: step.shapes,
|
|
4293
|
+
headerRows: step.headerRows,
|
|
4294
|
+
fillDown: step.fillDown,
|
|
4295
|
+
includeHidden: step.includeHidden
|
|
4296
|
+
});
|
|
4297
|
+
}
|
|
4298
|
+
if (document.kind !== 'pdf') throw new Error(`table reads a PDF, a workbook (a spreadsheet, a CSV), a deck (a presentation) or HTML tables; the current document is ${document.kind} (request it with "as": "pdf", "csv", "xlsx", "pptx" or "html")`);
|
|
4299
|
+
refuseOptions(step, ['sheet', 'headerRows', 'includeHidden', 'slide', 'shapes'], 'workbooks and decks', 'a PDF');
|
|
4300
|
+
const tables = findTables(document, query);
|
|
4301
|
+
return step.fillDown === undefined ? tables : tables.map(table => ({
|
|
4302
|
+
...table,
|
|
4303
|
+
rows: fillDown(table.rows, step.fillDown ?? [])
|
|
4304
|
+
}));
|
|
4305
|
+
}
|
|
2610
4306
|
/**
|
|
2611
4307
|
* A table extract's query: the selector matches the header row, the other
|
|
2612
|
-
* patterns come from the step; all case-insensitive, since PDFs
|
|
2613
|
-
* headings freely.
|
|
4308
|
+
* patterns come from the step; all case-insensitive, since PDFs and
|
|
4309
|
+
* spreadsheets capitalise headings freely.
|
|
2614
4310
|
*/
|
|
2615
4311
|
function tableQuery(step, selector) {
|
|
2616
4312
|
const columns = step.columns === undefined ? undefined : Object.fromEntries(Object.entries(step.columns).map(([key, pattern]) => [key, patternOf(pattern, `columns.${key}`)]));
|
|
2617
4313
|
return {
|
|
2618
4314
|
header: patternOf(selector, 'selector'),
|
|
2619
|
-
until:
|
|
4315
|
+
until: optionalPattern(step.until, 'until'),
|
|
2620
4316
|
columns,
|
|
2621
4317
|
align: step.align
|
|
2622
4318
|
};
|
|
2623
4319
|
}
|
|
4320
|
+
function refuseOptions(step, options, reads, current) {
|
|
4321
|
+
for (const option of options) {
|
|
4322
|
+
if (step[option] !== undefined) throw new Error(`"${option}" reads ${reads}; the current document is ${current}`);
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
function optionalPattern(source, where) {
|
|
4326
|
+
return source === undefined ? undefined : patternOf(source, where);
|
|
4327
|
+
}
|
|
2624
4328
|
function patternOf(source, where) {
|
|
2625
4329
|
try {
|
|
2626
4330
|
return new RegExp(source, 'i');
|
|
@@ -2630,11 +4334,13 @@ function patternOf(source, where) {
|
|
|
2630
4334
|
});
|
|
2631
4335
|
}
|
|
2632
4336
|
}
|
|
2633
|
-
/** The text a regex extract reads: markup, text, a PDF's rows, or JSON re-serialised (a list of texts joined by newlines). */
|
|
4337
|
+
/** The text a regex extract reads: markup, text, a PDF's or a workbook's rows, or JSON re-serialised (a list of texts joined by newlines). */
|
|
2634
4338
|
function textOf$1(document) {
|
|
2635
4339
|
if (document.kind === 'html') return document.html;
|
|
2636
4340
|
if (document.kind === 'text') return document.text;
|
|
2637
4341
|
if (document.kind === 'pdf') return pdfText(document);
|
|
4342
|
+
if (document.kind === 'workbook') return workbookText(document);
|
|
4343
|
+
if (document.kind === 'deck') return deckText(document);
|
|
2638
4344
|
if (Array.isArray(document.data) && document.data.every(entry => typeof entry === 'string')) return document.data.join('\n');
|
|
2639
4345
|
return typeof document.data === 'string' ? document.data : JSON.stringify(document.data);
|
|
2640
4346
|
}
|
|
@@ -2646,8 +4352,8 @@ function documentFor(step, scope) {
|
|
|
2646
4352
|
}
|
|
2647
4353
|
const source = scope.get(step.from);
|
|
2648
4354
|
if (source === undefined) throw new Error(`"${step.from}" is not bound`);
|
|
2649
|
-
if (isPdfDocument(source)) return source;
|
|
2650
|
-
if (step.kind === 'table') throw new Error(`"${step.from}" is not a PDF; request it with "as": "pdf"`);
|
|
4355
|
+
if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source)) return source;
|
|
4356
|
+
if (step.kind === 'table') throw new Error(`"${step.from}" is not a PDF, a workbook or a deck; request it with "as": "pdf", "csv", "xlsx" or "pptx"`);
|
|
2651
4357
|
if (step.kind === 'regex') {
|
|
2652
4358
|
if (typeof source === 'string') return {
|
|
2653
4359
|
kind: 'text',
|
|
@@ -2683,6 +4389,135 @@ function documentFor(step, scope) {
|
|
|
2683
4389
|
};
|
|
2684
4390
|
}
|
|
2685
4391
|
|
|
4392
|
+
/** Aliases one document may expand: a "billion laughs" document needs far more. */
|
|
4393
|
+
const MAX_ALIASES = 100;
|
|
4394
|
+
/**
|
|
4395
|
+
* Parses YAML with the `yaml` package, imported on first use. The version is
|
|
4396
|
+
* pinned to YAML 1.2 (core schema) whatever the document declares: under a
|
|
4397
|
+
* `%YAML 1.1` directive, `NO` would read as `false` and `0123` as octal `83`.
|
|
4398
|
+
* Merge keys (`<<: *base`) are applied, duplicate keys are an error, aliases
|
|
4399
|
+
* are capped, and custom tags never build values: nothing in the text runs.
|
|
4400
|
+
*
|
|
4401
|
+
* @param text - The YAML.
|
|
4402
|
+
* @param source - Where it came from, for messages.
|
|
4403
|
+
* @param scalars - `typed` (default), or `text` to keep every scalar as written (`0123` stays `"0123"`).
|
|
4404
|
+
* @returns The data, the number of documents and the warnings.
|
|
4405
|
+
* @throws Error naming the source, with the line and column, for YAML that does not parse.
|
|
4406
|
+
*/
|
|
4407
|
+
async function readYaml(text, source, scalars = 'typed') {
|
|
4408
|
+
const {
|
|
4409
|
+
parseAllDocuments
|
|
4410
|
+
} = await import('yaml');
|
|
4411
|
+
const parsed = parseAllDocuments(text, {
|
|
4412
|
+
version: '1.2',
|
|
4413
|
+
schema: scalars === 'text' ? 'failsafe' : 'core',
|
|
4414
|
+
merge: true,
|
|
4415
|
+
uniqueKeys: true,
|
|
4416
|
+
prettyErrors: true
|
|
4417
|
+
});
|
|
4418
|
+
const documents = Array.isArray(parsed) ? parsed : [parsed];
|
|
4419
|
+
const warnings = [];
|
|
4420
|
+
const values = [];
|
|
4421
|
+
for (const document of documents) {
|
|
4422
|
+
const [error] = document.errors;
|
|
4423
|
+
if (error !== undefined) throw new Error(`${source}: not YAML (${error.message.split('\n', 1)[0]})`, {
|
|
4424
|
+
cause: error
|
|
4425
|
+
});
|
|
4426
|
+
warnings.push(...document.warnings.map(warning => warning.message.split('\n', 1)[0]));
|
|
4427
|
+
try {
|
|
4428
|
+
values.push(document.toJS({
|
|
4429
|
+
maxAliasCount: MAX_ALIASES
|
|
4430
|
+
}));
|
|
4431
|
+
} catch (error) {
|
|
4432
|
+
throw new Error(`${source}: ${error.message}`, {
|
|
4433
|
+
cause: error
|
|
4434
|
+
});
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
return {
|
|
4438
|
+
data: values.length === 1 ? values[0] : values,
|
|
4439
|
+
documents: values.length,
|
|
4440
|
+
warnings
|
|
4441
|
+
};
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
/** A leading `---` block of YAML. */
|
|
4445
|
+
const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
4446
|
+
/**
|
|
4447
|
+
* Renders Markdown (GitHub-flavoured: tables, task lists, strikethrough,
|
|
4448
|
+
* autolinks) to HTML with `marked`, imported on first use, so every `css`
|
|
4449
|
+
* selector works on it:
|
|
4450
|
+
*
|
|
4451
|
+
* - each heading and everything up to the next heading of the same or a higher
|
|
4452
|
+
* level is wrapped in `<section data-heading="…" data-level="…">`, sections
|
|
4453
|
+
* nesting, so "the table under *Prezzi*" is one selector;
|
|
4454
|
+
* - headings get slug ids (`<h2 id="prezzi">`);
|
|
4455
|
+
* - a leading `---` YAML block is parsed (YAML 1.2, as `as: "yaml"` reads it)
|
|
4456
|
+
* and put in the head as `<script type="application/json" data-front-matter>`.
|
|
4457
|
+
*
|
|
4458
|
+
* Raw HTML in the Markdown is kept: it is data, parsed by cheerio, never run.
|
|
4459
|
+
*
|
|
4460
|
+
* @param text - The Markdown.
|
|
4461
|
+
* @param source - Where it came from, for messages.
|
|
4462
|
+
* @returns The HTML, the front matter's data and the YAML parser's warnings.
|
|
4463
|
+
* @throws Error naming the source when the front matter is not YAML.
|
|
4464
|
+
*/
|
|
4465
|
+
async function readMarkdown(text, source) {
|
|
4466
|
+
const matter = FRONT_MATTER.exec(text);
|
|
4467
|
+
const body = matter === null ? text : text.slice(matter[0].length);
|
|
4468
|
+
const front = matter === null ? undefined : await readYaml(matter[1], `${source} front matter`);
|
|
4469
|
+
const {
|
|
4470
|
+
marked
|
|
4471
|
+
} = await import('marked');
|
|
4472
|
+
const rendered = marked.parse(body, {
|
|
4473
|
+
gfm: true,
|
|
4474
|
+
async: false
|
|
4475
|
+
});
|
|
4476
|
+
const sections = sectioned(rendered);
|
|
4477
|
+
const head = front === undefined ? '' : `<script type="application/json" data-front-matter>${JSON.stringify(front.data ?? null).replaceAll('<', '<')}</script>`;
|
|
4478
|
+
return {
|
|
4479
|
+
html: `<!doctype html><html><head>${head}</head><body>${sections}</body></html>`,
|
|
4480
|
+
frontMatter: front?.data,
|
|
4481
|
+
warnings: front?.warnings ?? []
|
|
4482
|
+
};
|
|
4483
|
+
}
|
|
4484
|
+
/** Wraps each heading and what follows it, up to the next heading of the same or a higher level, in a section. */
|
|
4485
|
+
function sectioned(html) {
|
|
4486
|
+
const $ = load(html, null, false);
|
|
4487
|
+
const open = [];
|
|
4488
|
+
const used = new Map();
|
|
4489
|
+
let out = '';
|
|
4490
|
+
const nodes = $.root().contents().toArray();
|
|
4491
|
+
for (const node of nodes) {
|
|
4492
|
+
const level = node.type === 'tag' ? /^h([1-6])$/.exec(node.name)?.[1] : undefined;
|
|
4493
|
+
if (level === undefined) {
|
|
4494
|
+
out += $.html(node);
|
|
4495
|
+
continue;
|
|
4496
|
+
}
|
|
4497
|
+
const depth = Number(level);
|
|
4498
|
+
while (open.length > 0 && (open.at(-1) ?? 0) >= depth) {
|
|
4499
|
+
open.pop();
|
|
4500
|
+
out += '</section>';
|
|
4501
|
+
}
|
|
4502
|
+
const heading = $(node);
|
|
4503
|
+
const text = heading.text().replaceAll(/\s+/g, ' ').trim();
|
|
4504
|
+
heading.attr('id', uniqueSlug(text, used));
|
|
4505
|
+
out += `<section data-heading="${escapeAttribute(text)}" data-level="${depth}">${$.html(node)}`;
|
|
4506
|
+
open.push(depth);
|
|
4507
|
+
}
|
|
4508
|
+
return out + '</section>'.repeat(open.length);
|
|
4509
|
+
}
|
|
4510
|
+
/** GitHub's heading ids: lower case, spaces to hyphens, punctuation dropped, a counter for repeats. */
|
|
4511
|
+
function uniqueSlug(text, used) {
|
|
4512
|
+
const slug = text.toLowerCase().replaceAll(/[^\p{L}\p{N}\s-]/gu, '').trim().replaceAll(/\s/g, '-');
|
|
4513
|
+
const count = used.get(slug) ?? 0;
|
|
4514
|
+
used.set(slug, count + 1);
|
|
4515
|
+
return count === 0 ? slug : `${slug}-${count}`;
|
|
4516
|
+
}
|
|
4517
|
+
function escapeAttribute(text) {
|
|
4518
|
+
return text.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<');
|
|
4519
|
+
}
|
|
4520
|
+
|
|
2686
4521
|
/** A response with a 4xx or 5xx status. */
|
|
2687
4522
|
class HttpError extends Error {
|
|
2688
4523
|
status;
|
|
@@ -2699,6 +4534,82 @@ class HttpError extends Error {
|
|
|
2699
4534
|
}
|
|
2700
4535
|
}
|
|
2701
4536
|
|
|
4537
|
+
/**
|
|
4538
|
+
* Decodes a body, in this order: a byte-order mark (UTF-8, UTF-16 LE/BE; Excel's
|
|
4539
|
+
* "Unicode text" export is UTF-16 LE), the encoding a recipe asks for, the
|
|
4540
|
+
* charset the server declares, strict UTF-8, and Windows-1252 (a superset of
|
|
4541
|
+
* Latin-1) for text that is not UTF-8.
|
|
4542
|
+
*
|
|
4543
|
+
* The Windows-1252 fallback is only taken when the text holds no valid UTF-8
|
|
4544
|
+
* beyond ASCII: a UTF-8 page with one stray byte keeps its accents, with a
|
|
4545
|
+
* replacement character for the stray byte, instead of turning every accent
|
|
4546
|
+
* into mojibake.
|
|
4547
|
+
*
|
|
4548
|
+
* @param bytes - The body.
|
|
4549
|
+
* @param options - `encoding`: the recipe's choice, a WHATWG label (wins over
|
|
4550
|
+
* the charset, not over a BOM); `charset`: from the content type (ignored when
|
|
4551
|
+
* not a known label).
|
|
4552
|
+
* @returns The text, without its BOM, and the encoding used.
|
|
4553
|
+
* @throws Error when `encoding` is not a known label.
|
|
4554
|
+
*/
|
|
4555
|
+
function decodeText(bytes, options = {}) {
|
|
4556
|
+
const bom = bomOf(bytes);
|
|
4557
|
+
if (bom !== undefined) return decodeWith(new TextDecoder(bom), bytes);
|
|
4558
|
+
if (options.encoding !== undefined) {
|
|
4559
|
+
const decoder = decoderFor(options.encoding);
|
|
4560
|
+
if (decoder === undefined) throw new Error(`"${options.encoding}" is not an encoding this runtime knows (try utf8, windows-1252, iso-8859-15, utf-16le, shift_jis…)`);
|
|
4561
|
+
return decodeWith(decoder, bytes);
|
|
4562
|
+
}
|
|
4563
|
+
const declared = options.charset === undefined ? undefined : decoderFor(options.charset);
|
|
4564
|
+
if (declared !== undefined) return decodeWith(declared, bytes);
|
|
4565
|
+
try {
|
|
4566
|
+
return decodeWith(new TextDecoder('utf-8', {
|
|
4567
|
+
fatal: true
|
|
4568
|
+
}), bytes);
|
|
4569
|
+
} catch {
|
|
4570
|
+
const lenient = decodeWith(new TextDecoder('utf-8'), bytes);
|
|
4571
|
+
return hasNonAsciiText(lenient.text) ? lenient : decodeWith(new TextDecoder('windows-1252'), bytes);
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
4574
|
+
/** Decodes, naming the encoding by its canonical WHATWG name (`utf-8`, `windows-1252`, `utf-16le`). */
|
|
4575
|
+
function decodeWith(decoder, bytes) {
|
|
4576
|
+
return {
|
|
4577
|
+
text: decoder.decode(bytes),
|
|
4578
|
+
encoding: decoder.encoding
|
|
4579
|
+
};
|
|
4580
|
+
}
|
|
4581
|
+
/**
|
|
4582
|
+
* The charset a content type declares (`text/csv; charset=ISO-8859-1`).
|
|
4583
|
+
*
|
|
4584
|
+
* @param contentType - The header value.
|
|
4585
|
+
* @returns The charset, or `undefined`.
|
|
4586
|
+
*/
|
|
4587
|
+
function charsetOf(contentType) {
|
|
4588
|
+
const match = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
|
|
4589
|
+
return match?.[1];
|
|
4590
|
+
}
|
|
4591
|
+
function bomOf(bytes) {
|
|
4592
|
+
if (bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) return 'utf8';
|
|
4593
|
+
if (bytes[0] === 0xFF && bytes[1] === 0xFE) return 'utf-16le';
|
|
4594
|
+
if (bytes[0] === 0xFE && bytes[1] === 0xFF) return 'utf-16be';
|
|
4595
|
+
return undefined;
|
|
4596
|
+
}
|
|
4597
|
+
/** Whether the text holds a character beyond ASCII other than the replacement character: valid UTF-8 was seen. */
|
|
4598
|
+
function hasNonAsciiText(text) {
|
|
4599
|
+
for (const char of text) {
|
|
4600
|
+
const code = char.codePointAt(0) ?? 0;
|
|
4601
|
+
if (code !== 0xFF_FD && code > 0x7F) return true;
|
|
4602
|
+
}
|
|
4603
|
+
return false;
|
|
4604
|
+
}
|
|
4605
|
+
function decoderFor(label) {
|
|
4606
|
+
try {
|
|
4607
|
+
return new TextDecoder(label.trim());
|
|
4608
|
+
} catch {
|
|
4609
|
+
return undefined;
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
|
|
2702
4613
|
/**
|
|
2703
4614
|
* HTTP through Playwright's request context: cookies, redirects and storage
|
|
2704
4615
|
* state behave exactly as they do in the browser, so a session captured by a
|
|
@@ -2738,12 +4649,20 @@ class HttpClient {
|
|
|
2738
4649
|
data: httpRequest.body,
|
|
2739
4650
|
timeout: httpRequest.timeoutMs ?? this.timeoutMs
|
|
2740
4651
|
});
|
|
2741
|
-
const
|
|
4652
|
+
const {
|
|
4653
|
+
body,
|
|
4654
|
+
warnings,
|
|
4655
|
+
format
|
|
4656
|
+
} = await readBody(response, httpRequest);
|
|
2742
4657
|
const result = {
|
|
2743
4658
|
status: response.status(),
|
|
2744
4659
|
url: response.url(),
|
|
2745
4660
|
headers: response.headers(),
|
|
2746
|
-
body
|
|
4661
|
+
body,
|
|
4662
|
+
format,
|
|
4663
|
+
...(warnings.length > 0 && {
|
|
4664
|
+
warnings
|
|
4665
|
+
})
|
|
2747
4666
|
};
|
|
2748
4667
|
if (response.status() >= 400) throw new HttpError(response.status(), response.url(), body, response.headers());
|
|
2749
4668
|
return result;
|
|
@@ -2756,40 +4675,107 @@ class HttpClient {
|
|
|
2756
4675
|
return this.context.dispose();
|
|
2757
4676
|
}
|
|
2758
4677
|
}
|
|
2759
|
-
async function readBody(response,
|
|
2760
|
-
const
|
|
2761
|
-
|
|
4678
|
+
async function readBody(response, httpRequest) {
|
|
4679
|
+
const contentType = response.headers()['content-type'] ?? '';
|
|
4680
|
+
const format = httpRequest.as ?? formatFromContentType(contentType);
|
|
4681
|
+
return parseBody(format, await response.body(), response.url(), {
|
|
4682
|
+
...httpRequest,
|
|
4683
|
+
charset: charsetOf(contentType)
|
|
4684
|
+
});
|
|
2762
4685
|
}
|
|
2763
4686
|
/**
|
|
2764
|
-
* A `file:` URL, read from disk: a PDF
|
|
2765
|
-
* instead of a server. The
|
|
4687
|
+
* A `file:` URL, read from disk: a PDF, spreadsheet, presentation, CSV, YAML
|
|
4688
|
+
* or JSON a recipe gets from a folder instead of a server. The format is `as`,
|
|
4689
|
+
* else the file extension.
|
|
2766
4690
|
*/
|
|
2767
4691
|
async function readLocalFile(httpRequest) {
|
|
2768
4692
|
const path = fileURLToPath(httpRequest.url);
|
|
2769
4693
|
const bytes = await readFile(path);
|
|
4694
|
+
const {
|
|
4695
|
+
body,
|
|
4696
|
+
warnings,
|
|
4697
|
+
format
|
|
4698
|
+
} = await parseBody(httpRequest.as ?? formatFromExtension(extname(path)), bytes, httpRequest.url, httpRequest);
|
|
2770
4699
|
return {
|
|
2771
4700
|
status: 200,
|
|
2772
4701
|
url: httpRequest.url,
|
|
2773
4702
|
headers: {},
|
|
2774
|
-
body
|
|
4703
|
+
body,
|
|
4704
|
+
format,
|
|
4705
|
+
...(warnings.length > 0 && {
|
|
4706
|
+
warnings
|
|
4707
|
+
})
|
|
2775
4708
|
};
|
|
2776
4709
|
}
|
|
2777
|
-
async function parseBody(
|
|
2778
|
-
if (
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
4710
|
+
async function parseBody(format, bytes, url, reading) {
|
|
4711
|
+
if (format === 'yaml') {
|
|
4712
|
+
const {
|
|
4713
|
+
text
|
|
4714
|
+
} = decodeText(bytes, reading);
|
|
4715
|
+
const {
|
|
4716
|
+
data,
|
|
4717
|
+
warnings
|
|
4718
|
+
} = await readYaml(text, url, reading.scalars);
|
|
4719
|
+
return {
|
|
4720
|
+
body: {
|
|
2783
4721
|
kind: 'json',
|
|
2784
|
-
data
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
4722
|
+
data
|
|
4723
|
+
},
|
|
4724
|
+
warnings,
|
|
4725
|
+
format
|
|
4726
|
+
};
|
|
4727
|
+
}
|
|
4728
|
+
if (format === 'markdown') {
|
|
4729
|
+
const {
|
|
4730
|
+
text
|
|
4731
|
+
} = decodeText(bytes, reading);
|
|
4732
|
+
const {
|
|
4733
|
+
html,
|
|
4734
|
+
warnings
|
|
4735
|
+
} = await readMarkdown(text, url);
|
|
4736
|
+
return {
|
|
4737
|
+
body: {
|
|
4738
|
+
kind: 'html',
|
|
4739
|
+
html
|
|
4740
|
+
},
|
|
4741
|
+
warnings,
|
|
4742
|
+
format
|
|
4743
|
+
};
|
|
4744
|
+
}
|
|
4745
|
+
return {
|
|
4746
|
+
body: await parseFormat(format, bytes, url, reading),
|
|
4747
|
+
warnings: [],
|
|
4748
|
+
format
|
|
4749
|
+
};
|
|
4750
|
+
}
|
|
4751
|
+
async function parseFormat(format, bytes, url, reading) {
|
|
4752
|
+
if (format === 'pdf') return readPdf(bytes, url);
|
|
4753
|
+
if (format === 'xlsx') return readXlsxWorkbook(bytes, url);
|
|
4754
|
+
if (format === 'pptx') return readPptxDeck(bytes, url);
|
|
4755
|
+
const {
|
|
4756
|
+
text,
|
|
4757
|
+
encoding
|
|
4758
|
+
} = decodeText(bytes, reading);
|
|
4759
|
+
if (format === 'csv') return csvWorkbook(text, {
|
|
4760
|
+
name: sheetNameOf(url),
|
|
4761
|
+
encoding,
|
|
4762
|
+
delimiter: reading.delimiter
|
|
4763
|
+
});
|
|
4764
|
+
if (format === 'jsonl') return {
|
|
4765
|
+
kind: 'json',
|
|
4766
|
+
data: parseJsonLines(text, url)
|
|
4767
|
+
};
|
|
4768
|
+
if (format === 'json') {
|
|
4769
|
+
const parsed = parseJsonLike(text);
|
|
4770
|
+
if ('error' in parsed) throw new Error(`${url}: body is not JSON (${parsed.error.message})${looksLikeJsonLines(text) ? '; it looks like JSON Lines: read it with "as": "jsonl"' : ''}`, {
|
|
4771
|
+
cause: parsed.error
|
|
4772
|
+
});
|
|
4773
|
+
return {
|
|
4774
|
+
kind: 'json',
|
|
4775
|
+
data: parsed.value
|
|
4776
|
+
};
|
|
2791
4777
|
}
|
|
2792
|
-
return
|
|
4778
|
+
return format === 'html' ? {
|
|
2793
4779
|
kind: 'html',
|
|
2794
4780
|
html: text
|
|
2795
4781
|
} : {
|
|
@@ -2797,28 +4783,61 @@ async function parseBody(kind, bytes, url) {
|
|
|
2797
4783
|
text
|
|
2798
4784
|
};
|
|
2799
4785
|
}
|
|
2800
|
-
|
|
2801
|
-
|
|
4786
|
+
/** Several lines, the first of them JSON on its own. */
|
|
4787
|
+
function looksLikeJsonLines(text) {
|
|
4788
|
+
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '');
|
|
4789
|
+
if (lines.length < 2) return false;
|
|
4790
|
+
const first = parseJsonLike(lines[0]);
|
|
4791
|
+
return 'value' in first;
|
|
4792
|
+
}
|
|
4793
|
+
function formatFromContentType(contentType) {
|
|
4794
|
+
const type = contentType.toLowerCase().split(';', 1)[0].trim();
|
|
4795
|
+
if (CSV_TYPES.has(type)) return 'csv';
|
|
4796
|
+
if (JSON_LINES_TYPES.has(type)) return 'jsonl';
|
|
4797
|
+
// A legacy .xls or .ppt goes to the Office reader too, which says what to do with it.
|
|
4798
|
+
if (type.includes('spreadsheetml') || type.startsWith('application/vnd.ms-excel')) return 'xlsx';
|
|
4799
|
+
if (type.includes('presentationml') || type.startsWith('application/vnd.ms-powerpoint')) return 'pptx';
|
|
4800
|
+
if (YAML_TYPES.has(type)) return 'yaml';
|
|
4801
|
+
if (type === 'text/markdown' || type === 'text/x-markdown') return 'markdown';
|
|
2802
4802
|
if (type.includes('json')) return 'json';
|
|
2803
4803
|
if (type.includes('pdf')) return 'pdf';
|
|
2804
4804
|
if (type.includes('html') || type.includes('xml')) return 'html';
|
|
2805
4805
|
return 'text';
|
|
2806
4806
|
}
|
|
2807
|
-
|
|
2808
|
-
|
|
4807
|
+
const JSON_LINES_TYPES = new Set(['application/x-ndjson', 'application/ndjson', 'application/jsonl', 'application/x-jsonlines', 'application/jsonlines']);
|
|
4808
|
+
const YAML_TYPES = new Set(['application/yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml']);
|
|
4809
|
+
const CSV_TYPES = new Set(['text/csv', 'application/csv', 'text/x-csv', 'application/x-csv', 'text/comma-separated-values', 'text/tab-separated-values']);
|
|
4810
|
+
function formatFromExtension(extension) {
|
|
4811
|
+
const formats = {
|
|
2809
4812
|
'.json': 'json',
|
|
4813
|
+
'.jsonl': 'jsonl',
|
|
4814
|
+
'.ndjson': 'jsonl',
|
|
2810
4815
|
'.pdf': 'pdf',
|
|
4816
|
+
'.csv': 'csv',
|
|
4817
|
+
'.tsv': 'csv',
|
|
4818
|
+
'.xlsx': 'xlsx',
|
|
4819
|
+
'.xlsm': 'xlsx',
|
|
4820
|
+
'.xls': 'xlsx',
|
|
4821
|
+
'.pptx': 'pptx',
|
|
4822
|
+
'.pptm': 'pptx',
|
|
4823
|
+
'.ppsx': 'pptx',
|
|
4824
|
+
'.ppt': 'pptx',
|
|
4825
|
+
'.yaml': 'yaml',
|
|
4826
|
+
'.yml': 'yaml',
|
|
4827
|
+
'.md': 'markdown',
|
|
4828
|
+
'.markdown': 'markdown',
|
|
2811
4829
|
'.html': 'html',
|
|
2812
4830
|
'.htm': 'html',
|
|
2813
4831
|
'.xml': 'html'
|
|
2814
4832
|
};
|
|
2815
|
-
return
|
|
4833
|
+
return formats[extension.toLowerCase()] ?? 'text';
|
|
2816
4834
|
}
|
|
2817
4835
|
|
|
2818
4836
|
/**
|
|
2819
4837
|
* Sends a `request` step: renders its templates, waits for the gate's throttle,
|
|
2820
|
-
* sends,
|
|
2821
|
-
* 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).
|
|
2822
4841
|
*
|
|
2823
4842
|
* @param step - The request step.
|
|
2824
4843
|
* @param scope - The scope to render in and bind into.
|
|
@@ -2826,22 +4845,34 @@ function kindFromExtension(extension) {
|
|
|
2826
4845
|
* @param recipe - The recipe: its limits, block rule and id.
|
|
2827
4846
|
* @param gate - Spaces request starts by `delayMs`.
|
|
2828
4847
|
* @param events - Where to report the visit.
|
|
2829
|
-
* @throws BlockedError when the response is a block
|
|
4848
|
+
* @throws BlockedError when the response is a block, or a captcha page under `session.captcha`; HttpError for any other 4xx/5xx.
|
|
2830
4849
|
*/
|
|
2831
4850
|
async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
2832
4851
|
const lookup = path => scope.lookup(path);
|
|
2833
4852
|
const url = resolveUrl(renderText(step.url, lookup), scope.pageState?.url);
|
|
2834
|
-
|
|
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);
|
|
2835
4866
|
let response;
|
|
2836
4867
|
try {
|
|
2837
|
-
response = await
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
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
|
|
2845
4876
|
});
|
|
2846
4877
|
} catch (error) {
|
|
2847
4878
|
if (!(error instanceof HttpError)) throw error;
|
|
@@ -2866,6 +4897,15 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
|
2866
4897
|
number: scope.pageState?.number ?? 1,
|
|
2867
4898
|
status: response.status
|
|
2868
4899
|
});
|
|
4900
|
+
const warnings = response.warnings ?? [];
|
|
4901
|
+
for (const warning of warnings) events.emit({
|
|
4902
|
+
type: 'warning',
|
|
4903
|
+
recipeId: recipe.id,
|
|
4904
|
+
message: `${response.url}: ${warning}`,
|
|
4905
|
+
meta: {
|
|
4906
|
+
url: response.url
|
|
4907
|
+
}
|
|
4908
|
+
});
|
|
2869
4909
|
const blocked = await detectBlock({
|
|
2870
4910
|
url: response.url,
|
|
2871
4911
|
status: response.status,
|
|
@@ -2873,15 +4913,30 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
|
2873
4913
|
text: async () => bodyText(response.body)
|
|
2874
4914
|
}, recipe.session?.blockedWhen);
|
|
2875
4915
|
if (blocked !== undefined) throw blocked;
|
|
4916
|
+
if (recipe.session?.captcha !== undefined && response.body.kind === 'html' && CAPTCHA_MARKUP.test(response.body.html)) {
|
|
4917
|
+
throw new BlockedError(response.url, response.status, 'the page shows a captcha, which is solved on a live page: run this recipe in web mode, or get past it in session.bootstrap');
|
|
4918
|
+
}
|
|
2876
4919
|
scope.setPage({
|
|
2877
4920
|
url: response.url,
|
|
2878
4921
|
document: response.body
|
|
2879
4922
|
});
|
|
2880
4923
|
if (step.id !== undefined) scope.set(step.id, documentValue(response.body));
|
|
2881
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
|
+
}
|
|
4933
|
+
/** The class names of the widgets `session.captcha` solves. Checked only when a recipe declares it. */
|
|
4934
|
+
const CAPTCHA_MARKUP = /\b(?:g-recaptcha|h-captcha|cf-turnstile)\b/;
|
|
2882
4935
|
function bodyText(body) {
|
|
2883
4936
|
if (body.kind === 'json') return JSON.stringify(body.data);
|
|
2884
4937
|
if (body.kind === 'pdf') return pdfText(body);
|
|
4938
|
+
if (body.kind === 'workbook') return workbookText(body);
|
|
4939
|
+
if (body.kind === 'deck') return deckText(body);
|
|
2885
4940
|
return body.kind === 'html' ? body.html : body.text;
|
|
2886
4941
|
}
|
|
2887
4942
|
function renderMap(map, lookup) {
|
|
@@ -2902,11 +4957,11 @@ function resolveUrl(target, base) {
|
|
|
2902
4957
|
throw new Error(`"${target}" is not a URL${base === undefined || base === '' ? ' and no page is known to resolve it against' : ` and cannot be resolved against ${base}`}`);
|
|
2903
4958
|
}
|
|
2904
4959
|
}
|
|
2905
|
-
/** What a step id holds for a document: parsed JSON, the read PDF, or the markup / text. */
|
|
4960
|
+
/** What a step id holds for a document: parsed JSON, the read PDF, workbook or deck, or the markup / text. */
|
|
2906
4961
|
function documentValue(body) {
|
|
2907
4962
|
if (body.kind === 'json') return body.data;
|
|
2908
|
-
if (body.kind === '
|
|
2909
|
-
return body.kind === '
|
|
4963
|
+
if (body.kind === 'html') return body.html;
|
|
4964
|
+
return body.kind === 'text' ? body.text : body;
|
|
2910
4965
|
}
|
|
2911
4966
|
|
|
2912
4967
|
/** Runs api-mode leaf steps against an HTTP sender. */
|
|
@@ -3988,11 +6043,10 @@ async function extractFromPage(step, page, scope) {
|
|
|
3988
6043
|
extractFromDocument(step, scope);
|
|
3989
6044
|
return;
|
|
3990
6045
|
}
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
const values = take === 'text' ? raw.map(value => typeof value === 'string' ? collapse(value) : value) : raw;
|
|
6046
|
+
const values = step.kind === 'table' ? tablesIn({
|
|
6047
|
+
kind: 'html',
|
|
6048
|
+
html: await page.content()
|
|
6049
|
+
}, step, scope) : await readPage(step, page, scope);
|
|
3996
6050
|
if (step.many === true) {
|
|
3997
6051
|
if (step.id !== undefined) scope.set(step.id, values);
|
|
3998
6052
|
return;
|
|
@@ -4000,6 +6054,13 @@ async function extractFromPage(step, page, scope) {
|
|
|
4000
6054
|
if (values.length === 0) throw new NoMatchError(step.selector);
|
|
4001
6055
|
if (step.id !== undefined) scope.set(step.id, values[0]);
|
|
4002
6056
|
}
|
|
6057
|
+
/** A css, xpath or regex extract on the live page. */
|
|
6058
|
+
async function readPage(step, page, scope) {
|
|
6059
|
+
const rendered = renderSelector(step.selector, scope);
|
|
6060
|
+
const take = step.take ?? 'text';
|
|
6061
|
+
const raw = step.kind === 'regex' ? selectRegex(await page.content(), rendered) : await page.locator(step.kind === 'xpath' ? `xpath=${rendered}` : rendered).evaluateAll(readAll, take);
|
|
6062
|
+
return take === 'text' ? raw.map(value => typeof value === 'string' ? collapse(value) : value) : raw;
|
|
6063
|
+
}
|
|
4003
6064
|
/** Runs inside the page: one value per matched element. Keep it self-contained; it is serialised. */
|
|
4004
6065
|
function readAll(elements, take) {
|
|
4005
6066
|
return elements.map(element => {
|
|
@@ -4151,18 +6212,34 @@ function readElements(elements) {
|
|
|
4151
6212
|
|
|
4152
6213
|
/**
|
|
4153
6214
|
* Runs a `goto` step: renders the URL (relative to the current page), waits for
|
|
4154
|
-
* the gate's throttle (`delayMs`), navigates,
|
|
4155
|
-
*
|
|
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.
|
|
4156
6218
|
*
|
|
4157
6219
|
* @throws BlockedError when the response is a block.
|
|
4158
6220
|
*/
|
|
4159
6221
|
async function navigate(step, page, scope, recipe, gate, events) {
|
|
4160
6222
|
const target = renderText(step.url, path => scope.lookup(path));
|
|
4161
6223
|
const url = new URL(target, scope.pageState?.url ?? page.url()).href;
|
|
4162
|
-
|
|
4163
|
-
const response = await
|
|
4164
|
-
|
|
4165
|
-
|
|
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
|
|
4166
6243
|
});
|
|
4167
6244
|
scope.setPage({
|
|
4168
6245
|
url: page.url()
|
|
@@ -4185,18 +6262,26 @@ async function navigate(step, page, scope, recipe, gate, events) {
|
|
|
4185
6262
|
}
|
|
4186
6263
|
|
|
4187
6264
|
const NEXT_LINK_TIMEOUT_MS = 2000;
|
|
4188
|
-
/**
|
|
6265
|
+
/** Steps after which a page may show a new captcha (`session.captcha`). */
|
|
6266
|
+
const CHALLENGING_STEPS = new Set(['click', 'press']);
|
|
6267
|
+
/**
|
|
6268
|
+
* Runs web-mode leaf steps on a browser page. With a captcha guard, a page a
|
|
6269
|
+
* navigation, click or key press leads to is checked for a challenge, solved
|
|
6270
|
+
* before the next step runs.
|
|
6271
|
+
*/
|
|
4189
6272
|
class WebStepRunner {
|
|
4190
6273
|
session;
|
|
4191
6274
|
recipe;
|
|
4192
6275
|
events;
|
|
4193
6276
|
gate;
|
|
6277
|
+
captcha;
|
|
4194
6278
|
page;
|
|
4195
|
-
constructor(session, recipe, events, gate = new RunGate(1, recipe.limits?.delayMs ?? 0)) {
|
|
6279
|
+
constructor(session, recipe, events, gate = new RunGate(1, recipe.limits?.delayMs ?? 0), captcha) {
|
|
4196
6280
|
this.session = session;
|
|
4197
6281
|
this.recipe = recipe;
|
|
4198
6282
|
this.events = events;
|
|
4199
6283
|
this.gate = gate;
|
|
6284
|
+
this.captcha = captcha;
|
|
4200
6285
|
this.page = session.page;
|
|
4201
6286
|
}
|
|
4202
6287
|
/** Clicks and key presses can navigate; keep `page.url` honest after every leaf step. */
|
|
@@ -4206,11 +6291,29 @@ class WebStepRunner {
|
|
|
4206
6291
|
url
|
|
4207
6292
|
});
|
|
4208
6293
|
}
|
|
6294
|
+
/** Navigates; a block page showing a captcha is solved under `onBlock.solve`, and a page reached is checked for one. */
|
|
6295
|
+
async visit(step, scope) {
|
|
6296
|
+
try {
|
|
6297
|
+
await navigate(step, this.page, scope, this.recipe, this.gate, this.events);
|
|
6298
|
+
} catch (error) {
|
|
6299
|
+
if (!(error instanceof BlockedError) || this.captcha?.solvesBlocks !== true) throw error;
|
|
6300
|
+
await this.captcha.solveBlock(this.page, error);
|
|
6301
|
+
return;
|
|
6302
|
+
}
|
|
6303
|
+
await this.captcha?.check(this.page);
|
|
6304
|
+
}
|
|
4209
6305
|
async runLeaf(step, scope) {
|
|
4210
6306
|
switch (step.type) {
|
|
4211
6307
|
case 'goto':
|
|
4212
6308
|
{
|
|
4213
|
-
|
|
6309
|
+
await this.visit(step, scope);
|
|
6310
|
+
break;
|
|
6311
|
+
}
|
|
6312
|
+
case 'captcha':
|
|
6313
|
+
{
|
|
6314
|
+
if (this.captcha === undefined) throw new Error('a captcha step needs a crawler with captcha solvers');
|
|
6315
|
+
await this.captcha.step(this.page, step);
|
|
6316
|
+
break;
|
|
4214
6317
|
}
|
|
4215
6318
|
case 'click':
|
|
4216
6319
|
{
|
|
@@ -4262,6 +6365,7 @@ class WebStepRunner {
|
|
|
4262
6365
|
throw new Error(`"${step.type}" is an api step; this recipe runs in web mode`);
|
|
4263
6366
|
}
|
|
4264
6367
|
}
|
|
6368
|
+
if (CHALLENGING_STEPS.has(step.type)) await this.captcha?.check(this.page);
|
|
4265
6369
|
this.trackUrl(scope);
|
|
4266
6370
|
}
|
|
4267
6371
|
async nextPage(next, scope) {
|
|
@@ -4269,9 +6373,10 @@ class WebStepRunner {
|
|
|
4269
6373
|
if ('url' in next) {
|
|
4270
6374
|
const target = renderText(next.url, path => scope.lookup(path));
|
|
4271
6375
|
if (target === '') return null;
|
|
4272
|
-
await
|
|
6376
|
+
await this.visit({
|
|
6377
|
+
type: 'goto',
|
|
4273
6378
|
url: target
|
|
4274
|
-
},
|
|
6379
|
+
}, scope);
|
|
4275
6380
|
return {
|
|
4276
6381
|
kind: 'url',
|
|
4277
6382
|
url: this.page.url()
|
|
@@ -4284,8 +6389,13 @@ class WebStepRunner {
|
|
|
4284
6389
|
const link = this.page.locator(next.selector).first();
|
|
4285
6390
|
if (!(await appears(link, NEXT_LINK_TIMEOUT_MS))) return null;
|
|
4286
6391
|
const before = this.page.url();
|
|
4287
|
-
await
|
|
4288
|
-
|
|
6392
|
+
const release = await this.gate.request(before);
|
|
6393
|
+
try {
|
|
6394
|
+
await link.click();
|
|
6395
|
+
await this.page.waitForLoadState();
|
|
6396
|
+
} finally {
|
|
6397
|
+
release();
|
|
6398
|
+
}
|
|
4289
6399
|
if (this.page.url() === before) await this.page.waitForTimeout(NEXT_LINK_TIMEOUT_MS / 4);
|
|
4290
6400
|
this.events.emit({
|
|
4291
6401
|
type: 'page:visit',
|
|
@@ -4293,11 +6403,29 @@ class WebStepRunner {
|
|
|
4293
6403
|
url: this.page.url(),
|
|
4294
6404
|
number: (scope.pageState?.number ?? 1) + 1
|
|
4295
6405
|
});
|
|
6406
|
+
await this.captcha?.check(this.page);
|
|
4296
6407
|
return {
|
|
4297
6408
|
kind: 'url',
|
|
4298
6409
|
url: this.page.url()
|
|
4299
6410
|
};
|
|
4300
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
|
+
}
|
|
4301
6429
|
async elements(selector) {
|
|
4302
6430
|
return snapshotElements(selector, this.page);
|
|
4303
6431
|
}
|
|
@@ -4334,15 +6462,30 @@ function accessOptions(lease, headers) {
|
|
|
4334
6462
|
* The bootstrap runs through the same access lease as the crawl that follows,
|
|
4335
6463
|
* so a login and the requests that use its cookies come from one IP.
|
|
4336
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
|
+
*
|
|
4337
6469
|
* @param recipe - The input recipe.
|
|
4338
6470
|
* @param deps - Browser, hooks, events.
|
|
4339
6471
|
* @param lease - The recipe run's access; direct when omitted.
|
|
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.
|
|
4340
6474
|
* @returns The state, or `undefined` when the recipe declares none.
|
|
4341
6475
|
*/
|
|
4342
|
-
async function resolveStorageState(recipe, deps, lease) {
|
|
6476
|
+
async function resolveStorageState(recipe, deps, lease, captcha, owner = {}) {
|
|
4343
6477
|
const saved = await readSavedState(recipe, deps);
|
|
6478
|
+
if (saved !== undefined) return saved;
|
|
4344
6479
|
const session = recipe.session;
|
|
4345
|
-
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;
|
|
4346
6489
|
const browser = await deps.browser();
|
|
4347
6490
|
const browserSession = await browser.newSession({
|
|
4348
6491
|
cookies: session.cookies,
|
|
@@ -4351,11 +6494,32 @@ async function resolveStorageState(recipe, deps, lease) {
|
|
|
4351
6494
|
...accessOptions(lease, session.headers)
|
|
4352
6495
|
});
|
|
4353
6496
|
try {
|
|
4354
|
-
return await runBootstrap(recipe, browserSession, deps);
|
|
6497
|
+
return await runBootstrap(recipe, browserSession, deps, captcha);
|
|
4355
6498
|
} finally {
|
|
4356
6499
|
await browserSession.close();
|
|
4357
6500
|
}
|
|
4358
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
|
+
}
|
|
4359
6523
|
/**
|
|
4360
6524
|
* The storage state saved by an earlier bootstrap (`session.storageStatePath`), if the recipe names one.
|
|
4361
6525
|
*
|
|
@@ -4376,15 +6540,16 @@ async function readSavedState(recipe, deps) {
|
|
|
4376
6540
|
* @param recipe - An input recipe with `session.bootstrap`.
|
|
4377
6541
|
* @param browserSession - Where the steps run.
|
|
4378
6542
|
* @param deps - Hooks, events, `storageStateDir`.
|
|
6543
|
+
* @param captcha - Solves the bootstrap's captchas.
|
|
4379
6544
|
* @returns The kept state.
|
|
4380
6545
|
*/
|
|
4381
|
-
async function runBootstrap(recipe, browserSession, deps) {
|
|
6546
|
+
async function runBootstrap(recipe, browserSession, deps, captcha) {
|
|
4382
6547
|
const bootstrap = recipe.session?.bootstrap;
|
|
4383
6548
|
if (bootstrap === undefined) return {
|
|
4384
6549
|
cookies: [],
|
|
4385
6550
|
origins: []
|
|
4386
6551
|
};
|
|
4387
|
-
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);
|
|
4388
6553
|
const scope = new ExtractionScope();
|
|
4389
6554
|
scope.set('vars', recipe.vars ?? {});
|
|
4390
6555
|
scope.set('start', {
|
|
@@ -4489,6 +6654,66 @@ class RotatingRunner {
|
|
|
4489
6654
|
throw error;
|
|
4490
6655
|
}
|
|
4491
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
|
+
}
|
|
4492
6717
|
async elements(selector, scope) {
|
|
4493
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');
|
|
4494
6719
|
return this.inner.elements(selector, scope);
|
|
@@ -4537,12 +6762,19 @@ class RotatingRunner {
|
|
|
4537
6762
|
* the sink sees one record at a time and `maxRecords` is exact: once reached,
|
|
4538
6763
|
* every later emit returns `stop` before mapping.
|
|
4539
6764
|
*
|
|
4540
|
-
* @param
|
|
6765
|
+
* @param recipe - The input recipe.
|
|
4541
6766
|
* @param output - The output recipe it feeds.
|
|
4542
6767
|
* @param deps - Shared browser, hooks, events, sink and de-duplication.
|
|
4543
6768
|
* @returns What happened.
|
|
4544
6769
|
*/
|
|
4545
|
-
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
|
+
};
|
|
4546
6778
|
const started = Date.now();
|
|
4547
6779
|
const report = {
|
|
4548
6780
|
recipeId: input.id,
|
|
@@ -4555,29 +6787,44 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4555
6787
|
pages: 0,
|
|
4556
6788
|
durationMs: 0
|
|
4557
6789
|
};
|
|
6790
|
+
const captchas = {
|
|
6791
|
+
detected: 0,
|
|
6792
|
+
solved: 0,
|
|
6793
|
+
failed: 0
|
|
6794
|
+
};
|
|
4558
6795
|
const limits = input.limits ?? {};
|
|
4559
|
-
//
|
|
4560
|
-
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);
|
|
4561
6798
|
let stopped = false;
|
|
4562
6799
|
let chain = Promise.resolve();
|
|
4563
6800
|
const unsubscribe = deps.events.subscribe(event => {
|
|
4564
6801
|
if (event.type === 'page:visit' && event.recipeId === input.id) report.pages += 1;
|
|
4565
6802
|
if (event.type === 'step:skip' && event.recipeId === input.id) report.stepsSkipped += 1;
|
|
6803
|
+
if (event.type === 'captcha:detected' && event.recipeId === input.id) captchas.detected += 1;
|
|
6804
|
+
if (event.type === 'captcha:solved' && event.recipeId === input.id) captchas.solved += 1;
|
|
6805
|
+
if (event.type === 'captcha:failed' && event.recipeId === input.id) captchas.failed += 1;
|
|
4566
6806
|
});
|
|
4567
6807
|
deps.events.emit({
|
|
4568
6808
|
type: 'recipe:start',
|
|
4569
6809
|
recipeId: input.id,
|
|
4570
6810
|
mode: input.mode
|
|
4571
6811
|
});
|
|
4572
|
-
deps.dedupe.
|
|
6812
|
+
const dedupe = deps.dedupe.forRecipe();
|
|
4573
6813
|
let runner;
|
|
4574
6814
|
try {
|
|
4575
6815
|
const onBlock = input.session?.onBlock;
|
|
6816
|
+
const solvers = deps.captchaSolvers ?? new CaptchaSolverRegistry();
|
|
6817
|
+
for (const name of captchaSolverNames(input)) solvers.resolve(name);
|
|
6818
|
+
const context = {
|
|
6819
|
+
gate,
|
|
6820
|
+
solvers,
|
|
6821
|
+
budget: new CaptchaBudget(input.session?.captcha?.maxSolves ?? DEFAULT_MAX_SOLVES)
|
|
6822
|
+
};
|
|
4576
6823
|
runner = await RotatingRunner.open({
|
|
4577
6824
|
recipe: input,
|
|
4578
6825
|
events: deps.events,
|
|
4579
6826
|
maxRotations: onBlock?.rotate === true ? onBlock.attempts ?? 2 : 0,
|
|
4580
|
-
open: attempt => openLeased(input, deps,
|
|
6827
|
+
open: attempt => openLeased(input, deps, context, attempt)
|
|
4581
6828
|
});
|
|
4582
6829
|
for (const point of input.start) {
|
|
4583
6830
|
const scope = new ExtractionScope();
|
|
@@ -4612,6 +6859,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4612
6859
|
} finally {
|
|
4613
6860
|
await runner?.dispose();
|
|
4614
6861
|
unsubscribe();
|
|
6862
|
+
if (captchas.detected > 0) report.captchas = captchas;
|
|
4615
6863
|
report.durationMs = Date.now() - started;
|
|
4616
6864
|
deps.events.emit({
|
|
4617
6865
|
type: 'recipe:finish',
|
|
@@ -4669,7 +6917,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4669
6917
|
url,
|
|
4670
6918
|
key: record.key
|
|
4671
6919
|
});
|
|
4672
|
-
} else if (
|
|
6920
|
+
} else if (dedupe.isDuplicate(record)) {
|
|
4673
6921
|
report.duplicates += 1;
|
|
4674
6922
|
deps.events.emit({
|
|
4675
6923
|
type: 'record:duplicate',
|
|
@@ -4737,11 +6985,11 @@ async function leaseAccess(input, deps, attempt) {
|
|
|
4737
6985
|
return lease;
|
|
4738
6986
|
}
|
|
4739
6987
|
/** A lease and a runner opened on it; the lease is released again when opening fails. */
|
|
4740
|
-
async function openLeased(input, deps,
|
|
6988
|
+
async function openLeased(input, deps, context, attempt) {
|
|
4741
6989
|
const lease = await leaseAccess(input, deps, attempt);
|
|
4742
6990
|
try {
|
|
4743
6991
|
return {
|
|
4744
|
-
runner: await openRunner(input, deps,
|
|
6992
|
+
runner: await openRunner(input, deps, context, lease),
|
|
4745
6993
|
lease
|
|
4746
6994
|
};
|
|
4747
6995
|
} catch (error) {
|
|
@@ -4749,9 +6997,20 @@ async function openLeased(input, deps, gate, attempt) {
|
|
|
4749
6997
|
throw error;
|
|
4750
6998
|
}
|
|
4751
6999
|
}
|
|
4752
|
-
async function openRunner(input, deps,
|
|
4753
|
-
|
|
4754
|
-
|
|
7000
|
+
async function openRunner(input, deps, context, lease) {
|
|
7001
|
+
const {
|
|
7002
|
+
gate
|
|
7003
|
+
} = context;
|
|
7004
|
+
const captcha = new CaptchaGuard({
|
|
7005
|
+
recipe: input,
|
|
7006
|
+
events: deps.events,
|
|
7007
|
+
solvers: context.solvers,
|
|
7008
|
+
budget: context.budget,
|
|
7009
|
+
lease
|
|
7010
|
+
});
|
|
7011
|
+
if (lease.cdp !== undefined) return openRemoteRunner(input, deps, context, lease, lease.cdp);
|
|
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);
|
|
4755
7014
|
const session = input.session;
|
|
4756
7015
|
const access = accessOptions(lease, session?.headers);
|
|
4757
7016
|
if (input.mode === 'web') {
|
|
@@ -4763,7 +7022,7 @@ async function openRunner(input, deps, gate, lease) {
|
|
|
4763
7022
|
viewport: session?.viewport,
|
|
4764
7023
|
...access
|
|
4765
7024
|
});
|
|
4766
|
-
return new WebStepRunner(browserSession, input, deps.events, gate);
|
|
7025
|
+
return new WebStepRunner(browserSession, input, deps.events, gate, captcha);
|
|
4767
7026
|
}
|
|
4768
7027
|
const client = await HttpClient.open({
|
|
4769
7028
|
storageState,
|
|
@@ -4775,13 +7034,43 @@ async function openRunner(input, deps, gate, lease) {
|
|
|
4775
7034
|
});
|
|
4776
7035
|
return new ApiStepRunner(client, input, deps.events, gate);
|
|
4777
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
|
+
}
|
|
4778
7059
|
/**
|
|
4779
7060
|
* A web runner in a remote browser. The bootstrap runs in the same remote
|
|
4780
7061
|
* session as the crawl: providers tie the IP and fingerprint to the
|
|
4781
7062
|
* connection, so a login in one connection would not carry to another.
|
|
4782
7063
|
*/
|
|
4783
|
-
async function openRemoteRunner(input, deps,
|
|
7064
|
+
async function openRemoteRunner(input, deps, context, lease, cdp) {
|
|
7065
|
+
const captcha = new CaptchaGuard({
|
|
7066
|
+
recipe: input,
|
|
7067
|
+
events: deps.events,
|
|
7068
|
+
solvers: context.solvers,
|
|
7069
|
+
budget: context.budget,
|
|
7070
|
+
lease
|
|
7071
|
+
});
|
|
4784
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`);
|
|
4785
7074
|
const session = input.session;
|
|
4786
7075
|
const storageState = await readSavedState(input, deps);
|
|
4787
7076
|
const browserSession = await BrowserClient.connectOverCDP(cdp, {
|
|
@@ -4791,36 +7080,51 @@ async function openRemoteRunner(input, deps, gate, lease, cdp) {
|
|
|
4791
7080
|
...accessOptions(lease, session?.headers)
|
|
4792
7081
|
}, input.limits?.timeoutMs);
|
|
4793
7082
|
try {
|
|
4794
|
-
if (storageState === undefined && session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps);
|
|
7083
|
+
if (storageState === undefined && session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps, captcha);
|
|
4795
7084
|
} catch (error) {
|
|
4796
7085
|
await browserSession.close();
|
|
4797
7086
|
throw error;
|
|
4798
7087
|
}
|
|
4799
|
-
return new WebStepRunner(browserSession, input, deps.events, gate);
|
|
7088
|
+
return new WebStepRunner(browserSession, input, deps.events, context.gate, captcha);
|
|
4800
7089
|
}
|
|
4801
7090
|
|
|
4802
7091
|
/**
|
|
4803
|
-
* 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.
|
|
4804
7096
|
*
|
|
4805
7097
|
* @param set - The bound recipes.
|
|
4806
7098
|
* @param deps - Shared browser, hooks, events, sink and de-duplication.
|
|
4807
7099
|
* @param onRecipeError - Whether a failed recipe stops the run.
|
|
7100
|
+
* @param parallel - How many input recipes run at once.
|
|
4808
7101
|
* @returns The report.
|
|
4809
7102
|
*/
|
|
4810
|
-
async function runCrawl(set, deps, onRecipeError) {
|
|
7103
|
+
async function runCrawl(set, deps, onRecipeError, parallel = 1) {
|
|
4811
7104
|
const started = Date.now();
|
|
4812
7105
|
await deps.sink.open(set.output);
|
|
4813
|
-
const
|
|
7106
|
+
const reports = [];
|
|
4814
7107
|
let sink;
|
|
4815
7108
|
try {
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
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));
|
|
4821
7124
|
} finally {
|
|
4822
7125
|
sink = await deps.sink.close();
|
|
4823
7126
|
}
|
|
7127
|
+
const recipes = reports.filter(report => report !== undefined);
|
|
4824
7128
|
return {
|
|
4825
7129
|
outputId: set.output.id,
|
|
4826
7130
|
recipes,
|
|
@@ -4834,15 +7138,19 @@ async function runCrawl(set, deps, onRecipeError) {
|
|
|
4834
7138
|
* Creates a crawler. The browser is launched lazily, on the first recipe or
|
|
4835
7139
|
* bootstrap that needs it, and shared by every run until `close`.
|
|
4836
7140
|
*
|
|
4837
|
-
* @param options - Hooks, sink, events, browser settings, access, policies.
|
|
7141
|
+
* @param options - Hooks, sink, events, browser settings, access, captcha solvers, policies.
|
|
4838
7142
|
* @returns The crawler.
|
|
4839
7143
|
* @throws AccessConfigError when the access config cannot work.
|
|
7144
|
+
* @throws Error when two captcha solvers share a name.
|
|
4840
7145
|
*/
|
|
4841
7146
|
function createCrawler(options = {}) {
|
|
4842
7147
|
const sink = options.sink ?? memorySink();
|
|
4843
7148
|
if (options.resume === true && sink.has === undefined) throw new Error('resume needs a sink that can tell which keys it has (jsonLinesSink with append, memorySink, or a custom sink with `has`)');
|
|
4844
7149
|
const access = new AccessBroker(options.access, options.accessPlugins);
|
|
4845
7150
|
const hooks = new HookRegistry(options.hooks);
|
|
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);
|
|
4846
7154
|
const events = new EventBus(options.onEvent);
|
|
4847
7155
|
let browser;
|
|
4848
7156
|
const launch = () => {
|
|
@@ -4860,8 +7168,12 @@ function createCrawler(options = {}) {
|
|
|
4860
7168
|
resume: options.resume === true,
|
|
4861
7169
|
debug: options.debug === true,
|
|
4862
7170
|
access,
|
|
7171
|
+
captchaSolvers,
|
|
7172
|
+
hosts,
|
|
7173
|
+
profiles,
|
|
7174
|
+
retry: options.retry,
|
|
4863
7175
|
ignoreHTTPSErrors: options.browser?.ignoreHTTPSErrors
|
|
4864
|
-
}, options.onRecipeError ?? 'continue'),
|
|
7176
|
+
}, options.onRecipeError ?? 'continue', options.parallel ?? 1),
|
|
4865
7177
|
async close() {
|
|
4866
7178
|
const launched = browser;
|
|
4867
7179
|
browser = undefined;
|
|
@@ -4877,7 +7189,8 @@ const CRAWL_MODES = ['web', 'api'];
|
|
|
4877
7189
|
const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
|
|
4878
7190
|
/** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
|
|
4879
7191
|
const TAKE_KINDS = ['text', 'html', 'value', 'json'];
|
|
4880
|
-
const BODY_KINDS = ['json', 'html', 'text', 'pdf'];
|
|
7192
|
+
const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown'];
|
|
7193
|
+
const YAML_SCALARS = ['typed', 'text'];
|
|
4881
7194
|
/** How a PDF table aligns a row's values against a cell wrapped over several lines. */
|
|
4882
7195
|
const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
|
|
4883
7196
|
const FIELD_TYPES = ['string', 'number', 'integer', 'boolean', 'date', 'datetime', 'currency', 'url', 'enum', 'array', 'object', 'json'];
|
|
@@ -4889,7 +7202,7 @@ const KEEP_KINDS = ['cookies', 'localStorage'];
|
|
|
4889
7202
|
const WAIT_UNTIL = ['load', 'domcontentloaded', 'networkidle', 'commit'];
|
|
4890
7203
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
|
|
4891
7204
|
/** Steps that only make sense with a live browser page. */
|
|
4892
|
-
const WEB_ONLY_STEPS = ['goto', 'click', 'fill', 'press', 'select', 'scroll', 'wait', 'evaluate', 'screenshot'];
|
|
7205
|
+
const WEB_ONLY_STEPS = ['goto', 'click', 'fill', 'press', 'select', 'scroll', 'wait', 'evaluate', 'screenshot', 'captcha'];
|
|
4893
7206
|
/** Steps that only make sense against an HTTP request context. */
|
|
4894
7207
|
const API_ONLY_STEPS = ['request'];
|
|
4895
7208
|
|
|
@@ -5054,9 +7367,18 @@ const requestStep = z.strictObject({
|
|
|
5054
7367
|
query: stringMap.optional(),
|
|
5055
7368
|
headers: stringMap.optional(),
|
|
5056
7369
|
body: z.unknown().optional(),
|
|
5057
|
-
as: z.enum(BODY_KINDS).optional()
|
|
7370
|
+
as: z.enum(BODY_KINDS).optional(),
|
|
7371
|
+
encoding: z.string().min(1).optional(),
|
|
7372
|
+
delimiter: z.string().length(1).optional(),
|
|
7373
|
+
scalars: z.enum(YAML_SCALARS).optional()
|
|
7374
|
+
}).refine(step => step.delimiter === undefined || step.as === undefined || step.as === 'csv', {
|
|
7375
|
+
message: '"delimiter" reads CSV only: drop it or set "as": "csv"',
|
|
7376
|
+
path: ['delimiter']
|
|
7377
|
+
}).refine(step => step.scalars === undefined || step.as === undefined || step.as === 'yaml', {
|
|
7378
|
+
message: '"scalars" reads YAML only: drop it or set "as": "yaml"',
|
|
7379
|
+
path: ['scalars']
|
|
5058
7380
|
});
|
|
5059
|
-
const tableOnly = ['columns', 'until', 'align'];
|
|
7381
|
+
const tableOnly = ['columns', 'until', 'align', 'sheet', 'headerRows', 'fillDown', 'includeHidden', 'slide', 'shapes'];
|
|
5060
7382
|
const extractStep = z.strictObject({
|
|
5061
7383
|
...base,
|
|
5062
7384
|
type: z.literal('extract'),
|
|
@@ -5067,7 +7389,13 @@ const extractStep = z.strictObject({
|
|
|
5067
7389
|
from: stepId.optional(),
|
|
5068
7390
|
columns: stringMap.optional(),
|
|
5069
7391
|
until: z.string().min(1).optional(),
|
|
5070
|
-
align: z.enum(TABLE_ALIGNS).optional()
|
|
7392
|
+
align: z.enum(TABLE_ALIGNS).optional(),
|
|
7393
|
+
sheet: z.string().min(1).optional(),
|
|
7394
|
+
headerRows: z.int().min(1).optional(),
|
|
7395
|
+
fillDown: z.array(z.string().min(1)).min(1).optional(),
|
|
7396
|
+
includeHidden: z.boolean().optional(),
|
|
7397
|
+
slide: z.string().min(1).optional(),
|
|
7398
|
+
shapes: z.boolean().optional()
|
|
5071
7399
|
}).check(context => {
|
|
5072
7400
|
if (context.value.kind === 'table') return;
|
|
5073
7401
|
for (const key of tableOnly) {
|
|
@@ -5101,6 +7429,19 @@ const hookStep = z.strictObject({
|
|
|
5101
7429
|
name: z.string().min(1),
|
|
5102
7430
|
args: z.record(z.string(), z.unknown()).optional()
|
|
5103
7431
|
});
|
|
7432
|
+
const captchaCheckSchema = z.strictObject({
|
|
7433
|
+
gone: z.boolean().optional(),
|
|
7434
|
+
selector: z.string().min(1).optional()
|
|
7435
|
+
});
|
|
7436
|
+
const captchaStep = z.strictObject({
|
|
7437
|
+
...base,
|
|
7438
|
+
type: z.literal('captcha'),
|
|
7439
|
+
solver: z.string().min(1).optional(),
|
|
7440
|
+
selector: z.string().min(1).optional(),
|
|
7441
|
+
verify: captchaCheckSchema.optional(),
|
|
7442
|
+
attempts: z.int().min(1).max(10).optional(),
|
|
7443
|
+
timeoutMs: z.int().min(1000).optional()
|
|
7444
|
+
});
|
|
5104
7445
|
const emitFlag = z.union([z.literal(true), z.strictObject({
|
|
5105
7446
|
output: z.string().min(1)
|
|
5106
7447
|
})]);
|
|
@@ -5129,7 +7470,7 @@ const paginateStep = z.strictObject({
|
|
|
5129
7470
|
maxPages: z.int().min(1).optional(),
|
|
5130
7471
|
steps
|
|
5131
7472
|
});
|
|
5132
|
-
const stepSchema = z.discriminatedUnion('type', [gotoStep, clickStep, fillStep, pressStep, selectStep, scrollStep, waitStep, evaluateStep, screenshotStep, requestStep, extractStep, assignStep, collectStep, emitStep, hookStep, forEachStep, ifStep, paginateStep]);
|
|
7473
|
+
const stepSchema = z.discriminatedUnion('type', [gotoStep, clickStep, fillStep, pressStep, selectStep, scrollStep, waitStep, evaluateStep, screenshotStep, requestStep, extractStep, assignStep, collectStep, emitStep, hookStep, forEachStep, ifStep, paginateStep, captchaStep]);
|
|
5133
7474
|
|
|
5134
7475
|
const args = z.record(z.string(), z.unknown());
|
|
5135
7476
|
const stringList = z.array(z.string());
|
|
@@ -5277,9 +7618,20 @@ const blockRuleSchema = z.strictObject({
|
|
|
5277
7618
|
text: regexSource.optional()
|
|
5278
7619
|
});
|
|
5279
7620
|
const blockRotationSchema = z.strictObject({
|
|
5280
|
-
rotate: z.boolean(),
|
|
7621
|
+
rotate: z.boolean().optional(),
|
|
7622
|
+
solve: z.boolean().optional(),
|
|
5281
7623
|
attempts: z.int().min(1).max(10).optional()
|
|
5282
7624
|
});
|
|
7625
|
+
const captchaSettingsSchema = z.strictObject({
|
|
7626
|
+
solver: z.string().min(1),
|
|
7627
|
+
detect: z.strictObject({
|
|
7628
|
+
selector: z.string().min(1)
|
|
7629
|
+
}).optional(),
|
|
7630
|
+
verify: captchaCheckSchema.optional(),
|
|
7631
|
+
attempts: z.int().min(1).max(10).optional(),
|
|
7632
|
+
timeoutMs: z.int().min(1000).optional(),
|
|
7633
|
+
maxSolves: z.int().nonnegative().optional()
|
|
7634
|
+
});
|
|
5283
7635
|
const sessionSpecSchema = z.strictObject({
|
|
5284
7636
|
headers: z.record(z.string(), z.string()).optional(),
|
|
5285
7637
|
cookies: z.array(cookieSchema).optional(),
|
|
@@ -5292,13 +7644,22 @@ const sessionSpecSchema = z.strictObject({
|
|
|
5292
7644
|
bootstrap: bootstrapSchema.optional(),
|
|
5293
7645
|
access: sessionAccessSchema.optional(),
|
|
5294
7646
|
blockedWhen: blockRuleSchema.optional(),
|
|
5295
|
-
onBlock: blockRotationSchema.optional()
|
|
7647
|
+
onBlock: blockRotationSchema.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()
|
|
5296
7656
|
});
|
|
5297
7657
|
const limitsSchema = z.strictObject({
|
|
5298
7658
|
maxRecords: z.int().positive().optional(),
|
|
5299
7659
|
delayMs: z.int().nonnegative().optional(),
|
|
5300
7660
|
timeoutMs: z.int().positive().optional(),
|
|
5301
|
-
concurrency: z.int().min(1).max(64).optional()
|
|
7661
|
+
concurrency: z.int().min(1).max(64).optional(),
|
|
7662
|
+
retry: retryRuleSchema.optional()
|
|
5302
7663
|
});
|
|
5303
7664
|
const inputRecipeSchema = z.strictObject({
|
|
5304
7665
|
$schema: z.string().optional(),
|
|
@@ -5595,7 +7956,9 @@ const API_ONLY = new Set(API_ONLY_STEPS);
|
|
|
5595
7956
|
* - web-only steps appear only in web recipes or inside a bootstrap, api-only
|
|
5596
7957
|
* steps only in api recipes, and `next.selector` only in web mode;
|
|
5597
7958
|
* - exactly one emitting construct exists on any path (the two branches of an
|
|
5598
|
-
* `if` are separate paths)
|
|
7959
|
+
* `if` are separate paths);
|
|
7960
|
+
* - a `captcha` step, and `onBlock.solve`, have a solver: their own or
|
|
7961
|
+
* `session.captcha.solver`.
|
|
5599
7962
|
*
|
|
5600
7963
|
* @param input - A parsed input recipe.
|
|
5601
7964
|
* @param output - The parsed output recipe it names.
|
|
@@ -5613,17 +7976,22 @@ function validateBinding(input, output) {
|
|
|
5613
7976
|
const known = new Set(RESERVED);
|
|
5614
7977
|
const varNames = [input.vars ?? {}, ...input.start.map(point => point.vars ?? {})].flatMap(record => Object.keys(record));
|
|
5615
7978
|
for (const name of varNames) known.add(name);
|
|
7979
|
+
const solver = input.session?.captcha !== undefined;
|
|
5616
7980
|
walkSteps(input.steps, 'steps', input.mode, known, report, {
|
|
5617
7981
|
emitting: false,
|
|
5618
|
-
ids: new Set()
|
|
7982
|
+
ids: new Set(),
|
|
7983
|
+
solver
|
|
5619
7984
|
});
|
|
5620
7985
|
if (input.session?.bootstrap !== undefined) {
|
|
5621
7986
|
walkSteps(input.session.bootstrap.steps, 'session.bootstrap.steps', 'web', new Set(RESERVED), report, {
|
|
5622
7987
|
emitting: false,
|
|
5623
7988
|
ids: new Set(),
|
|
5624
|
-
bootstrap: true
|
|
7989
|
+
bootstrap: true,
|
|
7990
|
+
solver
|
|
5625
7991
|
});
|
|
5626
7992
|
}
|
|
7993
|
+
if (!solver && input.session?.onBlock?.solve === true) report('session.onBlock.solve', 'solving a block needs a solver: add session.captcha');
|
|
7994
|
+
if (input.mode === 'api' && input.session?.onBlock?.solve === true) report('session.onBlock.solve', 'captchas are solved on a live page; this recipe runs in api mode (solve them in session.bootstrap)');
|
|
5627
7995
|
for (const [target, rule] of Object.entries(input.mapping)) {
|
|
5628
7996
|
const field = fieldAt(output.fields, target);
|
|
5629
7997
|
if (field === undefined) {
|
|
@@ -5655,7 +8023,11 @@ function walkStep(step, at, mode, known, report, state) {
|
|
|
5655
8023
|
known.add(step.id);
|
|
5656
8024
|
}
|
|
5657
8025
|
if (step.type === 'extract' && step.from !== undefined && !known.has(step.from)) report(`${at}.from`, `"${step.from}" is not a known id`);
|
|
5658
|
-
if (mode === 'web' && state.bootstrap !== true && step.type === 'extract' && step.kind === 'table' && step.from === undefined)
|
|
8026
|
+
if (mode === 'web' && state.bootstrap !== true && step.type === 'extract' && step.kind === 'table' && step.from === undefined) {
|
|
8027
|
+
const foreign = ['sheet', 'slide', 'shapes', 'align', 'includeHidden'].filter(option => step[option] !== undefined);
|
|
8028
|
+
if (foreign.length > 0) report(at, `a "table" extract on a web page reads its HTML tables; ${foreign.map(option => `"${option}"`).join(', ')} belong to PDFs, workbooks or decks (fetch one with a request step in an api recipe)`);
|
|
8029
|
+
}
|
|
8030
|
+
if (step.type === 'captcha' && step.solver === undefined && !state.solver) report(at, 'a captcha step needs a solver: name one ("solver") or add session.captcha');
|
|
5659
8031
|
if (step.type === 'collect' && !known.has(step.into)) report(`${at}.into`, `"${step.into}" is not a known id: set it to [] before the loop that collects into it`);
|
|
5660
8032
|
const nested = () => ({
|
|
5661
8033
|
...state,
|
|
@@ -5818,5 +8190,5 @@ function isSameOutput(document, output) {
|
|
|
5818
8190
|
return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
|
|
5819
8191
|
}
|
|
5820
8192
|
|
|
5821
|
-
export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, findTables, inputRecipeJsonSchema, inputRecipeSchema, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseInputRecipe, parseOutputRecipe, pdfText, readPdf, readRecipeSource, traceLine, tryParseJson, validateBinding };
|
|
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 };
|
|
5822
8194
|
//# sourceMappingURL=index.esm.js.map
|