@opencraw/core 0.1.1 → 0.1.2
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 +2313 -632
- 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 +2 -2
- 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 +40 -0
- package/dist/src/crawl-execution/bootstrap-session.use-case.d.ts +5 -2
- package/dist/src/crawl-execution/crawl-options.config.d.ts +3 -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/run-input-recipe.use-case.d.ts +3 -0
- 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 +9 -1
- 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 +2 -2
- package/dist/src/recipe-schema/input-recipe.contract.d.ts +33 -3
- 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/selection/index.d.ts +1 -1
- package/dist/src/selection/json-text.algorithm.d.ts +30 -3
- package/dist/src/web-steps/run-web-step.use-case.d.ts +10 -2
- 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
|
@@ -1248,6 +1248,928 @@ class BrowserClient {
|
|
|
1248
1248
|
}
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* Runs a body once per item of a list (`over`), or once per live element
|
|
1253
|
+
* matching `selector`, each in a fresh child scope with the item bound under
|
|
1254
|
+
* `as`; emits a record per iteration when asked.
|
|
1255
|
+
*
|
|
1256
|
+
* With a concurrent gate, iterations run as permits allow and records come
|
|
1257
|
+
* out in completion order; without one, in list order.
|
|
1258
|
+
*
|
|
1259
|
+
* @param step - The forEach step.
|
|
1260
|
+
* @param scope - The scope the list lives in.
|
|
1261
|
+
* @param walk - Runs a step list; also carries the emit callback.
|
|
1262
|
+
* @returns `stop` when the crawl reached its record limit.
|
|
1263
|
+
*/
|
|
1264
|
+
async function runForEach(step, scope, walk) {
|
|
1265
|
+
const items = await itemsOf$1(step, scope, walk);
|
|
1266
|
+
const gate = walk.gate;
|
|
1267
|
+
if (gate?.concurrent === true) return runPooled(step, scope, walk, items, gate);
|
|
1268
|
+
for (const item of items) {
|
|
1269
|
+
if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
|
|
1270
|
+
}
|
|
1271
|
+
return 'continue';
|
|
1272
|
+
}
|
|
1273
|
+
async function runIteration(step, scope, walk, item, overrides) {
|
|
1274
|
+
const child = scope.child();
|
|
1275
|
+
child.set(step.as, item);
|
|
1276
|
+
const outcome = await walk.runSteps(step.steps, child, `${walk.path}.steps`, overrides);
|
|
1277
|
+
if (outcome === 'stop' || step.emit === undefined) return outcome;
|
|
1278
|
+
return walk.onEmit(child, step.emit === true ? undefined : step.emit.output);
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Starts iterations as the gate hands out permits. A `stop` or a failure stops
|
|
1282
|
+
* new iterations; the ones in flight finish first, so the runner is never
|
|
1283
|
+
* disposed under them. The first failure is rethrown afterwards.
|
|
1284
|
+
*/
|
|
1285
|
+
async function runPooled(step, scope, walk, items, gate) {
|
|
1286
|
+
let stopped = false;
|
|
1287
|
+
let failure;
|
|
1288
|
+
const tasks = [];
|
|
1289
|
+
const overrides = {
|
|
1290
|
+
gate: gate.nested()
|
|
1291
|
+
};
|
|
1292
|
+
const iterate = async (item, release) => {
|
|
1293
|
+
try {
|
|
1294
|
+
if ((await runIteration(step, scope, walk, item, overrides)) === 'stop') stopped = true;
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
failure ??= {
|
|
1297
|
+
error
|
|
1298
|
+
};
|
|
1299
|
+
} finally {
|
|
1300
|
+
release();
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
for (const item of items) {
|
|
1304
|
+
if (stopped || failure !== undefined) break;
|
|
1305
|
+
const release = await gate.acquire();
|
|
1306
|
+
if (stopped || failure !== undefined) {
|
|
1307
|
+
release();
|
|
1308
|
+
break;
|
|
1309
|
+
}
|
|
1310
|
+
tasks.push(iterate(item, release));
|
|
1311
|
+
}
|
|
1312
|
+
await Promise.allSettled(tasks);
|
|
1313
|
+
if (failure !== undefined) throw failure.error;
|
|
1314
|
+
return stopped ? 'stop' : 'continue';
|
|
1315
|
+
}
|
|
1316
|
+
async function itemsOf$1(step, scope, walk) {
|
|
1317
|
+
if (step.selector !== undefined) {
|
|
1318
|
+
if (walk.runner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
|
|
1319
|
+
return walk.runner.elements(renderText(step.selector, path => scope.lookup(path)), scope);
|
|
1320
|
+
}
|
|
1321
|
+
const list = scope.get(step.over ?? '');
|
|
1322
|
+
return Array.isArray(list) ? list : list === undefined || list === null ? [] : [list];
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Runs a body once per page, each in a fresh child scope, then asks the runner
|
|
1327
|
+
* for the next page until there is none, `until` renders truthy, or `maxPages`
|
|
1328
|
+
* is reached.
|
|
1329
|
+
*
|
|
1330
|
+
* The runner reports the visit of each new page; this only steers.
|
|
1331
|
+
*
|
|
1332
|
+
* @param step - The paginate step.
|
|
1333
|
+
* @param scope - The scope to page in; its page URL advances with each page.
|
|
1334
|
+
* @param walk - Runs a step list; carries the runner and the emit callback.
|
|
1335
|
+
* @returns `stop` when the crawl reached its record limit.
|
|
1336
|
+
*/
|
|
1337
|
+
async function runPaginate(step, scope, walk) {
|
|
1338
|
+
let number = scope.pageState?.number ?? 1;
|
|
1339
|
+
let bound;
|
|
1340
|
+
for (let count = 1;; count += 1) {
|
|
1341
|
+
const page = scope.child();
|
|
1342
|
+
page.setPage({
|
|
1343
|
+
number
|
|
1344
|
+
});
|
|
1345
|
+
if (bound !== undefined) page.set(bound.name, bound.value);
|
|
1346
|
+
const outcome = await walk.runSteps(step.steps, page, `${walk.path}.steps`);
|
|
1347
|
+
if (outcome === 'stop') return 'stop';
|
|
1348
|
+
if (step.until !== undefined && isTruthy(render(step.until, path => page.lookup(path)))) break;
|
|
1349
|
+
if (step.maxPages !== undefined && count >= step.maxPages) break;
|
|
1350
|
+
const next = await walk.runner.nextPage(step.next, page);
|
|
1351
|
+
if (next === null) break;
|
|
1352
|
+
number += 1;
|
|
1353
|
+
if (next.kind === 'url') {
|
|
1354
|
+
scope.setPage({
|
|
1355
|
+
url: next.url,
|
|
1356
|
+
number
|
|
1357
|
+
});
|
|
1358
|
+
bound = undefined;
|
|
1359
|
+
} else {
|
|
1360
|
+
scope.setPage({
|
|
1361
|
+
number
|
|
1362
|
+
});
|
|
1363
|
+
bound = {
|
|
1364
|
+
name: next.name,
|
|
1365
|
+
value: next.value
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return 'continue';
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const FAIL = {
|
|
1373
|
+
policy: 'fail'
|
|
1374
|
+
};
|
|
1375
|
+
/**
|
|
1376
|
+
* The policy for a failing step: the step's own, else the recipe's, else `fail`.
|
|
1377
|
+
*
|
|
1378
|
+
* @param step - The step that failed.
|
|
1379
|
+
* @param recipe - Its recipe.
|
|
1380
|
+
* @returns The policy to apply.
|
|
1381
|
+
*/
|
|
1382
|
+
function resolveErrorPolicy(step, recipe) {
|
|
1383
|
+
return step.onError ?? recipe.onError ?? FAIL;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* How long to wait before a retry: linear backoff.
|
|
1387
|
+
*
|
|
1388
|
+
* @param policy - A retry policy.
|
|
1389
|
+
* @param attempt - The attempt about to be made, starting at 2.
|
|
1390
|
+
* @returns Milliseconds.
|
|
1391
|
+
*/
|
|
1392
|
+
function backoffFor(policy, attempt) {
|
|
1393
|
+
return (policy.backoffMs ?? 0) * (attempt - 1);
|
|
1394
|
+
}
|
|
1395
|
+
function sleep(ms) {
|
|
1396
|
+
return ms <= 0 ? Promise.resolve() : new Promise(resolve => setTimeout(resolve, ms));
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** A response the recipe's `session.blockedWhen` rule (or the default one) says is the site refusing the crawl. */
|
|
1400
|
+
class BlockedError extends Error {
|
|
1401
|
+
url;
|
|
1402
|
+
status;
|
|
1403
|
+
reason;
|
|
1404
|
+
name = 'BlockedError';
|
|
1405
|
+
constructor(url, status, reason) {
|
|
1406
|
+
super(`blocked at ${url}: ${reason}`);
|
|
1407
|
+
this.url = url;
|
|
1408
|
+
this.status = status;
|
|
1409
|
+
this.reason = reason;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/** A step that failed under the `fail` policy: the recipe stops here. */
|
|
1414
|
+
class StepFailure extends Error {
|
|
1415
|
+
stepPath;
|
|
1416
|
+
stepType;
|
|
1417
|
+
name = 'StepFailure';
|
|
1418
|
+
constructor(stepPath, stepType, cause) {
|
|
1419
|
+
super(`step ${stepPath} (${stepType}) failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
1420
|
+
cause
|
|
1421
|
+
});
|
|
1422
|
+
this.stepPath = stepPath;
|
|
1423
|
+
this.stepType = stepType;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/** A single `extract` that matched nothing. */
|
|
1427
|
+
class NoMatchError extends Error {
|
|
1428
|
+
selector;
|
|
1429
|
+
name = 'NoMatchError';
|
|
1430
|
+
constructor(selector) {
|
|
1431
|
+
super(`no match for ${selector}`);
|
|
1432
|
+
this.selector = selector;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Walks a step list in order. Control flow (`forEach`, `if`, `paginate`, `emit`,
|
|
1438
|
+
* `set`, `hook`, `when`, error policies) is handled here; leaf steps go to the
|
|
1439
|
+
* runner. Mode-agnostic: the same walk drives a browser page or an HTTP context.
|
|
1440
|
+
*
|
|
1441
|
+
* @param steps - The steps.
|
|
1442
|
+
* @param scope - The scope to run in.
|
|
1443
|
+
* @param options - Recipe, runner, hooks, events and the emit callback.
|
|
1444
|
+
* @param path - Where these steps are, for messages and events.
|
|
1445
|
+
* @returns `stop` when the emit callback asked to stop.
|
|
1446
|
+
* @throws StepFailure when a step fails under the `fail` policy.
|
|
1447
|
+
*/
|
|
1448
|
+
async function runSteps(steps, scope, options, path = 'steps') {
|
|
1449
|
+
for (const [index, step] of steps.entries()) {
|
|
1450
|
+
if (step.when !== undefined && !isTruthy(render(step.when, lookupIn(scope)))) continue;
|
|
1451
|
+
const at = `${path}.${index}`;
|
|
1452
|
+
const walk = {
|
|
1453
|
+
...options,
|
|
1454
|
+
path: at,
|
|
1455
|
+
runSteps: (inner, innerScope, innerPath, overrides) => runSteps(inner, innerScope, {
|
|
1456
|
+
...options,
|
|
1457
|
+
...overrides
|
|
1458
|
+
}, innerPath)
|
|
1459
|
+
};
|
|
1460
|
+
const outcome = await runWithPolicy(step, scope, walk);
|
|
1461
|
+
if (outcome === 'stop') return 'stop';
|
|
1462
|
+
}
|
|
1463
|
+
return 'continue';
|
|
1464
|
+
}
|
|
1465
|
+
async function runWithPolicy(step, scope, walk) {
|
|
1466
|
+
const policy = resolveErrorPolicy(step, walk.recipe);
|
|
1467
|
+
const attempts = policy.policy === 'retry' ? policy.attempts : 1;
|
|
1468
|
+
let attempt = 1;
|
|
1469
|
+
for (;;) {
|
|
1470
|
+
const started = Date.now();
|
|
1471
|
+
walk.events.emit({
|
|
1472
|
+
type: 'step:start',
|
|
1473
|
+
recipeId: walk.recipe.id,
|
|
1474
|
+
stepType: step.type,
|
|
1475
|
+
stepId: step.id,
|
|
1476
|
+
path: walk.path
|
|
1477
|
+
});
|
|
1478
|
+
try {
|
|
1479
|
+
const outcome = await runOne(step, scope, walk);
|
|
1480
|
+
walk.events.emit({
|
|
1481
|
+
type: 'step:finish',
|
|
1482
|
+
recipeId: walk.recipe.id,
|
|
1483
|
+
stepType: step.type,
|
|
1484
|
+
stepId: step.id,
|
|
1485
|
+
path: walk.path,
|
|
1486
|
+
durationMs: Date.now() - started
|
|
1487
|
+
});
|
|
1488
|
+
return outcome;
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
if (error instanceof StepFailure) throw error;
|
|
1491
|
+
// A block the runner can rotate away from is retried on the new access, without spending a retry attempt.
|
|
1492
|
+
if (error instanceof BlockedError && walk.runner.rotate !== undefined && (await walk.runner.rotate(error))) continue;
|
|
1493
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1494
|
+
if (policy.policy === 'retry' && attempt < attempts) {
|
|
1495
|
+
attempt += 1;
|
|
1496
|
+
walk.events.emit({
|
|
1497
|
+
type: 'step:retry',
|
|
1498
|
+
recipeId: walk.recipe.id,
|
|
1499
|
+
stepType: step.type,
|
|
1500
|
+
stepId: step.id,
|
|
1501
|
+
path: walk.path,
|
|
1502
|
+
attempt,
|
|
1503
|
+
error: message
|
|
1504
|
+
});
|
|
1505
|
+
await sleep(backoffFor(policy, attempt));
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (policy.policy === 'skip') {
|
|
1509
|
+
walk.events.emit({
|
|
1510
|
+
type: 'step:skip',
|
|
1511
|
+
recipeId: walk.recipe.id,
|
|
1512
|
+
stepType: step.type,
|
|
1513
|
+
stepId: step.id,
|
|
1514
|
+
path: walk.path,
|
|
1515
|
+
error: message
|
|
1516
|
+
});
|
|
1517
|
+
return 'continue';
|
|
1518
|
+
}
|
|
1519
|
+
throw new StepFailure(walk.path, step.type, error);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async function runOne(step, scope, walk) {
|
|
1524
|
+
switch (step.type) {
|
|
1525
|
+
case 'forEach':
|
|
1526
|
+
{
|
|
1527
|
+
return runForEach(step, scope, walk);
|
|
1528
|
+
}
|
|
1529
|
+
case 'if':
|
|
1530
|
+
{
|
|
1531
|
+
const branch = isTruthy(render(step.test, lookupIn(scope))) ? 'then' : 'else';
|
|
1532
|
+
walk.events.emit({
|
|
1533
|
+
type: 'step:branch',
|
|
1534
|
+
recipeId: walk.recipe.id,
|
|
1535
|
+
path: walk.path,
|
|
1536
|
+
branch
|
|
1537
|
+
});
|
|
1538
|
+
const chosen = branch === 'then' ? step.steps : step.else ?? [];
|
|
1539
|
+
return walk.runSteps(chosen, scope, `${walk.path}.${branch === 'then' ? 'steps' : 'else'}`);
|
|
1540
|
+
}
|
|
1541
|
+
case 'paginate':
|
|
1542
|
+
{
|
|
1543
|
+
return runPaginate(step, scope, walk);
|
|
1544
|
+
}
|
|
1545
|
+
case 'emit':
|
|
1546
|
+
{
|
|
1547
|
+
return walk.onEmit(scope, step.output);
|
|
1548
|
+
}
|
|
1549
|
+
case 'set':
|
|
1550
|
+
{
|
|
1551
|
+
if (step.id !== undefined) scope.set(step.id, typeof step.value === 'string' ? render(step.value, lookupIn(scope)) : step.value);
|
|
1552
|
+
return 'continue';
|
|
1553
|
+
}
|
|
1554
|
+
case 'collect':
|
|
1555
|
+
{
|
|
1556
|
+
const value = typeof step.value === 'string' ? render(step.value, lookupIn(scope)) : step.value;
|
|
1557
|
+
if (value !== undefined) scope.append(step.into, Array.isArray(value) ? value : [value]);
|
|
1558
|
+
return 'continue';
|
|
1559
|
+
}
|
|
1560
|
+
case 'hook':
|
|
1561
|
+
{
|
|
1562
|
+
const hook = walk.hooks.resolve(step.name);
|
|
1563
|
+
const result = await hook(undefined, renderArgs(step.args ?? {}, scope), {
|
|
1564
|
+
recipeId: walk.recipe.id,
|
|
1565
|
+
scope: scope.snapshot(),
|
|
1566
|
+
log: logThrough(walk)
|
|
1567
|
+
});
|
|
1568
|
+
if (step.id !== undefined) scope.set(step.id, result);
|
|
1569
|
+
return 'continue';
|
|
1570
|
+
}
|
|
1571
|
+
default:
|
|
1572
|
+
{
|
|
1573
|
+
await walk.runner.runLeaf(step, scope);
|
|
1574
|
+
return 'continue';
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
function lookupIn(scope) {
|
|
1579
|
+
return path => scope.lookup(path);
|
|
1580
|
+
}
|
|
1581
|
+
function renderArgs(args, scope) {
|
|
1582
|
+
return Object.fromEntries(Object.entries(args).map(([name, value]) => [name, typeof value === 'string' ? render(value, lookupIn(scope)) : value]));
|
|
1583
|
+
}
|
|
1584
|
+
function logThrough(walk) {
|
|
1585
|
+
return (level, message, meta) => {
|
|
1586
|
+
walk.events.emit({
|
|
1587
|
+
type: level === 'error' ? 'error' : 'warning',
|
|
1588
|
+
recipeId: walk.recipe.id,
|
|
1589
|
+
message: `[${level}] ${message}`,
|
|
1590
|
+
meta
|
|
1591
|
+
});
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* What bounds a recipe run: how many `forEach` iterations may be in flight and
|
|
1597
|
+
* how close together requests may start. One gate per recipe run, shared by
|
|
1598
|
+
* every loop in it, so nested loops never multiply the limit.
|
|
1599
|
+
*
|
|
1600
|
+
* Permits go to the outermost concurrent loop: a loop that runs inside an
|
|
1601
|
+
* iteration already holding a permit runs its body sequentially (see `nested`),
|
|
1602
|
+
* which keeps the total at `permits` and cannot deadlock.
|
|
1603
|
+
*/
|
|
1604
|
+
class RunGate {
|
|
1605
|
+
permits;
|
|
1606
|
+
minIntervalMs;
|
|
1607
|
+
shared;
|
|
1608
|
+
inFlight = 0;
|
|
1609
|
+
waiting = [];
|
|
1610
|
+
lastStart = -Infinity;
|
|
1611
|
+
/**
|
|
1612
|
+
* @param permits - Iterations allowed in flight; 1 is sequential.
|
|
1613
|
+
* @param minIntervalMs - Minimum time between two request starts across the run.
|
|
1614
|
+
* @param shared - The throttle state to share (internal: `nested` gates keep their parent's).
|
|
1615
|
+
*/
|
|
1616
|
+
constructor(permits, minIntervalMs, shared) {
|
|
1617
|
+
this.permits = permits;
|
|
1618
|
+
this.minIntervalMs = minIntervalMs;
|
|
1619
|
+
this.shared = shared;
|
|
1620
|
+
}
|
|
1621
|
+
/** Whether this gate lets more than one iteration run at once. */
|
|
1622
|
+
get concurrent() {
|
|
1623
|
+
return this.permits > 1;
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Takes a permit, waiting for one when all are in flight.
|
|
1627
|
+
*
|
|
1628
|
+
* @returns The release; call it exactly once, when the iteration ends.
|
|
1629
|
+
*/
|
|
1630
|
+
async acquire() {
|
|
1631
|
+
if (this.inFlight >= this.permits) await new Promise(resolve => {
|
|
1632
|
+
this.waiting.push(resolve);
|
|
1633
|
+
});
|
|
1634
|
+
this.inFlight += 1;
|
|
1635
|
+
let released = false;
|
|
1636
|
+
return () => {
|
|
1637
|
+
if (released) return;
|
|
1638
|
+
released = true;
|
|
1639
|
+
this.inFlight -= 1;
|
|
1640
|
+
this.waiting.shift()?.();
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Waits until a request may start: `minIntervalMs` after the previous start,
|
|
1645
|
+
* whichever loop started it. Returns at once when the interval has passed.
|
|
1646
|
+
*/
|
|
1647
|
+
async throttle() {
|
|
1648
|
+
const state = this.shared ?? this;
|
|
1649
|
+
if (state.minIntervalMs <= 0) return;
|
|
1650
|
+
const now = Date.now();
|
|
1651
|
+
const at = Math.max(now, state.lastStart + state.minIntervalMs);
|
|
1652
|
+
state.lastStart = at;
|
|
1653
|
+
await sleep(at - now);
|
|
1654
|
+
}
|
|
1655
|
+
/** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
|
|
1656
|
+
nested() {
|
|
1657
|
+
return new RunGate(1, this.minIntervalMs, this.shared ?? this);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
|
|
1662
|
+
const DEFAULT_BLOCK_RULE = {
|
|
1663
|
+
status: [403, 429],
|
|
1664
|
+
header: {
|
|
1665
|
+
'x-amzn-waf-action': 'challenge'
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1668
|
+
/**
|
|
1669
|
+
* Whether a response is a block.
|
|
1670
|
+
*
|
|
1671
|
+
* @param response - What came back.
|
|
1672
|
+
* @param rule - The recipe's `session.blockedWhen`; `DEFAULT_BLOCK_RULE` when omitted.
|
|
1673
|
+
* @returns The error to throw, or `undefined` when the response is not a block.
|
|
1674
|
+
*/
|
|
1675
|
+
async function detectBlock(response, rule = DEFAULT_BLOCK_RULE) {
|
|
1676
|
+
if (rule.status?.includes(response.status) === true) return new BlockedError(response.url, response.status, `HTTP ${response.status}`);
|
|
1677
|
+
const headers = Object.entries(rule.header ?? {});
|
|
1678
|
+
for (const [name, pattern] of headers) {
|
|
1679
|
+
const value = response.headers[name.toLowerCase()];
|
|
1680
|
+
if (value !== undefined && new RegExp(pattern, 'i').test(value)) return new BlockedError(response.url, response.status, `${name.toLowerCase()}: ${value}`);
|
|
1681
|
+
}
|
|
1682
|
+
if (rule.text !== undefined && response.text !== undefined) {
|
|
1683
|
+
let body = '';
|
|
1684
|
+
try {
|
|
1685
|
+
body = await response.text();
|
|
1686
|
+
} catch {
|
|
1687
|
+
// a body that cannot be read (a redirect, a download) cannot match
|
|
1688
|
+
}
|
|
1689
|
+
const pattern = new RegExp(rule.text, 'i');
|
|
1690
|
+
if (pattern.test(body)) return new BlockedError(response.url, response.status, `body matches /${rule.text}/i`);
|
|
1691
|
+
}
|
|
1692
|
+
return undefined;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* A challenge the engine could not get past: the solver failed, the page did
|
|
1697
|
+
* not confirm it, or the budget ran out. It is a block, so a recipe with
|
|
1698
|
+
* `onBlock.rotate` retries the step on a new access lease (a new IP often
|
|
1699
|
+
* means an easier challenge, or none), then the step's error policy applies.
|
|
1700
|
+
*/
|
|
1701
|
+
class CaptchaError extends BlockedError {
|
|
1702
|
+
kind;
|
|
1703
|
+
attempts;
|
|
1704
|
+
constructor(url, kind, attempts, reason) {
|
|
1705
|
+
super(url, 0, `captcha (${kind}) ${reason}`);
|
|
1706
|
+
this.kind = kind;
|
|
1707
|
+
this.attempts = attempts;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/** The captcha solvers a crawler was given, by name. */
|
|
1712
|
+
class CaptchaSolverRegistry {
|
|
1713
|
+
solvers = new Map();
|
|
1714
|
+
order = [];
|
|
1715
|
+
constructor(solvers = []) {
|
|
1716
|
+
for (const solver of solvers) {
|
|
1717
|
+
if (this.solvers.has(solver.name)) throw new Error(`two captcha solvers are named "${solver.name}"`);
|
|
1718
|
+
this.solvers.set(solver.name, solver);
|
|
1719
|
+
this.order.push(solver.name);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
/** The registered names. */
|
|
1723
|
+
get names() {
|
|
1724
|
+
return [...this.order];
|
|
1725
|
+
}
|
|
1726
|
+
has(name) {
|
|
1727
|
+
return this.solvers.has(name);
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* The solver of that name.
|
|
1731
|
+
*
|
|
1732
|
+
* @param name - As a recipe names it.
|
|
1733
|
+
* @returns The solver.
|
|
1734
|
+
* @throws Error naming what is registered when it is not.
|
|
1735
|
+
*/
|
|
1736
|
+
resolve(name) {
|
|
1737
|
+
const solver = this.solvers.get(name);
|
|
1738
|
+
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`);
|
|
1739
|
+
return solver;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** Default solves a recipe run may spend. */
|
|
1744
|
+
const DEFAULT_MAX_SOLVES = 10;
|
|
1745
|
+
/**
|
|
1746
|
+
* The solves a recipe run may still spend. Every solve costs money: a detector
|
|
1747
|
+
* that matches the wrong element would drain a balance without it. Shared by
|
|
1748
|
+
* every runner of the run, rotations included.
|
|
1749
|
+
*/
|
|
1750
|
+
class CaptchaBudget {
|
|
1751
|
+
max;
|
|
1752
|
+
used = 0;
|
|
1753
|
+
constructor(max = DEFAULT_MAX_SOLVES) {
|
|
1754
|
+
this.max = max;
|
|
1755
|
+
}
|
|
1756
|
+
/** Solves spent so far. */
|
|
1757
|
+
get spent() {
|
|
1758
|
+
return this.used;
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Spends one solve.
|
|
1762
|
+
*
|
|
1763
|
+
* @returns Whether one was left.
|
|
1764
|
+
*/
|
|
1765
|
+
take() {
|
|
1766
|
+
if (this.used >= this.max) return false;
|
|
1767
|
+
this.used += 1;
|
|
1768
|
+
return true;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** The widgets detection looks for when a recipe names none: reCAPTCHA v2, hCaptcha and Turnstile, as a container or as their iframe. */
|
|
1773
|
+
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(', ');
|
|
1774
|
+
/** reCAPTCHA v3 runs without a widget: its script is loaded with the site key as `render`. */
|
|
1775
|
+
const RECAPTCHA_V3_SCRIPT = 'script[src*="recaptcha/api.js?render="], script[src*="recaptcha/enterprise.js?render="]';
|
|
1776
|
+
/** Matches past this many are not looked at: a page does not show more challenges than that. */
|
|
1777
|
+
const MAX_CANDIDATES = 10;
|
|
1778
|
+
/**
|
|
1779
|
+
* The first visible challenge on the page, if any. An element counts only
|
|
1780
|
+
* when visible: sites keep hidden widgets around after a solve, and an
|
|
1781
|
+
* invisible reCAPTCHA shows nothing until it challenges.
|
|
1782
|
+
*
|
|
1783
|
+
* @param page - The live page.
|
|
1784
|
+
* @param selector - Where challenges are; `DEFAULT_CAPTCHA_SELECTOR` when omitted.
|
|
1785
|
+
* @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).
|
|
1786
|
+
* @returns The challenge, or `undefined`.
|
|
1787
|
+
*/
|
|
1788
|
+
async function detectChallenge(page, selector = DEFAULT_CAPTCHA_SELECTOR, options = {}) {
|
|
1789
|
+
const matches = page.locator(selector);
|
|
1790
|
+
const count = Math.min(await matches.count(), MAX_CANDIDATES);
|
|
1791
|
+
for (let index = 0; index < count; index += 1) {
|
|
1792
|
+
const element = matches.nth(index);
|
|
1793
|
+
if (!(await element.isVisible())) continue;
|
|
1794
|
+
const facts = await element.evaluate(readWidget);
|
|
1795
|
+
return challengeOf(facts, page.url(), `${selector} >> nth=${index}`);
|
|
1796
|
+
}
|
|
1797
|
+
if (options.v3 !== true) return undefined;
|
|
1798
|
+
const script = page.locator(RECAPTCHA_V3_SCRIPT).first();
|
|
1799
|
+
if ((await script.count()) === 0) return undefined;
|
|
1800
|
+
const src = (await script.getAttribute('src')) ?? '';
|
|
1801
|
+
const siteKey = new URL(src, page.url()).searchParams.get('render') ?? undefined;
|
|
1802
|
+
return {
|
|
1803
|
+
kind: 'recaptcha-v3',
|
|
1804
|
+
url: page.url(),
|
|
1805
|
+
...(siteKey !== undefined && siteKey !== 'explicit' && {
|
|
1806
|
+
siteKey
|
|
1807
|
+
})
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Whether the page is clear of challenges, tolerating a page that is
|
|
1812
|
+
* navigating (a solve often submits a form): an evaluation cut short by the
|
|
1813
|
+
* navigation counts as not clear yet.
|
|
1814
|
+
*
|
|
1815
|
+
* @param page - The live page.
|
|
1816
|
+
* @param selector - Where challenges are.
|
|
1817
|
+
* @returns Whether no challenge is visible.
|
|
1818
|
+
*/
|
|
1819
|
+
async function isClear(page, selector) {
|
|
1820
|
+
try {
|
|
1821
|
+
return (await detectChallenge(page, selector)) === undefined;
|
|
1822
|
+
} catch {
|
|
1823
|
+
return false;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
/** Runs inside the page. Keep it self-contained; it is serialised. */
|
|
1827
|
+
function readWidget(element) {
|
|
1828
|
+
const source = element.getAttribute('src') ?? element.querySelector('iframe')?.getAttribute('src') ?? '';
|
|
1829
|
+
return {
|
|
1830
|
+
tag: element.tagName.toLowerCase(),
|
|
1831
|
+
className: element.getAttribute('class') ?? '',
|
|
1832
|
+
src: source,
|
|
1833
|
+
siteKey: element.dataset.sitekey,
|
|
1834
|
+
action: element.dataset.action
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
function challengeOf(facts, url, selector) {
|
|
1838
|
+
const siteKey = facts.siteKey ?? siteKeyIn(facts.src, url);
|
|
1839
|
+
return {
|
|
1840
|
+
kind: kindOf(facts),
|
|
1841
|
+
url,
|
|
1842
|
+
selector,
|
|
1843
|
+
...(siteKey !== undefined && {
|
|
1844
|
+
siteKey
|
|
1845
|
+
}),
|
|
1846
|
+
...(facts.action !== undefined && {
|
|
1847
|
+
action: facts.action
|
|
1848
|
+
})
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
function kindOf(facts) {
|
|
1852
|
+
const hint = `${facts.className} ${facts.src}`.toLowerCase();
|
|
1853
|
+
if (hint.includes('recaptcha')) return 'recaptcha-v2';
|
|
1854
|
+
if (hint.includes('hcaptcha')) return 'hcaptcha';
|
|
1855
|
+
if (hint.includes('turnstile') || hint.includes('challenges.cloudflare.com')) return 'turnstile';
|
|
1856
|
+
return facts.tag === 'img' || facts.tag === 'canvas' ? 'image' : 'unknown';
|
|
1857
|
+
}
|
|
1858
|
+
/** A widget iframe carries its site key in the query: `k` (reCAPTCHA) or `sitekey` (hCaptcha). */
|
|
1859
|
+
function siteKeyIn(src, base) {
|
|
1860
|
+
if (src === '') return undefined;
|
|
1861
|
+
try {
|
|
1862
|
+
const parameters = new URL(src, base).searchParams;
|
|
1863
|
+
const hashParameters = new URLSearchParams(new URL(src, base).hash.slice(1));
|
|
1864
|
+
return parameters.get('k') ?? parameters.get('sitekey') ?? hashParameters.get('sitekey') ?? undefined;
|
|
1865
|
+
} catch {
|
|
1866
|
+
return undefined;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** Default solves tried per challenge. */
|
|
1871
|
+
const DEFAULT_CAPTCHA_ATTEMPTS = 3;
|
|
1872
|
+
/** Default time one solve may take: token services take 10 to 60 seconds. */
|
|
1873
|
+
const DEFAULT_CAPTCHA_TIMEOUT_MS = 120_000;
|
|
1874
|
+
/** How long the page has to confirm a solve. */
|
|
1875
|
+
const VERIFY_TIMEOUT_MS = 10_000;
|
|
1876
|
+
const VERIFY_POLL_MS = 250;
|
|
1877
|
+
/**
|
|
1878
|
+
* Gets past one challenge: asks the solver, then checks the page (a solver's
|
|
1879
|
+
* `solved` is a claim; the challenge must be gone and/or the `verify` element
|
|
1880
|
+
* must appear), and tries again with what the page shows next, up to
|
|
1881
|
+
* `attempts`. Each try spends one solve of the run's budget.
|
|
1882
|
+
*
|
|
1883
|
+
* A failed try re-detects the challenge (a widget re-renders after a wrong
|
|
1884
|
+
* answer). When it is gone without the page confirming, the page is reloaded
|
|
1885
|
+
* for a fresh one; when a reload shows none, there is nothing left to solve.
|
|
1886
|
+
*
|
|
1887
|
+
* @param plan - The challenge, the solver and the limits.
|
|
1888
|
+
* @throws CaptchaError when every try failed, or the budget is spent.
|
|
1889
|
+
*/
|
|
1890
|
+
async function resolveCaptcha(plan) {
|
|
1891
|
+
const {
|
|
1892
|
+
recipeId,
|
|
1893
|
+
page,
|
|
1894
|
+
solver,
|
|
1895
|
+
events,
|
|
1896
|
+
budget
|
|
1897
|
+
} = plan;
|
|
1898
|
+
let challenge = plan.challenge;
|
|
1899
|
+
let reason = 'no attempt ran';
|
|
1900
|
+
for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
|
|
1901
|
+
if (!budget.take()) {
|
|
1902
|
+
events.emit({
|
|
1903
|
+
type: 'captcha:budget',
|
|
1904
|
+
recipeId,
|
|
1905
|
+
url: challenge.url,
|
|
1906
|
+
kind: challenge.kind,
|
|
1907
|
+
max: budget.max
|
|
1908
|
+
});
|
|
1909
|
+
throw new CaptchaError(challenge.url, challenge.kind, attempt - 1, `left unsolved: the run's ${budget.max} solves are spent (session.captcha.maxSolves)`);
|
|
1910
|
+
}
|
|
1911
|
+
events.emit({
|
|
1912
|
+
type: 'captcha:solve',
|
|
1913
|
+
recipeId,
|
|
1914
|
+
url: challenge.url,
|
|
1915
|
+
kind: challenge.kind,
|
|
1916
|
+
solver: solver.name,
|
|
1917
|
+
attempt
|
|
1918
|
+
});
|
|
1919
|
+
const started = Date.now();
|
|
1920
|
+
const outcome = await solveOnce(plan, challenge, attempt);
|
|
1921
|
+
if (outcome.status === 'solved' && (await confirmed(page, challenge, plan))) {
|
|
1922
|
+
events.emit({
|
|
1923
|
+
type: 'captcha:solved',
|
|
1924
|
+
recipeId,
|
|
1925
|
+
url: challenge.url,
|
|
1926
|
+
kind: challenge.kind,
|
|
1927
|
+
solver: solver.name,
|
|
1928
|
+
attempt,
|
|
1929
|
+
durationMs: Date.now() - started
|
|
1930
|
+
});
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
reason = outcome.status === 'failed' ? outcome.reason : 'the page still shows the challenge';
|
|
1934
|
+
events.emit({
|
|
1935
|
+
type: 'captcha:failed',
|
|
1936
|
+
recipeId,
|
|
1937
|
+
url: challenge.url,
|
|
1938
|
+
kind: challenge.kind,
|
|
1939
|
+
solver: solver.name,
|
|
1940
|
+
attempt,
|
|
1941
|
+
reason
|
|
1942
|
+
});
|
|
1943
|
+
if (attempt === plan.attempts) break;
|
|
1944
|
+
const next = await nextChallenge(page, challenge, plan.selector);
|
|
1945
|
+
if (next === undefined) return;
|
|
1946
|
+
challenge = next;
|
|
1947
|
+
}
|
|
1948
|
+
throw new CaptchaError(challenge.url, challenge.kind, plan.attempts, `not solved after ${plan.attempts} attempt${plan.attempts === 1 ? '' : 's'}: ${reason}`);
|
|
1949
|
+
}
|
|
1950
|
+
/** Runs the solver once, bounded by the timeout; a throw or a malformed answer is a failure. */
|
|
1951
|
+
async function solveOnce(plan, challenge, attempt) {
|
|
1952
|
+
const controller = new AbortController();
|
|
1953
|
+
let timer;
|
|
1954
|
+
const timeout = new Promise(resolve => {
|
|
1955
|
+
timer = setTimeout(() => {
|
|
1956
|
+
controller.abort();
|
|
1957
|
+
resolve({
|
|
1958
|
+
status: 'failed',
|
|
1959
|
+
reason: `the solver took longer than ${plan.timeoutMs} ms`
|
|
1960
|
+
});
|
|
1961
|
+
}, plan.timeoutMs);
|
|
1962
|
+
});
|
|
1963
|
+
const log = (level, message, meta) => {
|
|
1964
|
+
plan.events.emit({
|
|
1965
|
+
type: level === 'error' ? 'error' : 'warning',
|
|
1966
|
+
recipeId: plan.recipeId,
|
|
1967
|
+
message: `[${plan.solver.name}] ${message}`,
|
|
1968
|
+
meta
|
|
1969
|
+
});
|
|
1970
|
+
};
|
|
1971
|
+
const solving = (async () => {
|
|
1972
|
+
try {
|
|
1973
|
+
return outcomeOf(await plan.solver.solve(challenge, {
|
|
1974
|
+
page: plan.page,
|
|
1975
|
+
lease: plan.lease,
|
|
1976
|
+
attempt,
|
|
1977
|
+
signal: controller.signal,
|
|
1978
|
+
log
|
|
1979
|
+
}));
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
return {
|
|
1982
|
+
status: 'failed',
|
|
1983
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
})();
|
|
1987
|
+
try {
|
|
1988
|
+
return await Promise.race([solving, timeout]);
|
|
1989
|
+
} finally {
|
|
1990
|
+
clearTimeout(timer);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
function outcomeOf(value) {
|
|
1994
|
+
if (typeof value !== 'object' || value === null) return {
|
|
1995
|
+
status: 'failed',
|
|
1996
|
+
reason: 'the solver returned no outcome'
|
|
1997
|
+
};
|
|
1998
|
+
const outcome = value;
|
|
1999
|
+
if (outcome.status === 'solved') return {
|
|
2000
|
+
status: 'solved'
|
|
2001
|
+
};
|
|
2002
|
+
return {
|
|
2003
|
+
status: 'failed',
|
|
2004
|
+
reason: typeof outcome.reason === 'string' ? outcome.reason : 'the solver reported a failure'
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Whether the page confirms the solve: the challenge is gone (unless
|
|
2009
|
+
* `verify.gone` is `false`, or the challenge has no widget, as with reCAPTCHA
|
|
2010
|
+
* v3) and the `verify.selector` element is visible, within ten seconds.
|
|
2011
|
+
*/
|
|
2012
|
+
async function confirmed(page, challenge, plan) {
|
|
2013
|
+
const needGone = plan.verify?.gone !== false && challenge.selector !== undefined;
|
|
2014
|
+
const shown = plan.verify?.selector;
|
|
2015
|
+
if (!needGone && shown === undefined) return true;
|
|
2016
|
+
const deadline = Date.now() + VERIFY_TIMEOUT_MS;
|
|
2017
|
+
for (;;) {
|
|
2018
|
+
const gone = !needGone || (await isClear(page, plan.selector));
|
|
2019
|
+
const visible = shown === undefined || (await isVisible(page, shown));
|
|
2020
|
+
if (gone && visible) return true;
|
|
2021
|
+
if (Date.now() >= deadline) return false;
|
|
2022
|
+
await page.waitForTimeout(VERIFY_POLL_MS);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
async function isVisible(page, selector) {
|
|
2026
|
+
try {
|
|
2027
|
+
return await page.locator(selector).first().isVisible();
|
|
2028
|
+
} catch {
|
|
2029
|
+
return false;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
/** 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. */
|
|
2033
|
+
async function nextChallenge(page, previous, selector) {
|
|
2034
|
+
if (previous.selector === undefined) return previous;
|
|
2035
|
+
const current = await detectChallenge(page, selector);
|
|
2036
|
+
if (current !== undefined) return current;
|
|
2037
|
+
await page.reload();
|
|
2038
|
+
return detectChallenge(page, selector);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Where a web runner meets captchas: after each navigation, click and key
|
|
2043
|
+
* press (`session.captcha`), on a block page (`onBlock.solve`), and at a
|
|
2044
|
+
* `captcha` step.
|
|
2045
|
+
*/
|
|
2046
|
+
class CaptchaGuard {
|
|
2047
|
+
options;
|
|
2048
|
+
constructor(options) {
|
|
2049
|
+
this.options = options;
|
|
2050
|
+
}
|
|
2051
|
+
settings() {
|
|
2052
|
+
const captcha = this.options.recipe.session?.captcha;
|
|
2053
|
+
if (captcha === undefined) return undefined;
|
|
2054
|
+
return {
|
|
2055
|
+
solver: captcha.solver,
|
|
2056
|
+
selector: captcha.detect?.selector ?? DEFAULT_CAPTCHA_SELECTOR,
|
|
2057
|
+
verify: captcha.verify,
|
|
2058
|
+
attempts: captcha.attempts ?? DEFAULT_CAPTCHA_ATTEMPTS,
|
|
2059
|
+
timeoutMs: captcha.timeoutMs ?? DEFAULT_CAPTCHA_TIMEOUT_MS
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
async solve(page, challenge, settings) {
|
|
2063
|
+
const {
|
|
2064
|
+
recipe,
|
|
2065
|
+
events,
|
|
2066
|
+
solvers,
|
|
2067
|
+
budget,
|
|
2068
|
+
lease
|
|
2069
|
+
} = this.options;
|
|
2070
|
+
events.emit({
|
|
2071
|
+
type: 'captcha:detected',
|
|
2072
|
+
recipeId: recipe.id,
|
|
2073
|
+
url: challenge.url,
|
|
2074
|
+
kind: challenge.kind,
|
|
2075
|
+
siteKey: challenge.siteKey
|
|
2076
|
+
});
|
|
2077
|
+
await resolveCaptcha({
|
|
2078
|
+
recipeId: recipe.id,
|
|
2079
|
+
page,
|
|
2080
|
+
challenge,
|
|
2081
|
+
solver: solvers.resolve(settings.solver),
|
|
2082
|
+
selector: settings.selector,
|
|
2083
|
+
verify: settings.verify,
|
|
2084
|
+
attempts: settings.attempts,
|
|
2085
|
+
timeoutMs: settings.timeoutMs,
|
|
2086
|
+
budget,
|
|
2087
|
+
events,
|
|
2088
|
+
lease
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
/** Whether a block page is searched for a challenge before the block counts. */
|
|
2092
|
+
get solvesBlocks() {
|
|
2093
|
+
const session = this.options.recipe.session;
|
|
2094
|
+
return session?.onBlock?.solve === true && session.captcha !== undefined;
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* The automatic check: with `session.captcha`, solves the challenge the page
|
|
2098
|
+
* shows, if any. Without it, nothing is looked for.
|
|
2099
|
+
*
|
|
2100
|
+
* @param page - The live page.
|
|
2101
|
+
* @throws CaptchaError when the challenge could not be solved.
|
|
2102
|
+
*/
|
|
2103
|
+
async check(page) {
|
|
2104
|
+
const settings = this.settings();
|
|
2105
|
+
if (settings === undefined) return;
|
|
2106
|
+
const challenge = await detectChallenge(page, settings.selector);
|
|
2107
|
+
if (challenge !== undefined) await this.solve(page, challenge, settings);
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* A block page under `onBlock.solve`: solves the challenge it shows. A block
|
|
2111
|
+
* without a challenge stays a block.
|
|
2112
|
+
*
|
|
2113
|
+
* @param page - The page showing the block.
|
|
2114
|
+
* @param blocked - The block.
|
|
2115
|
+
* @throws BlockedError (`blocked`) when the page shows no challenge; CaptchaError when it could not be solved.
|
|
2116
|
+
*/
|
|
2117
|
+
async solveBlock(page, blocked) {
|
|
2118
|
+
const settings = this.settings();
|
|
2119
|
+
if (settings === undefined) throw blocked;
|
|
2120
|
+
const challenge = await detectChallenge(page, settings.selector);
|
|
2121
|
+
if (challenge === undefined) throw blocked;
|
|
2122
|
+
await this.solve(page, challenge, settings);
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* A `captcha` step: solves the challenge the page shows, reCAPTCHA v3
|
|
2126
|
+
* included; a page without one is fine.
|
|
2127
|
+
*
|
|
2128
|
+
* @param page - The live page.
|
|
2129
|
+
* @param step - The step.
|
|
2130
|
+
* @throws CaptchaError when the challenge could not be solved.
|
|
2131
|
+
*/
|
|
2132
|
+
async step(page, step) {
|
|
2133
|
+
const base = this.settings();
|
|
2134
|
+
const solver = step.solver ?? base?.solver;
|
|
2135
|
+
if (solver === undefined) throw new Error('a captcha step needs a solver: name one ("solver") or add session.captcha');
|
|
2136
|
+
const settings = {
|
|
2137
|
+
solver,
|
|
2138
|
+
selector: step.selector ?? base?.selector ?? DEFAULT_CAPTCHA_SELECTOR,
|
|
2139
|
+
verify: step.verify ?? base?.verify,
|
|
2140
|
+
attempts: step.attempts ?? base?.attempts ?? DEFAULT_CAPTCHA_ATTEMPTS,
|
|
2141
|
+
timeoutMs: step.timeoutMs ?? base?.timeoutMs ?? DEFAULT_CAPTCHA_TIMEOUT_MS
|
|
2142
|
+
};
|
|
2143
|
+
const challenge = await detectChallenge(page, settings.selector, {
|
|
2144
|
+
v3: true
|
|
2145
|
+
});
|
|
2146
|
+
if (challenge !== undefined) await this.solve(page, challenge, settings);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Every solver name a recipe uses (`session.captcha` and its `captcha`
|
|
2151
|
+
* steps, the bootstrap's included), to check them before the run starts.
|
|
2152
|
+
*
|
|
2153
|
+
* @param recipe - The input recipe.
|
|
2154
|
+
* @returns The names, without repeats.
|
|
2155
|
+
*/
|
|
2156
|
+
function captchaSolverNames(recipe) {
|
|
2157
|
+
const names = new Set();
|
|
2158
|
+
const fallback = recipe.session?.captcha?.solver;
|
|
2159
|
+
if (fallback !== undefined) names.add(fallback);
|
|
2160
|
+
const visit = steps => {
|
|
2161
|
+
for (const step of steps) {
|
|
2162
|
+
if (step.type === 'captcha') names.add(step.solver ?? fallback ?? '');
|
|
2163
|
+
if ('steps' in step) visit(step.steps);
|
|
2164
|
+
if (step.type === 'if') visit(step.else ?? []);
|
|
2165
|
+
}
|
|
2166
|
+
};
|
|
2167
|
+
visit(recipe.steps);
|
|
2168
|
+
visit(recipe.session?.bootstrap?.steps ?? []);
|
|
2169
|
+
names.delete('');
|
|
2170
|
+
return [...names];
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1251
2173
|
/** Fans crawl events out to listeners. A listener that throws never breaks the crawl. */
|
|
1252
2174
|
class EventBus {
|
|
1253
2175
|
listeners = new Set();
|
|
@@ -1307,6 +2229,26 @@ function traceLine(event) {
|
|
|
1307
2229
|
{
|
|
1308
2230
|
return `${indent(1)}↻ new access lease (attempt ${event.attempt})`;
|
|
1309
2231
|
}
|
|
2232
|
+
case 'captcha:detected':
|
|
2233
|
+
{
|
|
2234
|
+
return `${indent(1)}⚿ captcha ${event.kind} on ${event.url}`;
|
|
2235
|
+
}
|
|
2236
|
+
case 'captcha:solve':
|
|
2237
|
+
{
|
|
2238
|
+
return undefined;
|
|
2239
|
+
}
|
|
2240
|
+
case 'captcha:solved':
|
|
2241
|
+
{
|
|
2242
|
+
return `${indent(1)}✓ captcha solved by ${event.solver} (attempt ${event.attempt}, ${event.durationMs} ms)`;
|
|
2243
|
+
}
|
|
2244
|
+
case 'captcha:failed':
|
|
2245
|
+
{
|
|
2246
|
+
return `${indent(1)}✗ captcha attempt ${event.attempt} failed: ${event.reason}`;
|
|
2247
|
+
}
|
|
2248
|
+
case 'captcha:budget':
|
|
2249
|
+
{
|
|
2250
|
+
return `${indent(1)}⛔ captcha left unsolved: the run's ${event.max} solves are spent`;
|
|
2251
|
+
}
|
|
1310
2252
|
case 'page:visit':
|
|
1311
2253
|
{
|
|
1312
2254
|
return `${indent(1)}⇢ page ${event.number} ${event.url}${event.status === undefined || event.status >= 200 && event.status < 300 ? '' : ` [${event.status}]`}`;
|
|
@@ -1574,537 +2516,215 @@ function selectHtml(html, selector) {
|
|
|
1574
2516
|
element: api(element)
|
|
1575
2517
|
})).toArray();
|
|
1576
2518
|
}
|
|
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.
|
|
1699
|
-
*/
|
|
1700
|
-
async function runPooled(step, scope, walk, items, gate) {
|
|
1701
|
-
let stopped = false;
|
|
1702
|
-
let failure;
|
|
1703
|
-
const tasks = [];
|
|
1704
|
-
const overrides = {
|
|
1705
|
-
gate: gate.nested()
|
|
1706
|
-
};
|
|
1707
|
-
const iterate = async (item, release) => {
|
|
1708
|
-
try {
|
|
1709
|
-
if ((await runIteration(step, scope, walk, item, overrides)) === 'stop') stopped = true;
|
|
1710
|
-
} catch (error) {
|
|
1711
|
-
failure ??= {
|
|
1712
|
-
error
|
|
1713
|
-
};
|
|
1714
|
-
} finally {
|
|
1715
|
-
release();
|
|
1716
|
-
}
|
|
1717
|
-
};
|
|
1718
|
-
for (const item of items) {
|
|
1719
|
-
if (stopped || failure !== undefined) break;
|
|
1720
|
-
const release = await gate.acquire();
|
|
1721
|
-
if (stopped || failure !== undefined) {
|
|
1722
|
-
release();
|
|
1723
|
-
break;
|
|
1724
|
-
}
|
|
1725
|
-
tasks.push(iterate(item, release));
|
|
1726
|
-
}
|
|
1727
|
-
await Promise.allSettled(tasks);
|
|
1728
|
-
if (failure !== undefined) throw failure.error;
|
|
1729
|
-
return stopped ? 'stop' : 'continue';
|
|
1730
|
-
}
|
|
1731
|
-
async function itemsOf(step, scope, walk) {
|
|
1732
|
-
if (step.selector !== undefined) {
|
|
1733
|
-
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
|
-
return walk.runner.elements(renderText(step.selector, path => scope.lookup(path)), scope);
|
|
1735
|
-
}
|
|
1736
|
-
const list = scope.get(step.over ?? '');
|
|
1737
|
-
return Array.isArray(list) ? list : list === undefined || list === null ? [] : [list];
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/**
|
|
1741
|
-
* Runs a body once per page, each in a fresh child scope, then asks the runner
|
|
1742
|
-
* for the next page until there is none, `until` renders truthy, or `maxPages`
|
|
1743
|
-
* is reached.
|
|
1744
|
-
*
|
|
1745
|
-
* The runner reports the visit of each new page; this only steers.
|
|
1746
|
-
*
|
|
1747
|
-
* @param step - The paginate step.
|
|
1748
|
-
* @param scope - The scope to page in; its page URL advances with each page.
|
|
1749
|
-
* @param walk - Runs a step list; carries the runner and the emit callback.
|
|
1750
|
-
* @returns `stop` when the crawl reached its record limit.
|
|
1751
|
-
*/
|
|
1752
|
-
async function runPaginate(step, scope, walk) {
|
|
1753
|
-
let number = scope.pageState?.number ?? 1;
|
|
1754
|
-
let bound;
|
|
1755
|
-
for (let count = 1;; count += 1) {
|
|
1756
|
-
const page = scope.child();
|
|
1757
|
-
page.setPage({
|
|
1758
|
-
number
|
|
1759
|
-
});
|
|
1760
|
-
if (bound !== undefined) page.set(bound.name, bound.value);
|
|
1761
|
-
const outcome = await walk.runSteps(step.steps, page, `${walk.path}.steps`);
|
|
1762
|
-
if (outcome === 'stop') return 'stop';
|
|
1763
|
-
if (step.until !== undefined && isTruthy(render(step.until, path => page.lookup(path)))) break;
|
|
1764
|
-
if (step.maxPages !== undefined && count >= step.maxPages) break;
|
|
1765
|
-
const next = await walk.runner.nextPage(step.next, page);
|
|
1766
|
-
if (next === null) break;
|
|
1767
|
-
number += 1;
|
|
1768
|
-
if (next.kind === 'url') {
|
|
1769
|
-
scope.setPage({
|
|
1770
|
-
url: next.url,
|
|
1771
|
-
number
|
|
1772
|
-
});
|
|
1773
|
-
bound = undefined;
|
|
1774
|
-
} else {
|
|
1775
|
-
scope.setPage({
|
|
1776
|
-
number
|
|
1777
|
-
});
|
|
1778
|
-
bound = {
|
|
1779
|
-
name: next.name,
|
|
1780
|
-
value: next.value
|
|
1781
|
-
};
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
return 'continue';
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
const FAIL = {
|
|
1788
|
-
policy: 'fail'
|
|
1789
|
-
};
|
|
2519
|
+
|
|
1790
2520
|
/**
|
|
1791
|
-
* The
|
|
2521
|
+
* The value of an HTML match.
|
|
1792
2522
|
*
|
|
1793
|
-
* @param
|
|
1794
|
-
* @param
|
|
1795
|
-
* @returns The
|
|
2523
|
+
* @param match - A selected element.
|
|
2524
|
+
* @param take - What to take; `text` collapses whitespace.
|
|
2525
|
+
* @returns The value; `undefined` for a missing attribute.
|
|
1796
2526
|
*/
|
|
1797
|
-
function
|
|
1798
|
-
|
|
2527
|
+
function takeFromHtml(match, take) {
|
|
2528
|
+
if (take === 'text') return collapse(match.element.text());
|
|
2529
|
+
if (take === 'html') return match.element.html() ?? '';
|
|
2530
|
+
if (take === 'value') return match.element.val() ?? match.element.attr('value');
|
|
2531
|
+
if (take === 'json') return match.api.html(match.element);
|
|
2532
|
+
return match.element.attr(take.slice('attr:'.length));
|
|
1799
2533
|
}
|
|
1800
2534
|
/**
|
|
1801
|
-
*
|
|
2535
|
+
* The value of a JSON match.
|
|
1802
2536
|
*
|
|
1803
|
-
* @param
|
|
1804
|
-
* @param
|
|
1805
|
-
* @returns
|
|
2537
|
+
* @param node - A JSONPath result.
|
|
2538
|
+
* @param take - `json` keeps the node; `text` stringifies scalars.
|
|
2539
|
+
* @returns The value.
|
|
1806
2540
|
*/
|
|
1807
|
-
function
|
|
1808
|
-
|
|
2541
|
+
function takeFromJson(node, take) {
|
|
2542
|
+
if (take === 'json') return node;
|
|
2543
|
+
if (node === null || node === undefined) return undefined;
|
|
2544
|
+
if (typeof node === 'object') return JSON.stringify(node);
|
|
2545
|
+
return String(node);
|
|
1809
2546
|
}
|
|
1810
|
-
|
|
1811
|
-
|
|
2547
|
+
/**
|
|
2548
|
+
* Text as a human reads it: runs of whitespace collapsed, ends trimmed.
|
|
2549
|
+
*
|
|
2550
|
+
* @param text - Raw text content.
|
|
2551
|
+
* @returns The collapsed text.
|
|
2552
|
+
*/
|
|
2553
|
+
function collapse(text) {
|
|
2554
|
+
return text.replaceAll(/\s+/g, ' ').trim();
|
|
1812
2555
|
}
|
|
1813
2556
|
|
|
1814
|
-
/**
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2557
|
+
/**
|
|
2558
|
+
* JSON that arrives as text: a `<script type="application/ld+json">` body, a
|
|
2559
|
+
* `data-*` attribute, a fetched document read as text. Sites wrap JSON in
|
|
2560
|
+
* things that are not JSON: comment guards around inline JSON-LD, prefixes
|
|
2561
|
+
* that stop a page from loading an API as a script, a JSONP callback, an
|
|
2562
|
+
* assignment in an inline script. Those wrappers are removed, but only after
|
|
2563
|
+
* the text failed to parse as it is, and what is left must still be strict
|
|
2564
|
+
* JSON: nothing is evaluated.
|
|
2565
|
+
*/
|
|
2566
|
+
/** Comment guards sites wrap inline JSON-LD in: a CDATA marker inside a block comment, or an HTML comment. */
|
|
2567
|
+
const GUARDS = /^\s*(?:\/\*\s*<!\[CDATA\[\s*\*\/|<!\[CDATA\[|<!--)\s*|\s*(?:\/\*\s*\]\]>\s*\*\/|\]\]>|-->)\s*$/g;
|
|
2568
|
+
/** Anti-hijacking prefixes: `)]}'` (with or without a comma), `while(1);`, `for(;;);`. */
|
|
2569
|
+
const XSSI_PREFIX = /^\s*(?:\)\]\}'\s*,?|while\s*\(\s*1\s*\)\s*;|for\s*\(\s*;\s*;\s*\)\s*;)/;
|
|
2570
|
+
/** A JSONP call: `callback({...});`, the callback an identifier path. */
|
|
2571
|
+
const JSONP = /^\s*[$A-Z_][\w$]*(?:\.[$A-Z_][\w$]*)*\s*\(([\s\S]*)\)\s*(?:;\s*)?$/i;
|
|
2572
|
+
/** An assignment in an inline script: `window.__STATE__ = {...};`, with `var`, `let` or `const` or none. */
|
|
2573
|
+
const ASSIGNMENT = /^\s*(?:(?:var|let|const)\s+)?[$A-Z_a-z][\w$]*(?:\.[$A-Z_a-z][\w$]*|\[["'][^"']*["']\])*\s*=([\s\S]*)$/;
|
|
2574
|
+
/**
|
|
2575
|
+
* Parses text as JSON, or as JSON inside one of the wrappers sites put around
|
|
2576
|
+
* it: comment guards, an anti-hijacking prefix, a JSONP call, an assignment.
|
|
2577
|
+
* Valid JSON is always read as it is; a wrapper is only removed when that
|
|
2578
|
+
* fails.
|
|
2579
|
+
*
|
|
2580
|
+
* @param text - The text.
|
|
2581
|
+
* @returns The value, or the error the text as it is gave.
|
|
2582
|
+
*/
|
|
2583
|
+
function parseJsonLike(text) {
|
|
2584
|
+
const direct = parseJson$1(text);
|
|
2585
|
+
if ('value' in direct) return direct;
|
|
2586
|
+
const unguarded = text.replaceAll(GUARDS, '');
|
|
2587
|
+
const assigned = ASSIGNMENT.exec(unguarded)?.[1].trim().replace(/;$/, '');
|
|
2588
|
+
const candidates = [unguarded, unguarded.replace(XSSI_PREFIX, ''), JSONP.exec(unguarded)?.[1], assigned];
|
|
2589
|
+
for (const candidate of candidates) {
|
|
2590
|
+
if (candidate === undefined || candidate === text) continue;
|
|
2591
|
+
const parsed = parseJson$1(candidate);
|
|
2592
|
+
if ('value' in parsed) return parsed;
|
|
2593
|
+
}
|
|
2594
|
+
return direct;
|
|
1826
2595
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2596
|
+
/**
|
|
2597
|
+
* Parses JSON Lines (NDJSON): one JSON value per non-blank line.
|
|
2598
|
+
*
|
|
2599
|
+
* @param text - The text.
|
|
2600
|
+
* @param source - Where it came from, for the error.
|
|
2601
|
+
* @returns The values, in order.
|
|
2602
|
+
* @throws Error naming the source and the line that does not parse.
|
|
2603
|
+
*/
|
|
2604
|
+
function parseJsonLines(text, source) {
|
|
2605
|
+
const values = [];
|
|
2606
|
+
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
|
2607
|
+
if (line.trim() === '') continue;
|
|
2608
|
+
const parsed = parseJson$1(line);
|
|
2609
|
+
if ('error' in parsed) throw new Error(`${source}: line ${index + 1} is not JSON (${parsed.error.message})`, {
|
|
2610
|
+
cause: parsed.error
|
|
1836
2611
|
});
|
|
1837
|
-
|
|
1838
|
-
this.stepType = stepType;
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
/** A single `extract` that matched nothing. */
|
|
1842
|
-
class NoMatchError extends Error {
|
|
1843
|
-
selector;
|
|
1844
|
-
name = 'NoMatchError';
|
|
1845
|
-
constructor(selector) {
|
|
1846
|
-
super(`no match for ${selector}`);
|
|
1847
|
-
this.selector = selector;
|
|
2612
|
+
values.push(parsed.value);
|
|
1848
2613
|
}
|
|
2614
|
+
return values;
|
|
1849
2615
|
}
|
|
1850
|
-
|
|
1851
2616
|
/**
|
|
1852
|
-
*
|
|
1853
|
-
* `set`, `hook`, `when`, error policies) is handled here; leaf steps go to the
|
|
1854
|
-
* runner. Mode-agnostic: the same walk drives a browser page or an HTTP context.
|
|
2617
|
+
* Parses text as JSON, wrappers removed (see {@link parseJsonLike}).
|
|
1855
2618
|
*
|
|
1856
|
-
* @param
|
|
1857
|
-
* @
|
|
1858
|
-
* @param options - Recipe, runner, hooks, events and the emit callback.
|
|
1859
|
-
* @param path - Where these steps are, for messages and events.
|
|
1860
|
-
* @returns `stop` when the emit callback asked to stop.
|
|
1861
|
-
* @throws StepFailure when a step fails under the `fail` policy.
|
|
2619
|
+
* @param text - The text.
|
|
2620
|
+
* @returns The value, or `undefined` when it is not JSON.
|
|
1862
2621
|
*/
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
const at = `${path}.${index}`;
|
|
1867
|
-
const walk = {
|
|
1868
|
-
...options,
|
|
1869
|
-
path: at,
|
|
1870
|
-
runSteps: (inner, innerScope, innerPath, overrides) => runSteps(inner, innerScope, {
|
|
1871
|
-
...options,
|
|
1872
|
-
...overrides
|
|
1873
|
-
}, innerPath)
|
|
1874
|
-
};
|
|
1875
|
-
const outcome = await runWithPolicy(step, scope, walk);
|
|
1876
|
-
if (outcome === 'stop') return 'stop';
|
|
1877
|
-
}
|
|
1878
|
-
return 'continue';
|
|
1879
|
-
}
|
|
1880
|
-
async function runWithPolicy(step, scope, walk) {
|
|
1881
|
-
const policy = resolveErrorPolicy(step, walk.recipe);
|
|
1882
|
-
const attempts = policy.policy === 'retry' ? policy.attempts : 1;
|
|
1883
|
-
let attempt = 1;
|
|
1884
|
-
for (;;) {
|
|
1885
|
-
const started = Date.now();
|
|
1886
|
-
walk.events.emit({
|
|
1887
|
-
type: 'step:start',
|
|
1888
|
-
recipeId: walk.recipe.id,
|
|
1889
|
-
stepType: step.type,
|
|
1890
|
-
stepId: step.id,
|
|
1891
|
-
path: walk.path
|
|
1892
|
-
});
|
|
1893
|
-
try {
|
|
1894
|
-
const outcome = await runOne(step, scope, walk);
|
|
1895
|
-
walk.events.emit({
|
|
1896
|
-
type: 'step:finish',
|
|
1897
|
-
recipeId: walk.recipe.id,
|
|
1898
|
-
stepType: step.type,
|
|
1899
|
-
stepId: step.id,
|
|
1900
|
-
path: walk.path,
|
|
1901
|
-
durationMs: Date.now() - started
|
|
1902
|
-
});
|
|
1903
|
-
return outcome;
|
|
1904
|
-
} catch (error) {
|
|
1905
|
-
if (error instanceof StepFailure) throw error;
|
|
1906
|
-
// A block the runner can rotate away from is retried on the new access, without spending a retry attempt.
|
|
1907
|
-
if (error instanceof BlockedError && walk.runner.rotate !== undefined && (await walk.runner.rotate(error))) continue;
|
|
1908
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1909
|
-
if (policy.policy === 'retry' && attempt < attempts) {
|
|
1910
|
-
attempt += 1;
|
|
1911
|
-
walk.events.emit({
|
|
1912
|
-
type: 'step:retry',
|
|
1913
|
-
recipeId: walk.recipe.id,
|
|
1914
|
-
stepType: step.type,
|
|
1915
|
-
stepId: step.id,
|
|
1916
|
-
path: walk.path,
|
|
1917
|
-
attempt,
|
|
1918
|
-
error: message
|
|
1919
|
-
});
|
|
1920
|
-
await sleep(backoffFor(policy, attempt));
|
|
1921
|
-
continue;
|
|
1922
|
-
}
|
|
1923
|
-
if (policy.policy === 'skip') {
|
|
1924
|
-
walk.events.emit({
|
|
1925
|
-
type: 'step:skip',
|
|
1926
|
-
recipeId: walk.recipe.id,
|
|
1927
|
-
stepType: step.type,
|
|
1928
|
-
stepId: step.id,
|
|
1929
|
-
path: walk.path,
|
|
1930
|
-
error: message
|
|
1931
|
-
});
|
|
1932
|
-
return 'continue';
|
|
1933
|
-
}
|
|
1934
|
-
throw new StepFailure(walk.path, step.type, error);
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
2622
|
+
function tryParseJson(text) {
|
|
2623
|
+
const parsed = parseJsonLike(text);
|
|
2624
|
+
return 'value' in parsed ? parsed.value : undefined;
|
|
1937
2625
|
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
path: walk.path,
|
|
1951
|
-
branch
|
|
1952
|
-
});
|
|
1953
|
-
const chosen = branch === 'then' ? step.steps : step.else ?? [];
|
|
1954
|
-
return walk.runSteps(chosen, scope, `${walk.path}.${branch === 'then' ? 'steps' : 'else'}`);
|
|
1955
|
-
}
|
|
1956
|
-
case 'paginate':
|
|
1957
|
-
{
|
|
1958
|
-
return runPaginate(step, scope, walk);
|
|
1959
|
-
}
|
|
1960
|
-
case 'emit':
|
|
1961
|
-
{
|
|
1962
|
-
return walk.onEmit(scope, step.output);
|
|
1963
|
-
}
|
|
1964
|
-
case 'set':
|
|
1965
|
-
{
|
|
1966
|
-
if (step.id !== undefined) scope.set(step.id, typeof step.value === 'string' ? render(step.value, lookupIn(scope)) : step.value);
|
|
1967
|
-
return 'continue';
|
|
1968
|
-
}
|
|
1969
|
-
case 'collect':
|
|
1970
|
-
{
|
|
1971
|
-
const value = typeof step.value === 'string' ? render(step.value, lookupIn(scope)) : step.value;
|
|
1972
|
-
if (value !== undefined) scope.append(step.into, Array.isArray(value) ? value : [value]);
|
|
1973
|
-
return 'continue';
|
|
1974
|
-
}
|
|
1975
|
-
case 'hook':
|
|
1976
|
-
{
|
|
1977
|
-
const hook = walk.hooks.resolve(step.name);
|
|
1978
|
-
const result = await hook(undefined, renderArgs(step.args ?? {}, scope), {
|
|
1979
|
-
recipeId: walk.recipe.id,
|
|
1980
|
-
scope: scope.snapshot(),
|
|
1981
|
-
log: logThrough(walk)
|
|
1982
|
-
});
|
|
1983
|
-
if (step.id !== undefined) scope.set(step.id, result);
|
|
1984
|
-
return 'continue';
|
|
1985
|
-
}
|
|
1986
|
-
default:
|
|
1987
|
-
{
|
|
1988
|
-
await walk.runner.runLeaf(step, scope);
|
|
1989
|
-
return 'continue';
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
2626
|
+
/**
|
|
2627
|
+
* Parses text that must be JSON.
|
|
2628
|
+
*
|
|
2629
|
+
* @param text - The text.
|
|
2630
|
+
* @param id - What the text is, for the error.
|
|
2631
|
+
* @returns The value.
|
|
2632
|
+
* @throws Error when it is not JSON.
|
|
2633
|
+
*/
|
|
2634
|
+
function parseJsonText(text, id) {
|
|
2635
|
+
const parsed = tryParseJson(text);
|
|
2636
|
+
if (parsed === undefined) throw new Error(`"${id}" is text but not JSON`);
|
|
2637
|
+
return parsed;
|
|
1992
2638
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
2639
|
+
/**
|
|
2640
|
+
* The data a value holds, whatever shape it arrived in: JSON text is parsed, a
|
|
2641
|
+
* list of texts becomes the list of its parsable entries, and an entry that
|
|
2642
|
+
* parses to a list is spliced in. Data that is not text is kept as is.
|
|
2643
|
+
*
|
|
2644
|
+
* @param value - A bound value: data, text, or a list of either.
|
|
2645
|
+
* @returns A list of items.
|
|
2646
|
+
*/
|
|
2647
|
+
function dataItemsOf(value) {
|
|
2648
|
+
if (typeof value === 'string') return itemsOf(tryParseJson(value));
|
|
2649
|
+
if (Array.isArray(value)) return value.flatMap(entry => typeof entry === 'string' ? itemsOf(tryParseJson(entry)) : [entry]);
|
|
2650
|
+
return itemsOf(value);
|
|
1995
2651
|
}
|
|
1996
|
-
function
|
|
1997
|
-
|
|
2652
|
+
function itemsOf(parsed) {
|
|
2653
|
+
if (parsed === undefined || parsed === null) return [];
|
|
2654
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
1998
2655
|
}
|
|
1999
|
-
function
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2656
|
+
function parseJson$1(text) {
|
|
2657
|
+
try {
|
|
2658
|
+
return {
|
|
2659
|
+
value: JSON.parse(text)
|
|
2660
|
+
};
|
|
2661
|
+
} catch (error) {
|
|
2662
|
+
return {
|
|
2663
|
+
error: error
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2008
2666
|
}
|
|
2009
2667
|
|
|
2010
2668
|
/**
|
|
2011
|
-
*
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2669
|
+
* The text a `regex` extract reads: per visible slide, its title, its text
|
|
2670
|
+
* boxes in reading order, its tables' rows (cells separated by a tab) and its
|
|
2671
|
+
* notes after `Notes:`; slides separated by a blank line.
|
|
2014
2672
|
*
|
|
2015
|
-
*
|
|
2016
|
-
*
|
|
2017
|
-
* which keeps the total at `permits` and cannot deadlock.
|
|
2673
|
+
* @param document - The deck.
|
|
2674
|
+
* @returns The text.
|
|
2018
2675
|
*/
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
*/
|
|
2031
|
-
constructor(permits, minIntervalMs, shared) {
|
|
2032
|
-
this.permits = permits;
|
|
2033
|
-
this.minIntervalMs = minIntervalMs;
|
|
2034
|
-
this.shared = shared;
|
|
2035
|
-
}
|
|
2036
|
-
/** Whether this gate lets more than one iteration run at once. */
|
|
2037
|
-
get concurrent() {
|
|
2038
|
-
return this.permits > 1;
|
|
2039
|
-
}
|
|
2040
|
-
/**
|
|
2041
|
-
* Takes a permit, waiting for one when all are in flight.
|
|
2042
|
-
*
|
|
2043
|
-
* @returns The release; call it exactly once, when the iteration ends.
|
|
2044
|
-
*/
|
|
2045
|
-
async acquire() {
|
|
2046
|
-
if (this.inFlight >= this.permits) await new Promise(resolve => {
|
|
2047
|
-
this.waiting.push(resolve);
|
|
2048
|
-
});
|
|
2049
|
-
this.inFlight += 1;
|
|
2050
|
-
let released = false;
|
|
2051
|
-
return () => {
|
|
2052
|
-
if (released) return;
|
|
2053
|
-
released = true;
|
|
2054
|
-
this.inFlight -= 1;
|
|
2055
|
-
this.waiting.shift()?.();
|
|
2056
|
-
};
|
|
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
|
-
/** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
|
|
2071
|
-
nested() {
|
|
2072
|
-
return new RunGate(1, this.minIntervalMs, this.shared ?? this);
|
|
2073
|
-
}
|
|
2676
|
+
function deckText(document) {
|
|
2677
|
+
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');
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Whether a value bound in scope is a read deck (so `extract … from` can take it).
|
|
2681
|
+
*
|
|
2682
|
+
* @param value - Anything.
|
|
2683
|
+
* @returns Whether it is a {@link DeckDocument}.
|
|
2684
|
+
*/
|
|
2685
|
+
function isDeckDocument(value) {
|
|
2686
|
+
return typeof value === 'object' && value !== null && value.kind === 'deck' && Array.isArray(value.slides);
|
|
2074
2687
|
}
|
|
2075
2688
|
|
|
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
2689
|
/**
|
|
2084
|
-
*
|
|
2690
|
+
* Reads a `.pptx` presentation into a deck document, through
|
|
2691
|
+
* `@opencraw/office-reader`: every slide's text boxes with their positions,
|
|
2692
|
+
* its tables with their merged cells, its charts' cached data and its notes.
|
|
2693
|
+
* The reader is imported on first use, so recipes that never read a
|
|
2694
|
+
* presentation never load it.
|
|
2085
2695
|
*
|
|
2086
|
-
* @param
|
|
2087
|
-
* @param
|
|
2088
|
-
* @returns The
|
|
2696
|
+
* @param bytes - The file.
|
|
2697
|
+
* @param source - Where it came from, for messages.
|
|
2698
|
+
* @returns The deck.
|
|
2699
|
+
* @throws Error naming the source, and saying what to do, for a file that is
|
|
2700
|
+
* not a readable presentation (a legacy `.ppt`, a password-protected file, an `.odp`…).
|
|
2089
2701
|
*/
|
|
2090
|
-
async function
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2702
|
+
async function readPptxDeck(bytes, source) {
|
|
2703
|
+
const {
|
|
2704
|
+
readPptx,
|
|
2705
|
+
OfficeReadError
|
|
2706
|
+
} = await import('@opencraw/office-reader/pptx');
|
|
2707
|
+
try {
|
|
2708
|
+
const deck = await readPptx(bytes);
|
|
2709
|
+
return {
|
|
2710
|
+
kind: 'deck',
|
|
2711
|
+
width: deck.width,
|
|
2712
|
+
height: deck.height,
|
|
2713
|
+
slides: deck.slides.map(slide => ({
|
|
2714
|
+
...slide,
|
|
2715
|
+
tables: slide.tables.map(table => ({
|
|
2716
|
+
name: table.name,
|
|
2717
|
+
rows: table.rows,
|
|
2718
|
+
merges: table.merges
|
|
2719
|
+
}))
|
|
2720
|
+
}))
|
|
2721
|
+
};
|
|
2722
|
+
} catch (error) {
|
|
2723
|
+
if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
|
|
2724
|
+
cause: error
|
|
2725
|
+
});
|
|
2726
|
+
throw error;
|
|
2106
2727
|
}
|
|
2107
|
-
return undefined;
|
|
2108
2728
|
}
|
|
2109
2729
|
|
|
2110
2730
|
/** Runs closer than this share of the font size join into one cell. */
|
|
@@ -2128,7 +2748,17 @@ const ROW_OVERLAP = 0.4;
|
|
|
2128
2748
|
* @returns The rows.
|
|
2129
2749
|
*/
|
|
2130
2750
|
function assembleRows(runs) {
|
|
2131
|
-
|
|
2751
|
+
return rowsOfCells(joinCells(runs));
|
|
2752
|
+
}
|
|
2753
|
+
/**
|
|
2754
|
+
* Groups finished cells into rows, top to bottom: cells whose vertical extents
|
|
2755
|
+
* overlap share a row. For cells that need no joining, such as a slide's text
|
|
2756
|
+
* boxes, each already a cell.
|
|
2757
|
+
*
|
|
2758
|
+
* @param cells - The cells, in any order.
|
|
2759
|
+
* @returns The rows.
|
|
2760
|
+
*/
|
|
2761
|
+
function rowsOfCells(cells) {
|
|
2132
2762
|
const ordered = [...cells].sort((a, b) => middle(b) - middle(a) || a.x - b.x);
|
|
2133
2763
|
const rows = [];
|
|
2134
2764
|
let top = 0;
|
|
@@ -2305,7 +2935,7 @@ function isPdfDocument(value) {
|
|
|
2305
2935
|
function findTables(document, query) {
|
|
2306
2936
|
const tables = [];
|
|
2307
2937
|
for (const page of document.pages) {
|
|
2308
|
-
const starts = page.rows.flatMap((row, index) => query.header.test(plain(row)) ? [index] : []);
|
|
2938
|
+
const starts = page.rows.flatMap((row, index) => query.header.test(plain$1(row)) ? [index] : []);
|
|
2309
2939
|
for (const [position, start] of starts.entries()) {
|
|
2310
2940
|
const body = bodyOf(page.rows.slice(start + 1, starts[position + 1] ?? page.rows.length), query.until);
|
|
2311
2941
|
tables.push(readTable(page.number, page.rows[start], body, query));
|
|
@@ -2315,7 +2945,7 @@ function findTables(document, query) {
|
|
|
2315
2945
|
}
|
|
2316
2946
|
/** The rows under a header, up to the first one `until` matches. */
|
|
2317
2947
|
function bodyOf(rows, until) {
|
|
2318
|
-
const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain(row)));
|
|
2948
|
+
const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain$1(row)));
|
|
2319
2949
|
return end === -1 ? [...rows] : rows.slice(0, end);
|
|
2320
2950
|
}
|
|
2321
2951
|
function readTable(page, headerRow, body, query) {
|
|
@@ -2330,7 +2960,7 @@ function readTable(page, headerRow, body, query) {
|
|
|
2330
2960
|
page,
|
|
2331
2961
|
title: headers[0]?.text ?? '',
|
|
2332
2962
|
header: headers.map(header => header.text),
|
|
2333
|
-
rows: groups.map(group => named(joinLines(group, headers.length), headers, query.columns))
|
|
2963
|
+
rows: groups.map(group => named$1(joinLines(group, headers.length), headers, query.columns))
|
|
2334
2964
|
};
|
|
2335
2965
|
}
|
|
2336
2966
|
/**
|
|
@@ -2477,74 +3107,606 @@ function assignColumns(spans, headers) {
|
|
|
2477
3107
|
};
|
|
2478
3108
|
}));
|
|
2479
3109
|
}
|
|
2480
|
-
const last = table.at(-1) ?? [];
|
|
2481
|
-
let column = last.reduce((best, score, index) => better(score, last[best]) ? index : best, 0);
|
|
2482
|
-
const columns = [];
|
|
2483
|
-
for (let index = table.length - 1; index >= 0; index -= 1) {
|
|
2484
|
-
columns.unshift(column);
|
|
2485
|
-
column = table[index][column].previous;
|
|
3110
|
+
const last = table.at(-1) ?? [];
|
|
3111
|
+
let column = last.reduce((best, score, index) => better(score, last[best]) ? index : best, 0);
|
|
3112
|
+
const columns = [];
|
|
3113
|
+
for (let index = table.length - 1; index >= 0; index -= 1) {
|
|
3114
|
+
columns.unshift(column);
|
|
3115
|
+
column = table[index][column].previous;
|
|
3116
|
+
}
|
|
3117
|
+
return columns;
|
|
3118
|
+
}
|
|
3119
|
+
function valuesOf(row, bands, width) {
|
|
3120
|
+
const values = Array.from({
|
|
3121
|
+
length: width
|
|
3122
|
+
}, () => '');
|
|
3123
|
+
const ordered = [...row.cells].sort((a, b) => b.y - a.y || a.x - b.x);
|
|
3124
|
+
for (const cell of ordered) {
|
|
3125
|
+
const band = lastOf(bands, candidate => candidate.start <= cell.x + 0.5) ?? bands[0];
|
|
3126
|
+
if (band === undefined) continue;
|
|
3127
|
+
values[band.column] = joinText(values[band.column], cell.text);
|
|
3128
|
+
}
|
|
3129
|
+
return values;
|
|
3130
|
+
}
|
|
3131
|
+
function named$1(values, headers, columns) {
|
|
3132
|
+
if (columns === undefined) return Object.fromEntries(headers.map((header, index) => [header.text, values[index]]));
|
|
3133
|
+
const record = {};
|
|
3134
|
+
for (const [key, pattern] of Object.entries(columns)) {
|
|
3135
|
+
const index = headers.findIndex(header => pattern.test(header.text));
|
|
3136
|
+
if (index !== -1) record[key] = values[index];
|
|
3137
|
+
}
|
|
3138
|
+
return record;
|
|
3139
|
+
}
|
|
3140
|
+
/** Distances closer than this, in points, are a tie. */
|
|
3141
|
+
const TIE = 1;
|
|
3142
|
+
/**
|
|
3143
|
+
* The anchor a line belongs to: the nearest by vertical gap. On a tie (evenly
|
|
3144
|
+
* spaced lines) the anchor below wins: text reads top down, so a wrapped
|
|
3145
|
+
* cell's first line comes before the row it belongs to.
|
|
3146
|
+
*/
|
|
3147
|
+
function nearest(line, candidates) {
|
|
3148
|
+
const row = line.row;
|
|
3149
|
+
const gap = candidate => Math.max(0, candidate.bottom - row.top, row.bottom - candidate.top);
|
|
3150
|
+
const [first, ...rest] = candidates;
|
|
3151
|
+
if (first === undefined) throw new Error('no row to attach a line to');
|
|
3152
|
+
let best = first;
|
|
3153
|
+
for (const candidate of rest) {
|
|
3154
|
+
const difference = gap(candidate.row) - gap(best.row);
|
|
3155
|
+
if (difference < -TIE || Math.abs(difference) <= TIE && candidate.row.bottom < best.row.bottom) best = candidate;
|
|
3156
|
+
}
|
|
3157
|
+
return best;
|
|
3158
|
+
}
|
|
3159
|
+
function overlapOf(header, band) {
|
|
3160
|
+
return Math.max(0, Math.min(header.x + header.width, band.end) - Math.max(header.x, band.start));
|
|
3161
|
+
}
|
|
3162
|
+
function plain$1(row) {
|
|
3163
|
+
return row.cells.map(cell => cell.text).join(' ');
|
|
3164
|
+
}
|
|
3165
|
+
function joinText(first, second) {
|
|
3166
|
+
return first === '' ? second : second === '' ? first : `${first} ${second}`;
|
|
3167
|
+
}
|
|
3168
|
+
function median(values) {
|
|
3169
|
+
const ordered = [...values].sort((a, b) => a - b);
|
|
3170
|
+
return ordered[Math.floor(ordered.length / 2)] ?? 0;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
/**
|
|
3174
|
+
* The text a `regex` extract reads: the visible rows of the visible sheets,
|
|
3175
|
+
* cells separated by a tab, sheets separated by a blank line.
|
|
3176
|
+
*
|
|
3177
|
+
* @param document - The workbook.
|
|
3178
|
+
* @returns The text.
|
|
3179
|
+
*/
|
|
3180
|
+
function workbookText(document) {
|
|
3181
|
+
return document.sheets.filter(sheet => sheet.hidden !== true).map(sheet => visibleRows(sheet).map(row => row.map(String).join('\t')).join('\n')).join('\n\n');
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Whether a value bound in scope is a read workbook (so `extract … from` can take it).
|
|
3185
|
+
*
|
|
3186
|
+
* @param value - Anything.
|
|
3187
|
+
* @returns Whether it is a {@link WorkbookDocument}.
|
|
3188
|
+
*/
|
|
3189
|
+
function isWorkbookDocument(value) {
|
|
3190
|
+
return typeof value === 'object' && value !== null && value.kind === 'workbook' && Array.isArray(value.sheets);
|
|
3191
|
+
}
|
|
3192
|
+
function visibleRows(sheet) {
|
|
3193
|
+
if (sheet.hiddenRows === undefined || sheet.hiddenRows.length === 0) return sheet.rows;
|
|
3194
|
+
const hidden = new Set(sheet.hiddenRows);
|
|
3195
|
+
return sheet.rows.filter((_row, index) => !hidden.has(index));
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
/** The delimiters detection chooses between, in order of preference on a tie. */
|
|
3199
|
+
const CSV_DELIMITERS = [',', ';', '\t', '|'];
|
|
3200
|
+
/** How much of a file delimiter detection looks at. */
|
|
3201
|
+
const SAMPLE_CHARS = 64 * 1024;
|
|
3202
|
+
/** How many lines of the sample delimiter detection scores. */
|
|
3203
|
+
const SAMPLE_LINES = 100;
|
|
3204
|
+
/**
|
|
3205
|
+
* Parses CSV text (RFC 4180, tolerant): a field in double quotes may hold the
|
|
3206
|
+
* delimiter, line breaks and `""` for a quote; a quote inside an unquoted
|
|
3207
|
+
* field is taken literally (`1.0 Hybrid "Cross"`); CRLF, LF and CR all end a
|
|
3208
|
+
* record. Rows are kept as read: ragged rows stay ragged, nothing is trimmed.
|
|
3209
|
+
*
|
|
3210
|
+
* @param text - The decoded file.
|
|
3211
|
+
* @param delimiter - One character.
|
|
3212
|
+
* @returns The rows; a trailing empty line adds no row.
|
|
3213
|
+
*/
|
|
3214
|
+
function parseCsv(text, delimiter) {
|
|
3215
|
+
const rows = [];
|
|
3216
|
+
let row = [];
|
|
3217
|
+
let field = '';
|
|
3218
|
+
let index = 0;
|
|
3219
|
+
while (index < text.length) {
|
|
3220
|
+
const char = text[index];
|
|
3221
|
+
if (char === '"' && field === '') {
|
|
3222
|
+
const quoted = readQuoted(text, index + 1);
|
|
3223
|
+
field = quoted.value;
|
|
3224
|
+
index = quoted.next;
|
|
3225
|
+
} else if (char === delimiter) {
|
|
3226
|
+
row.push(field);
|
|
3227
|
+
field = '';
|
|
3228
|
+
index += 1;
|
|
3229
|
+
} else if (char === '\n' || char === '\r') {
|
|
3230
|
+
row.push(field);
|
|
3231
|
+
rows.push(row);
|
|
3232
|
+
row = [];
|
|
3233
|
+
field = '';
|
|
3234
|
+
index += char === '\r' && text[index + 1] === '\n' ? 2 : 1;
|
|
3235
|
+
} else {
|
|
3236
|
+
const end = plainEnd(text, index + 1, delimiter);
|
|
3237
|
+
field += text.slice(index, end);
|
|
3238
|
+
index = end;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
if (field !== '' || row.length > 0) {
|
|
3242
|
+
row.push(field);
|
|
3243
|
+
rows.push(row);
|
|
3244
|
+
}
|
|
3245
|
+
return rows;
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Chooses the delimiter of a CSV: the candidate whose field count (above one)
|
|
3249
|
+
* is the most consistent over the first lines, so a title line or two above
|
|
3250
|
+
* the header does not mislead it. A file of one column gets `,`.
|
|
3251
|
+
*
|
|
3252
|
+
* `;` with decimal commas (`Panda;15.950,00`), the usual European export,
|
|
3253
|
+
* scores `;`: a comma split gives rows of uneven width.
|
|
3254
|
+
*
|
|
3255
|
+
* @param text - The decoded file.
|
|
3256
|
+
* @returns The delimiter.
|
|
3257
|
+
*/
|
|
3258
|
+
function detectDelimiter(text) {
|
|
3259
|
+
const sample = text.slice(0, SAMPLE_CHARS);
|
|
3260
|
+
const truncated = text.length > SAMPLE_CHARS;
|
|
3261
|
+
let best = {
|
|
3262
|
+
delimiter: ',',
|
|
3263
|
+
score: 0
|
|
3264
|
+
};
|
|
3265
|
+
for (const delimiter of CSV_DELIMITERS) {
|
|
3266
|
+
const rows = parseCsv(sample, delimiter).slice(0, SAMPLE_LINES).filter(row => row.length > 1 || row[0] !== '');
|
|
3267
|
+
// The sample may cut the last line short.
|
|
3268
|
+
if (truncated && rows.length > 1) rows.pop();
|
|
3269
|
+
const score = consistency(rows);
|
|
3270
|
+
if (score > best.score) best = {
|
|
3271
|
+
delimiter,
|
|
3272
|
+
score
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
return best.delimiter;
|
|
3276
|
+
}
|
|
3277
|
+
/** The share of rows with the most common width above one, with that width breaking ties; 0 when no row splits. */
|
|
3278
|
+
function consistency(rows) {
|
|
3279
|
+
const counts = new Map();
|
|
3280
|
+
for (const row of rows) counts.set(row.length, (counts.get(row.length) ?? 0) + 1);
|
|
3281
|
+
let width = 1;
|
|
3282
|
+
let agreeing = 0;
|
|
3283
|
+
for (const [length, count] of counts) {
|
|
3284
|
+
if (!(length > 1 && (count > agreeing || count === agreeing && length > width))) {
|
|
3285
|
+
continue;
|
|
3286
|
+
}
|
|
3287
|
+
width = length;
|
|
3288
|
+
agreeing = count;
|
|
3289
|
+
}
|
|
3290
|
+
return width > 1 ? agreeing / rows.length * 1000 + width : 0;
|
|
3291
|
+
}
|
|
3292
|
+
/** Reads a quoted field starting after its opening quote. */
|
|
3293
|
+
function readQuoted(text, start) {
|
|
3294
|
+
let value = '';
|
|
3295
|
+
let index = start;
|
|
3296
|
+
for (;;) {
|
|
3297
|
+
const quote = text.indexOf('"', index);
|
|
3298
|
+
if (quote === -1) return {
|
|
3299
|
+
value: value + text.slice(index),
|
|
3300
|
+
next: text.length
|
|
3301
|
+
};
|
|
3302
|
+
value += text.slice(index, quote);
|
|
3303
|
+
if (text[quote + 1] !== '"') return {
|
|
3304
|
+
value,
|
|
3305
|
+
next: quote + 1
|
|
3306
|
+
};
|
|
3307
|
+
value += '"';
|
|
3308
|
+
index = quote + 2;
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
/** Where a run of plain characters (no delimiter, no line break) ends. */
|
|
3312
|
+
function plainEnd(text, start, delimiter) {
|
|
3313
|
+
const stops = new Set([delimiter, '\n', '\r']);
|
|
3314
|
+
let index = start;
|
|
3315
|
+
while (index < text.length) {
|
|
3316
|
+
if (stops.has(text[index])) break;
|
|
3317
|
+
index += 1;
|
|
3318
|
+
}
|
|
3319
|
+
return index;
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
/**
|
|
3323
|
+
* Reads decoded CSV text into a workbook of one sheet, named after the file.
|
|
3324
|
+
*
|
|
3325
|
+
* @param text - The decoded file.
|
|
3326
|
+
* @param options - The sheet name, the encoding it was decoded from (for a
|
|
3327
|
+
* probe to report) and a delimiter; without one it is detected.
|
|
3328
|
+
* @returns The workbook.
|
|
3329
|
+
* @throws Error when the delimiter given is not one character.
|
|
3330
|
+
*/
|
|
3331
|
+
function csvWorkbook(text, options) {
|
|
3332
|
+
if (options.delimiter !== undefined && [...options.delimiter].length !== 1) throw new Error(`a CSV delimiter is one character; got "${options.delimiter}"`);
|
|
3333
|
+
const delimiter = options.delimiter ?? detectDelimiter(text);
|
|
3334
|
+
return {
|
|
3335
|
+
kind: 'workbook',
|
|
3336
|
+
sheets: [{
|
|
3337
|
+
name: options.name,
|
|
3338
|
+
rows: parseCsv(text, delimiter)
|
|
3339
|
+
}],
|
|
3340
|
+
csv: {
|
|
3341
|
+
encoding: options.encoding,
|
|
3342
|
+
delimiter
|
|
3343
|
+
}
|
|
3344
|
+
};
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* The name a CSV's sheet takes: the file name without its extension
|
|
3348
|
+
* (`…/prezzo_alle_8.csv` → `prezzo_alle_8`), else `csv`.
|
|
3349
|
+
*
|
|
3350
|
+
* @param url - Where the file came from.
|
|
3351
|
+
* @returns The name.
|
|
3352
|
+
*/
|
|
3353
|
+
function sheetNameOf(url) {
|
|
3354
|
+
let path;
|
|
3355
|
+
try {
|
|
3356
|
+
path = new URL(url).pathname;
|
|
3357
|
+
} catch {
|
|
3358
|
+
path = url;
|
|
3359
|
+
}
|
|
3360
|
+
const file = path.split('/').at(-1) ?? '';
|
|
3361
|
+
let name;
|
|
3362
|
+
try {
|
|
3363
|
+
name = decodeURIComponent(file);
|
|
3364
|
+
} catch {
|
|
3365
|
+
name = file;
|
|
3366
|
+
}
|
|
3367
|
+
name = name.replace(/\.[^.]*$/, '');
|
|
3368
|
+
return name === '' ? 'csv' : name;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/**
|
|
3372
|
+
* Reads an `.xlsx` workbook into a workbook document, through
|
|
3373
|
+
* `@opencraw/office-reader`: every worksheet's cells, with hidden sheets,
|
|
3374
|
+
* hidden rows and merged ranges. Numbers and booleans keep their type (a
|
|
3375
|
+
* cell's `13955.625` is unambiguous; as text, a locale guess could read it as
|
|
3376
|
+
* thirteen million), dates become ISO text, errors their text, empty cells
|
|
3377
|
+
* `''`. Formulas give their cached value. The reader is imported on first use,
|
|
3378
|
+
* so recipes that never read a spreadsheet never load it.
|
|
3379
|
+
*
|
|
3380
|
+
* @param bytes - The file.
|
|
3381
|
+
* @param source - Where it came from, for messages.
|
|
3382
|
+
* @returns The workbook.
|
|
3383
|
+
* @throws Error naming the source, and saying what to do, for a file that is
|
|
3384
|
+
* not a readable workbook (a legacy `.xls`, a password-protected file, an `.ods`…).
|
|
3385
|
+
*/
|
|
3386
|
+
async function readXlsxWorkbook(bytes, source) {
|
|
3387
|
+
const {
|
|
3388
|
+
readXlsx,
|
|
3389
|
+
OfficeReadError
|
|
3390
|
+
} = await import('@opencraw/office-reader/xlsx');
|
|
3391
|
+
try {
|
|
3392
|
+
const book = await readXlsx(bytes);
|
|
3393
|
+
return {
|
|
3394
|
+
kind: 'workbook',
|
|
3395
|
+
sheets: book.sheets.map(sheet => ({
|
|
3396
|
+
name: sheet.name,
|
|
3397
|
+
rows: sheet.rows.map(row => row.map(cell => workbookCell(cell))),
|
|
3398
|
+
hidden: sheet.hidden,
|
|
3399
|
+
hiddenRows: sheet.hiddenRows,
|
|
3400
|
+
merges: sheet.merges
|
|
3401
|
+
}))
|
|
3402
|
+
};
|
|
3403
|
+
} catch (error) {
|
|
3404
|
+
if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
|
|
3405
|
+
cause: error
|
|
3406
|
+
});
|
|
3407
|
+
throw error;
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
/** A typed spreadsheet value as a workbook cell. */
|
|
3411
|
+
function workbookCell(value) {
|
|
3412
|
+
if (value === null) return '';
|
|
3413
|
+
if (value instanceof Date) return isoText(value);
|
|
3414
|
+
if (typeof value === 'object') return value.error;
|
|
3415
|
+
return value;
|
|
3416
|
+
}
|
|
3417
|
+
/** 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. */
|
|
3418
|
+
function isoText(date) {
|
|
3419
|
+
const iso = date.toISOString();
|
|
3420
|
+
if (date.getUTCFullYear() < 1900) return iso.slice(11, 19);
|
|
3421
|
+
return iso.slice(11, 19) === '00:00:00' ? iso.slice(0, 10) : iso.slice(0, 19);
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
/**
|
|
3425
|
+
* Finds every table whose header row matches, in every sheet the query
|
|
3426
|
+
* selects, and reads its rows by column. Unlike a PDF, a grid needs no
|
|
3427
|
+
* geometry: column *i* of a row belongs to header *i*.
|
|
3428
|
+
*
|
|
3429
|
+
* Merged ranges are filled first (the file stores their value in the top-left
|
|
3430
|
+
* cell only), so a brand merged down its models' rows reads on every row, and
|
|
3431
|
+
* a group header merged across its sub-columns names each of them. Empty rows
|
|
3432
|
+
* are skipped.
|
|
3433
|
+
*
|
|
3434
|
+
* @param document - The workbook.
|
|
3435
|
+
* @param query - Which tables, and how to name their columns.
|
|
3436
|
+
* @returns The tables, sheet by sheet, top to bottom.
|
|
3437
|
+
*/
|
|
3438
|
+
function findGridTables(document, query) {
|
|
3439
|
+
const tables = [];
|
|
3440
|
+
for (const sheet of document.sheets) {
|
|
3441
|
+
if (sheet.hidden === true && query.includeHidden !== true) continue;
|
|
3442
|
+
if (query.sheet !== undefined && !query.sheet.test(sheet.name)) continue;
|
|
3443
|
+
tables.push(...sheetTables(sheet, query));
|
|
2486
3444
|
}
|
|
2487
|
-
return
|
|
3445
|
+
return tables;
|
|
2488
3446
|
}
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
3447
|
+
/**
|
|
3448
|
+
* Fills blank cells in the given columns with the value of the row above,
|
|
3449
|
+
* within one table: pivot exports write a group's name on its first row only.
|
|
3450
|
+
*
|
|
3451
|
+
* @param rows - The table's rows, in order.
|
|
3452
|
+
* @param keys - The columns to fill.
|
|
3453
|
+
* @returns The rows, filled (new objects; the input is not changed).
|
|
3454
|
+
*/
|
|
3455
|
+
function fillDown(rows, keys) {
|
|
3456
|
+
const last = new Map();
|
|
3457
|
+
return rows.map(row => {
|
|
3458
|
+
const filled = {
|
|
3459
|
+
...row
|
|
3460
|
+
};
|
|
3461
|
+
for (const key of keys) {
|
|
3462
|
+
const value = filled[key];
|
|
3463
|
+
if (value === undefined || value === '') {
|
|
3464
|
+
const above = last.get(key);
|
|
3465
|
+
if (above !== undefined) filled[key] = above;
|
|
3466
|
+
} else {
|
|
3467
|
+
last.set(key, value);
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
return filled;
|
|
3471
|
+
});
|
|
3472
|
+
}
|
|
3473
|
+
function sheetTables(sheet, query) {
|
|
3474
|
+
const hidden = new Set(query.includeHidden === true ? [] : sheet.hiddenRows);
|
|
3475
|
+
const visible = sheet.rows.flatMap((_row, index) => hidden.has(index) ? [] : [index]);
|
|
3476
|
+
const grid = filledGrid(sheet);
|
|
3477
|
+
const plainRows = new Map(visible.map(index => [index, plain(sheet.rows[index])]));
|
|
3478
|
+
const starts = visible.filter(index => plainRows.get(index) !== '' && query.header.test(plainRows.get(index) ?? ''));
|
|
3479
|
+
const headerRows = query.headerRows ?? 1;
|
|
3480
|
+
return starts.map((start, position) => {
|
|
3481
|
+
const at = visible.indexOf(start);
|
|
3482
|
+
const headerIndexes = visible.slice(at, at + headerRows);
|
|
3483
|
+
const next = starts[position + 1] ?? Infinity;
|
|
3484
|
+
const body = [];
|
|
3485
|
+
const below = visible.slice(at + headerRows);
|
|
3486
|
+
for (const index of below) {
|
|
3487
|
+
if (index >= next) break;
|
|
3488
|
+
if (query.until?.test(plainRows.get(index) ?? '') === true) break;
|
|
3489
|
+
if (plainRows.get(index) !== '') body.push(index);
|
|
3490
|
+
}
|
|
3491
|
+
const columns = columnsOf(grid, headerIndexes, body);
|
|
3492
|
+
const rows = body.map(index => named(grid[index] ?? [], columns, query.columns));
|
|
3493
|
+
return {
|
|
3494
|
+
sheet: sheet.name,
|
|
3495
|
+
title: sheet.rows[start].map(text => clean(text)).find(text => text !== '') ?? '',
|
|
3496
|
+
header: columns.map(column => column.key),
|
|
3497
|
+
rows: query.fillDown === undefined ? rows : fillDown(rows, query.fillDown)
|
|
3498
|
+
};
|
|
3499
|
+
});
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* The table's columns: each column's key joins the distinct texts its header
|
|
3503
|
+
* rows hold (`Insgesamt` over `August 2026` gives `Insgesamt August 2026`). A
|
|
3504
|
+
* column with no header text but data below is keyed by its letter (`A`); one
|
|
3505
|
+
* with neither is dropped. A key seen before gets a counter (`Price 2`).
|
|
3506
|
+
*/
|
|
3507
|
+
function columnsOf(grid, headerIndexes, body) {
|
|
3508
|
+
const width = Math.max(0, ...[...headerIndexes, ...body].map(index => grid[index]?.length ?? 0));
|
|
3509
|
+
const seen = new Map();
|
|
3510
|
+
const columns = [];
|
|
3511
|
+
for (let index = 0; index < width; index += 1) {
|
|
3512
|
+
const parts = [];
|
|
3513
|
+
for (const row of headerIndexes) {
|
|
3514
|
+
const text = clean(grid[row]?.[index] ?? '');
|
|
3515
|
+
if (text !== '' && !parts.includes(text)) parts.push(text);
|
|
3516
|
+
}
|
|
3517
|
+
const hasData = body.some(row => clean(grid[row]?.[index] ?? '') !== '');
|
|
3518
|
+
if (!hasData && parts.length === 0) continue;
|
|
3519
|
+
const base = parts.length === 0 ? columnLetter(index) : parts.join(' ');
|
|
3520
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
3521
|
+
seen.set(base, count);
|
|
3522
|
+
columns.push({
|
|
3523
|
+
index,
|
|
3524
|
+
key: count === 1 ? base : `${base} ${count}`
|
|
3525
|
+
});
|
|
2498
3526
|
}
|
|
2499
|
-
return
|
|
3527
|
+
return columns;
|
|
2500
3528
|
}
|
|
2501
|
-
function named(
|
|
2502
|
-
|
|
3529
|
+
function named(row, columns, patterns) {
|
|
3530
|
+
const value = column => {
|
|
3531
|
+
const cell = row[column.index] ?? '';
|
|
3532
|
+
return typeof cell === 'string' ? cell.trim() : cell;
|
|
3533
|
+
};
|
|
3534
|
+
if (patterns === undefined) return Object.fromEntries(columns.map(column => [column.key, value(column)]));
|
|
2503
3535
|
const record = {};
|
|
2504
|
-
for (const [key, pattern] of Object.entries(
|
|
2505
|
-
const
|
|
2506
|
-
if (
|
|
3536
|
+
for (const [key, pattern] of Object.entries(patterns)) {
|
|
3537
|
+
const column = columns.find(candidate => pattern.test(candidate.key));
|
|
3538
|
+
if (column !== undefined) record[key] = value(column);
|
|
2507
3539
|
}
|
|
2508
3540
|
return record;
|
|
2509
3541
|
}
|
|
2510
|
-
/**
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
let best = first;
|
|
2523
|
-
for (const candidate of rest) {
|
|
2524
|
-
const difference = gap(candidate.row) - gap(best.row);
|
|
2525
|
-
if (difference < -TIE || Math.abs(difference) <= TIE && candidate.row.bottom < best.row.bottom) best = candidate;
|
|
3542
|
+
/** The sheet's rows with every merged range's value copied into the cells it covers. */
|
|
3543
|
+
function filledGrid(sheet) {
|
|
3544
|
+
if (sheet.merges === undefined || sheet.merges.length === 0) return sheet.rows;
|
|
3545
|
+
const grid = sheet.rows.map(row => [...row]);
|
|
3546
|
+
for (const reference of sheet.merges) {
|
|
3547
|
+
const range = rangeOf(reference);
|
|
3548
|
+
if (range === undefined) continue;
|
|
3549
|
+
const value = sheet.rows[range.top]?.[range.left] ?? '';
|
|
3550
|
+
for (let row = range.top; row <= range.bottom; row += 1) {
|
|
3551
|
+
grid[row] ??= [];
|
|
3552
|
+
for (let column = range.left; column <= range.right; column += 1) grid[row][column] = value;
|
|
3553
|
+
}
|
|
2526
3554
|
}
|
|
2527
|
-
return
|
|
3555
|
+
return grid;
|
|
2528
3556
|
}
|
|
2529
|
-
|
|
2530
|
-
|
|
3557
|
+
/** `B10:B13` as 0-based bounds; `undefined` for anything else. */
|
|
3558
|
+
function rangeOf(reference) {
|
|
3559
|
+
const [from, to = from] = reference.split(':', 2);
|
|
3560
|
+
const start = cellOf(from);
|
|
3561
|
+
const end = cellOf(to);
|
|
3562
|
+
if (start === undefined || end === undefined) return undefined;
|
|
3563
|
+
return {
|
|
3564
|
+
top: Math.min(start.row, end.row),
|
|
3565
|
+
left: Math.min(start.column, end.column),
|
|
3566
|
+
bottom: Math.max(start.row, end.row),
|
|
3567
|
+
right: Math.max(start.column, end.column)
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
function cellOf(reference) {
|
|
3571
|
+
const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
|
|
3572
|
+
if (match === null) return undefined;
|
|
3573
|
+
const letters = match[1].toUpperCase();
|
|
3574
|
+
let column = 0;
|
|
3575
|
+
for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
|
|
3576
|
+
return {
|
|
3577
|
+
row: Number(match[2]) - 1,
|
|
3578
|
+
column: column - 1
|
|
3579
|
+
};
|
|
2531
3580
|
}
|
|
3581
|
+
/** `0` → `A`, `25` → `Z`, `26` → `AA`. */
|
|
3582
|
+
function columnLetter(index) {
|
|
3583
|
+
let letters = '';
|
|
3584
|
+
for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) letters = String.fromCodePoint(65 + (rest - 1) % 26) + letters;
|
|
3585
|
+
return letters;
|
|
3586
|
+
}
|
|
3587
|
+
/** A row as a header pattern sees it: its non-empty cells, whitespace collapsed, joined by spaces. */
|
|
2532
3588
|
function plain(row) {
|
|
2533
|
-
return row.
|
|
3589
|
+
return (row ?? []).map(text => clean(text)).filter(text => text !== '').join(' ');
|
|
2534
3590
|
}
|
|
2535
|
-
function
|
|
2536
|
-
return
|
|
3591
|
+
function clean(cell) {
|
|
3592
|
+
return String(cell).replaceAll(/\s+/g, ' ').trim();
|
|
2537
3593
|
}
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
3594
|
+
|
|
3595
|
+
/**
|
|
3596
|
+
* Every `<table>` of an HTML document as a sheet (`table 1`, `table 2`…, in
|
|
3597
|
+
* document order), so the workbook table reader works on web pages and
|
|
3598
|
+
* rendered Markdown: rows in order (`thead`, `tbody`, `tfoot` alike), `th` and
|
|
3599
|
+
* `td` alike, cell text with whitespace collapsed, `colspan` and `rowspan` as
|
|
3600
|
+
* merged ranges. A table inside a table is a sheet of its own, and its rows
|
|
3601
|
+
* are not its parent's.
|
|
3602
|
+
*
|
|
3603
|
+
* @param html - The document.
|
|
3604
|
+
* @returns The tables.
|
|
3605
|
+
*/
|
|
3606
|
+
function htmlTableSheets(html) {
|
|
3607
|
+
const $ = load(html);
|
|
3608
|
+
const tables = $('table').get();
|
|
3609
|
+
return tables.map((table, index) => {
|
|
3610
|
+
const all = $(table).find('tr').get();
|
|
3611
|
+
const rows = all.filter(row => $(row).closest('table').get(0) === table);
|
|
3612
|
+
const grid = [];
|
|
3613
|
+
const merges = [];
|
|
3614
|
+
for (const [rowIndex, row] of rows.entries()) {
|
|
3615
|
+
grid[rowIndex] ??= [];
|
|
3616
|
+
let column = 0;
|
|
3617
|
+
const cells = $(row).children('th, td').get();
|
|
3618
|
+
for (const cell of cells) {
|
|
3619
|
+
while (grid[rowIndex][column] !== undefined) column += 1;
|
|
3620
|
+
const columnSpan = span($(cell).attr('colspan'));
|
|
3621
|
+
const rowSpan = span($(cell).attr('rowspan'));
|
|
3622
|
+
for (let down = 0; down < rowSpan; down += 1) {
|
|
3623
|
+
grid[rowIndex + down] ??= [];
|
|
3624
|
+
for (let across = 0; across < columnSpan; across += 1) grid[rowIndex + down][column + across] = '';
|
|
3625
|
+
}
|
|
3626
|
+
grid[rowIndex][column] = $(cell).text().replaceAll(/\s+/g, ' ').trim();
|
|
3627
|
+
if (columnSpan > 1 || rowSpan > 1) merges.push(`${letter(column)}${rowIndex + 1}:${letter(column + columnSpan - 1)}${rowIndex + rowSpan}`);
|
|
3628
|
+
column += columnSpan;
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
return {
|
|
3632
|
+
name: `table ${index + 1}`,
|
|
3633
|
+
rows: grid.slice(0, rows.length).map(row => Array.from(row, cell => cell ?? '')),
|
|
3634
|
+
merges
|
|
3635
|
+
};
|
|
3636
|
+
});
|
|
3637
|
+
}
|
|
3638
|
+
function span(value) {
|
|
3639
|
+
const number = Math.trunc(Number(value ?? '1'));
|
|
3640
|
+
return Number.isFinite(number) && number > 0 ? Math.min(number, 1000) : 1;
|
|
3641
|
+
}
|
|
3642
|
+
function letter(index) {
|
|
3643
|
+
let letters = '';
|
|
3644
|
+
for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) letters = String.fromCodePoint(65 + (rest - 1) % 26) + letters;
|
|
3645
|
+
return letters;
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
/**
|
|
3649
|
+
* Finds tables in a deck: native tables through the workbook table reader
|
|
3650
|
+
* (merged cells filled, a header over several rows joined), or, with
|
|
3651
|
+
* `shapes`, text boxes laid out as a table through the PDF table reader (a box
|
|
3652
|
+
* is a cell, boxes whose heights overlap a row, columns from where the body's
|
|
3653
|
+
* boxes start). Hidden slides are skipped unless `includeHidden`.
|
|
3654
|
+
*
|
|
3655
|
+
* @param document - The deck.
|
|
3656
|
+
* @param query - Which tables, on which slides, and how to name their columns.
|
|
3657
|
+
* @returns The tables, slide by slide.
|
|
3658
|
+
*/
|
|
3659
|
+
function findDeckTables(document, query) {
|
|
3660
|
+
const slides = document.slides.filter(slide => (query.includeHidden === true || !slide.hidden) && (query.slide === undefined || query.slide.test(slide.title ?? '')));
|
|
3661
|
+
if (query.shapes === true) return shapeTables(document, slides, query);
|
|
3662
|
+
return slides.flatMap(slide => findGridTables({
|
|
3663
|
+
sheets: slide.tables
|
|
3664
|
+
}, query).map(table => ({
|
|
3665
|
+
slide: slide.number,
|
|
3666
|
+
slideTitle: slide.title ?? '',
|
|
3667
|
+
title: table.title,
|
|
3668
|
+
header: table.header,
|
|
3669
|
+
rows: table.rows
|
|
3670
|
+
})));
|
|
3671
|
+
}
|
|
3672
|
+
/** Text boxes as a PDF of one page per slide, y flipped (PDF counts from the bottom), each box one cell. */
|
|
3673
|
+
function shapeTables(document, slides, query) {
|
|
3674
|
+
const pdf = {
|
|
3675
|
+
pages: slides.map(slide => ({
|
|
3676
|
+
number: slide.number,
|
|
3677
|
+
width: document.width,
|
|
3678
|
+
height: document.height,
|
|
3679
|
+
rows: rowsOfCells(slide.shapes.map(shape => ({
|
|
3680
|
+
x: shape.x,
|
|
3681
|
+
y: document.height - shape.y - shape.height,
|
|
3682
|
+
width: shape.width,
|
|
3683
|
+
height: shape.height,
|
|
3684
|
+
text: shape.text.replaceAll(/\s+/g, ' ').trim()
|
|
3685
|
+
})))
|
|
3686
|
+
}))
|
|
3687
|
+
};
|
|
3688
|
+
const titles = new Map(slides.map(slide => [slide.number, slide.title ?? '']));
|
|
3689
|
+
return findTables(pdf, {
|
|
3690
|
+
header: query.header,
|
|
3691
|
+
until: query.until,
|
|
3692
|
+
columns: query.columns,
|
|
3693
|
+
align: query.align
|
|
3694
|
+
}).map(table => ({
|
|
3695
|
+
slide: table.page,
|
|
3696
|
+
slideTitle: titles.get(table.page) ?? '',
|
|
3697
|
+
title: table.title,
|
|
3698
|
+
header: table.header,
|
|
3699
|
+
rows: query.fillDown === undefined ? table.rows : fillDown(table.rows, query.fillDown)
|
|
3700
|
+
}));
|
|
2541
3701
|
}
|
|
2542
3702
|
|
|
2543
3703
|
/**
|
|
2544
3704
|
* Runs an `extract` step against a static document: the value bound under
|
|
2545
3705
|
* `from`, else the scope's current document. `css` reads HTML, `jsonpath`
|
|
2546
|
-
* reads JSON (or a read PDF
|
|
2547
|
-
*
|
|
3706
|
+
* reads JSON (or a read PDF, workbook or deck as data), `table` reads the
|
|
3707
|
+
* tables of a PDF, a workbook (a spreadsheet, a CSV), a deck (a presentation)
|
|
3708
|
+
* or HTML (its `<table>`s), `regex` reads any document as text; `xpath` needs
|
|
3709
|
+
* a live page and is refused here.
|
|
2548
3710
|
*
|
|
2549
3711
|
* A `jsonpath` extract whose `from` is text parses that text as JSON, and a
|
|
2550
3712
|
* list of texts (every `<script type="application/ld+json">` of a page) becomes
|
|
@@ -2563,19 +3725,18 @@ function extractFromDocument(step, scope) {
|
|
|
2563
3725
|
switch (step.kind) {
|
|
2564
3726
|
case 'jsonpath':
|
|
2565
3727
|
{
|
|
2566
|
-
if (document.kind
|
|
2567
|
-
values = selectJson(document.kind === '
|
|
3728
|
+
if (document.kind === 'html' || document.kind === 'text') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
|
|
3729
|
+
values = selectJson(document.kind === 'json' ? document.data : document, selector).map(node => takeFromJson(node, take));
|
|
2568
3730
|
break;
|
|
2569
3731
|
}
|
|
2570
3732
|
case 'table':
|
|
2571
3733
|
{
|
|
2572
|
-
|
|
2573
|
-
values = findTables(document, tableQuery(step, selector));
|
|
3734
|
+
values = readTables(document, step, selector);
|
|
2574
3735
|
break;
|
|
2575
3736
|
}
|
|
2576
3737
|
case 'css':
|
|
2577
3738
|
{
|
|
2578
|
-
if (document.kind !== 'html') throw new Error(`css needs an HTML document; the current document is ${document.kind}`);
|
|
3739
|
+
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
3740
|
values = selectHtml(document.html, selector).map(match => takeFromHtml(match, take));
|
|
2580
3741
|
break;
|
|
2581
3742
|
}
|
|
@@ -2607,20 +3768,82 @@ function extractFromDocument(step, scope) {
|
|
|
2607
3768
|
function renderSelector(selector, scope) {
|
|
2608
3769
|
return hasPlaceholder(selector) ? renderText(selector, path => scope.lookup(path)) : selector;
|
|
2609
3770
|
}
|
|
3771
|
+
/**
|
|
3772
|
+
* The tables a `table` extract finds in a document: a PDF's, a workbook's, a
|
|
3773
|
+
* deck's, or an HTML document's `<table>`s (a fetched page, rendered Markdown,
|
|
3774
|
+
* a live page's content).
|
|
3775
|
+
*
|
|
3776
|
+
* @param document - The document.
|
|
3777
|
+
* @param step - The extract step.
|
|
3778
|
+
* @param scope - Where its selector renders.
|
|
3779
|
+
* @returns The tables.
|
|
3780
|
+
*/
|
|
3781
|
+
function tablesIn(document, step, scope) {
|
|
3782
|
+
return readTables(document, step, renderSelector(step.selector, scope));
|
|
3783
|
+
}
|
|
3784
|
+
function readTables(document, step, selector) {
|
|
3785
|
+
const query = tableQuery(step, selector);
|
|
3786
|
+
if (document.kind === 'html') {
|
|
3787
|
+
refuseOptions(step, ['sheet', 'slide', 'shapes'], 'workbooks and decks', 'HTML');
|
|
3788
|
+
return findGridTables({
|
|
3789
|
+
sheets: htmlTableSheets(document.html)
|
|
3790
|
+
}, {
|
|
3791
|
+
...query,
|
|
3792
|
+
headerRows: step.headerRows,
|
|
3793
|
+
fillDown: step.fillDown
|
|
3794
|
+
});
|
|
3795
|
+
}
|
|
3796
|
+
if (document.kind === 'workbook') {
|
|
3797
|
+
refuseOptions(step, ['slide', 'shapes'], 'decks (presentations)', 'a workbook');
|
|
3798
|
+
return findGridTables(document, {
|
|
3799
|
+
...query,
|
|
3800
|
+
sheet: optionalPattern(step.sheet, 'sheet'),
|
|
3801
|
+
headerRows: step.headerRows,
|
|
3802
|
+
fillDown: step.fillDown,
|
|
3803
|
+
includeHidden: step.includeHidden
|
|
3804
|
+
});
|
|
3805
|
+
}
|
|
3806
|
+
if (document.kind === 'deck') {
|
|
3807
|
+
refuseOptions(step, ['sheet'], 'workbooks (spreadsheets, CSV)', 'a deck');
|
|
3808
|
+
return findDeckTables(document, {
|
|
3809
|
+
...query,
|
|
3810
|
+
slide: optionalPattern(step.slide, 'slide'),
|
|
3811
|
+
shapes: step.shapes,
|
|
3812
|
+
headerRows: step.headerRows,
|
|
3813
|
+
fillDown: step.fillDown,
|
|
3814
|
+
includeHidden: step.includeHidden
|
|
3815
|
+
});
|
|
3816
|
+
}
|
|
3817
|
+
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")`);
|
|
3818
|
+
refuseOptions(step, ['sheet', 'headerRows', 'includeHidden', 'slide', 'shapes'], 'workbooks and decks', 'a PDF');
|
|
3819
|
+
const tables = findTables(document, query);
|
|
3820
|
+
return step.fillDown === undefined ? tables : tables.map(table => ({
|
|
3821
|
+
...table,
|
|
3822
|
+
rows: fillDown(table.rows, step.fillDown ?? [])
|
|
3823
|
+
}));
|
|
3824
|
+
}
|
|
2610
3825
|
/**
|
|
2611
3826
|
* 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.
|
|
3827
|
+
* patterns come from the step; all case-insensitive, since PDFs and
|
|
3828
|
+
* spreadsheets capitalise headings freely.
|
|
2614
3829
|
*/
|
|
2615
3830
|
function tableQuery(step, selector) {
|
|
2616
3831
|
const columns = step.columns === undefined ? undefined : Object.fromEntries(Object.entries(step.columns).map(([key, pattern]) => [key, patternOf(pattern, `columns.${key}`)]));
|
|
2617
3832
|
return {
|
|
2618
3833
|
header: patternOf(selector, 'selector'),
|
|
2619
|
-
until:
|
|
3834
|
+
until: optionalPattern(step.until, 'until'),
|
|
2620
3835
|
columns,
|
|
2621
3836
|
align: step.align
|
|
2622
3837
|
};
|
|
2623
3838
|
}
|
|
3839
|
+
function refuseOptions(step, options, reads, current) {
|
|
3840
|
+
for (const option of options) {
|
|
3841
|
+
if (step[option] !== undefined) throw new Error(`"${option}" reads ${reads}; the current document is ${current}`);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
function optionalPattern(source, where) {
|
|
3845
|
+
return source === undefined ? undefined : patternOf(source, where);
|
|
3846
|
+
}
|
|
2624
3847
|
function patternOf(source, where) {
|
|
2625
3848
|
try {
|
|
2626
3849
|
return new RegExp(source, 'i');
|
|
@@ -2630,11 +3853,13 @@ function patternOf(source, where) {
|
|
|
2630
3853
|
});
|
|
2631
3854
|
}
|
|
2632
3855
|
}
|
|
2633
|
-
/** The text a regex extract reads: markup, text, a PDF's rows, or JSON re-serialised (a list of texts joined by newlines). */
|
|
3856
|
+
/** 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
3857
|
function textOf$1(document) {
|
|
2635
3858
|
if (document.kind === 'html') return document.html;
|
|
2636
3859
|
if (document.kind === 'text') return document.text;
|
|
2637
3860
|
if (document.kind === 'pdf') return pdfText(document);
|
|
3861
|
+
if (document.kind === 'workbook') return workbookText(document);
|
|
3862
|
+
if (document.kind === 'deck') return deckText(document);
|
|
2638
3863
|
if (Array.isArray(document.data) && document.data.every(entry => typeof entry === 'string')) return document.data.join('\n');
|
|
2639
3864
|
return typeof document.data === 'string' ? document.data : JSON.stringify(document.data);
|
|
2640
3865
|
}
|
|
@@ -2646,8 +3871,8 @@ function documentFor(step, scope) {
|
|
|
2646
3871
|
}
|
|
2647
3872
|
const source = scope.get(step.from);
|
|
2648
3873
|
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"`);
|
|
3874
|
+
if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source)) return source;
|
|
3875
|
+
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
3876
|
if (step.kind === 'regex') {
|
|
2652
3877
|
if (typeof source === 'string') return {
|
|
2653
3878
|
kind: 'text',
|
|
@@ -2683,6 +3908,135 @@ function documentFor(step, scope) {
|
|
|
2683
3908
|
};
|
|
2684
3909
|
}
|
|
2685
3910
|
|
|
3911
|
+
/** Aliases one document may expand: a "billion laughs" document needs far more. */
|
|
3912
|
+
const MAX_ALIASES = 100;
|
|
3913
|
+
/**
|
|
3914
|
+
* Parses YAML with the `yaml` package, imported on first use. The version is
|
|
3915
|
+
* pinned to YAML 1.2 (core schema) whatever the document declares: under a
|
|
3916
|
+
* `%YAML 1.1` directive, `NO` would read as `false` and `0123` as octal `83`.
|
|
3917
|
+
* Merge keys (`<<: *base`) are applied, duplicate keys are an error, aliases
|
|
3918
|
+
* are capped, and custom tags never build values: nothing in the text runs.
|
|
3919
|
+
*
|
|
3920
|
+
* @param text - The YAML.
|
|
3921
|
+
* @param source - Where it came from, for messages.
|
|
3922
|
+
* @param scalars - `typed` (default), or `text` to keep every scalar as written (`0123` stays `"0123"`).
|
|
3923
|
+
* @returns The data, the number of documents and the warnings.
|
|
3924
|
+
* @throws Error naming the source, with the line and column, for YAML that does not parse.
|
|
3925
|
+
*/
|
|
3926
|
+
async function readYaml(text, source, scalars = 'typed') {
|
|
3927
|
+
const {
|
|
3928
|
+
parseAllDocuments
|
|
3929
|
+
} = await import('yaml');
|
|
3930
|
+
const parsed = parseAllDocuments(text, {
|
|
3931
|
+
version: '1.2',
|
|
3932
|
+
schema: scalars === 'text' ? 'failsafe' : 'core',
|
|
3933
|
+
merge: true,
|
|
3934
|
+
uniqueKeys: true,
|
|
3935
|
+
prettyErrors: true
|
|
3936
|
+
});
|
|
3937
|
+
const documents = Array.isArray(parsed) ? parsed : [parsed];
|
|
3938
|
+
const warnings = [];
|
|
3939
|
+
const values = [];
|
|
3940
|
+
for (const document of documents) {
|
|
3941
|
+
const [error] = document.errors;
|
|
3942
|
+
if (error !== undefined) throw new Error(`${source}: not YAML (${error.message.split('\n', 1)[0]})`, {
|
|
3943
|
+
cause: error
|
|
3944
|
+
});
|
|
3945
|
+
warnings.push(...document.warnings.map(warning => warning.message.split('\n', 1)[0]));
|
|
3946
|
+
try {
|
|
3947
|
+
values.push(document.toJS({
|
|
3948
|
+
maxAliasCount: MAX_ALIASES
|
|
3949
|
+
}));
|
|
3950
|
+
} catch (error) {
|
|
3951
|
+
throw new Error(`${source}: ${error.message}`, {
|
|
3952
|
+
cause: error
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
return {
|
|
3957
|
+
data: values.length === 1 ? values[0] : values,
|
|
3958
|
+
documents: values.length,
|
|
3959
|
+
warnings
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
/** A leading `---` block of YAML. */
|
|
3964
|
+
const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
3965
|
+
/**
|
|
3966
|
+
* Renders Markdown (GitHub-flavoured: tables, task lists, strikethrough,
|
|
3967
|
+
* autolinks) to HTML with `marked`, imported on first use, so every `css`
|
|
3968
|
+
* selector works on it:
|
|
3969
|
+
*
|
|
3970
|
+
* - each heading and everything up to the next heading of the same or a higher
|
|
3971
|
+
* level is wrapped in `<section data-heading="…" data-level="…">`, sections
|
|
3972
|
+
* nesting, so "the table under *Prezzi*" is one selector;
|
|
3973
|
+
* - headings get slug ids (`<h2 id="prezzi">`);
|
|
3974
|
+
* - a leading `---` YAML block is parsed (YAML 1.2, as `as: "yaml"` reads it)
|
|
3975
|
+
* and put in the head as `<script type="application/json" data-front-matter>`.
|
|
3976
|
+
*
|
|
3977
|
+
* Raw HTML in the Markdown is kept: it is data, parsed by cheerio, never run.
|
|
3978
|
+
*
|
|
3979
|
+
* @param text - The Markdown.
|
|
3980
|
+
* @param source - Where it came from, for messages.
|
|
3981
|
+
* @returns The HTML, the front matter's data and the YAML parser's warnings.
|
|
3982
|
+
* @throws Error naming the source when the front matter is not YAML.
|
|
3983
|
+
*/
|
|
3984
|
+
async function readMarkdown(text, source) {
|
|
3985
|
+
const matter = FRONT_MATTER.exec(text);
|
|
3986
|
+
const body = matter === null ? text : text.slice(matter[0].length);
|
|
3987
|
+
const front = matter === null ? undefined : await readYaml(matter[1], `${source} front matter`);
|
|
3988
|
+
const {
|
|
3989
|
+
marked
|
|
3990
|
+
} = await import('marked');
|
|
3991
|
+
const rendered = marked.parse(body, {
|
|
3992
|
+
gfm: true,
|
|
3993
|
+
async: false
|
|
3994
|
+
});
|
|
3995
|
+
const sections = sectioned(rendered);
|
|
3996
|
+
const head = front === undefined ? '' : `<script type="application/json" data-front-matter>${JSON.stringify(front.data ?? null).replaceAll('<', '<')}</script>`;
|
|
3997
|
+
return {
|
|
3998
|
+
html: `<!doctype html><html><head>${head}</head><body>${sections}</body></html>`,
|
|
3999
|
+
frontMatter: front?.data,
|
|
4000
|
+
warnings: front?.warnings ?? []
|
|
4001
|
+
};
|
|
4002
|
+
}
|
|
4003
|
+
/** Wraps each heading and what follows it, up to the next heading of the same or a higher level, in a section. */
|
|
4004
|
+
function sectioned(html) {
|
|
4005
|
+
const $ = load(html, null, false);
|
|
4006
|
+
const open = [];
|
|
4007
|
+
const used = new Map();
|
|
4008
|
+
let out = '';
|
|
4009
|
+
const nodes = $.root().contents().toArray();
|
|
4010
|
+
for (const node of nodes) {
|
|
4011
|
+
const level = node.type === 'tag' ? /^h([1-6])$/.exec(node.name)?.[1] : undefined;
|
|
4012
|
+
if (level === undefined) {
|
|
4013
|
+
out += $.html(node);
|
|
4014
|
+
continue;
|
|
4015
|
+
}
|
|
4016
|
+
const depth = Number(level);
|
|
4017
|
+
while (open.length > 0 && (open.at(-1) ?? 0) >= depth) {
|
|
4018
|
+
open.pop();
|
|
4019
|
+
out += '</section>';
|
|
4020
|
+
}
|
|
4021
|
+
const heading = $(node);
|
|
4022
|
+
const text = heading.text().replaceAll(/\s+/g, ' ').trim();
|
|
4023
|
+
heading.attr('id', uniqueSlug(text, used));
|
|
4024
|
+
out += `<section data-heading="${escapeAttribute(text)}" data-level="${depth}">${$.html(node)}`;
|
|
4025
|
+
open.push(depth);
|
|
4026
|
+
}
|
|
4027
|
+
return out + '</section>'.repeat(open.length);
|
|
4028
|
+
}
|
|
4029
|
+
/** GitHub's heading ids: lower case, spaces to hyphens, punctuation dropped, a counter for repeats. */
|
|
4030
|
+
function uniqueSlug(text, used) {
|
|
4031
|
+
const slug = text.toLowerCase().replaceAll(/[^\p{L}\p{N}\s-]/gu, '').trim().replaceAll(/\s/g, '-');
|
|
4032
|
+
const count = used.get(slug) ?? 0;
|
|
4033
|
+
used.set(slug, count + 1);
|
|
4034
|
+
return count === 0 ? slug : `${slug}-${count}`;
|
|
4035
|
+
}
|
|
4036
|
+
function escapeAttribute(text) {
|
|
4037
|
+
return text.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<');
|
|
4038
|
+
}
|
|
4039
|
+
|
|
2686
4040
|
/** A response with a 4xx or 5xx status. */
|
|
2687
4041
|
class HttpError extends Error {
|
|
2688
4042
|
status;
|
|
@@ -2699,6 +4053,82 @@ class HttpError extends Error {
|
|
|
2699
4053
|
}
|
|
2700
4054
|
}
|
|
2701
4055
|
|
|
4056
|
+
/**
|
|
4057
|
+
* Decodes a body, in this order: a byte-order mark (UTF-8, UTF-16 LE/BE; Excel's
|
|
4058
|
+
* "Unicode text" export is UTF-16 LE), the encoding a recipe asks for, the
|
|
4059
|
+
* charset the server declares, strict UTF-8, and Windows-1252 (a superset of
|
|
4060
|
+
* Latin-1) for text that is not UTF-8.
|
|
4061
|
+
*
|
|
4062
|
+
* The Windows-1252 fallback is only taken when the text holds no valid UTF-8
|
|
4063
|
+
* beyond ASCII: a UTF-8 page with one stray byte keeps its accents, with a
|
|
4064
|
+
* replacement character for the stray byte, instead of turning every accent
|
|
4065
|
+
* into mojibake.
|
|
4066
|
+
*
|
|
4067
|
+
* @param bytes - The body.
|
|
4068
|
+
* @param options - `encoding`: the recipe's choice, a WHATWG label (wins over
|
|
4069
|
+
* the charset, not over a BOM); `charset`: from the content type (ignored when
|
|
4070
|
+
* not a known label).
|
|
4071
|
+
* @returns The text, without its BOM, and the encoding used.
|
|
4072
|
+
* @throws Error when `encoding` is not a known label.
|
|
4073
|
+
*/
|
|
4074
|
+
function decodeText(bytes, options = {}) {
|
|
4075
|
+
const bom = bomOf(bytes);
|
|
4076
|
+
if (bom !== undefined) return decodeWith(new TextDecoder(bom), bytes);
|
|
4077
|
+
if (options.encoding !== undefined) {
|
|
4078
|
+
const decoder = decoderFor(options.encoding);
|
|
4079
|
+
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…)`);
|
|
4080
|
+
return decodeWith(decoder, bytes);
|
|
4081
|
+
}
|
|
4082
|
+
const declared = options.charset === undefined ? undefined : decoderFor(options.charset);
|
|
4083
|
+
if (declared !== undefined) return decodeWith(declared, bytes);
|
|
4084
|
+
try {
|
|
4085
|
+
return decodeWith(new TextDecoder('utf-8', {
|
|
4086
|
+
fatal: true
|
|
4087
|
+
}), bytes);
|
|
4088
|
+
} catch {
|
|
4089
|
+
const lenient = decodeWith(new TextDecoder('utf-8'), bytes);
|
|
4090
|
+
return hasNonAsciiText(lenient.text) ? lenient : decodeWith(new TextDecoder('windows-1252'), bytes);
|
|
4091
|
+
}
|
|
4092
|
+
}
|
|
4093
|
+
/** Decodes, naming the encoding by its canonical WHATWG name (`utf-8`, `windows-1252`, `utf-16le`). */
|
|
4094
|
+
function decodeWith(decoder, bytes) {
|
|
4095
|
+
return {
|
|
4096
|
+
text: decoder.decode(bytes),
|
|
4097
|
+
encoding: decoder.encoding
|
|
4098
|
+
};
|
|
4099
|
+
}
|
|
4100
|
+
/**
|
|
4101
|
+
* The charset a content type declares (`text/csv; charset=ISO-8859-1`).
|
|
4102
|
+
*
|
|
4103
|
+
* @param contentType - The header value.
|
|
4104
|
+
* @returns The charset, or `undefined`.
|
|
4105
|
+
*/
|
|
4106
|
+
function charsetOf(contentType) {
|
|
4107
|
+
const match = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
|
|
4108
|
+
return match?.[1];
|
|
4109
|
+
}
|
|
4110
|
+
function bomOf(bytes) {
|
|
4111
|
+
if (bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) return 'utf8';
|
|
4112
|
+
if (bytes[0] === 0xFF && bytes[1] === 0xFE) return 'utf-16le';
|
|
4113
|
+
if (bytes[0] === 0xFE && bytes[1] === 0xFF) return 'utf-16be';
|
|
4114
|
+
return undefined;
|
|
4115
|
+
}
|
|
4116
|
+
/** Whether the text holds a character beyond ASCII other than the replacement character: valid UTF-8 was seen. */
|
|
4117
|
+
function hasNonAsciiText(text) {
|
|
4118
|
+
for (const char of text) {
|
|
4119
|
+
const code = char.codePointAt(0) ?? 0;
|
|
4120
|
+
if (code !== 0xFF_FD && code > 0x7F) return true;
|
|
4121
|
+
}
|
|
4122
|
+
return false;
|
|
4123
|
+
}
|
|
4124
|
+
function decoderFor(label) {
|
|
4125
|
+
try {
|
|
4126
|
+
return new TextDecoder(label.trim());
|
|
4127
|
+
} catch {
|
|
4128
|
+
return undefined;
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
|
|
2702
4132
|
/**
|
|
2703
4133
|
* HTTP through Playwright's request context: cookies, redirects and storage
|
|
2704
4134
|
* state behave exactly as they do in the browser, so a session captured by a
|
|
@@ -2738,12 +4168,20 @@ class HttpClient {
|
|
|
2738
4168
|
data: httpRequest.body,
|
|
2739
4169
|
timeout: httpRequest.timeoutMs ?? this.timeoutMs
|
|
2740
4170
|
});
|
|
2741
|
-
const
|
|
4171
|
+
const {
|
|
4172
|
+
body,
|
|
4173
|
+
warnings,
|
|
4174
|
+
format
|
|
4175
|
+
} = await readBody(response, httpRequest);
|
|
2742
4176
|
const result = {
|
|
2743
4177
|
status: response.status(),
|
|
2744
4178
|
url: response.url(),
|
|
2745
4179
|
headers: response.headers(),
|
|
2746
|
-
body
|
|
4180
|
+
body,
|
|
4181
|
+
format,
|
|
4182
|
+
...(warnings.length > 0 && {
|
|
4183
|
+
warnings
|
|
4184
|
+
})
|
|
2747
4185
|
};
|
|
2748
4186
|
if (response.status() >= 400) throw new HttpError(response.status(), response.url(), body, response.headers());
|
|
2749
4187
|
return result;
|
|
@@ -2756,40 +4194,107 @@ class HttpClient {
|
|
|
2756
4194
|
return this.context.dispose();
|
|
2757
4195
|
}
|
|
2758
4196
|
}
|
|
2759
|
-
async function readBody(response,
|
|
2760
|
-
const
|
|
2761
|
-
|
|
4197
|
+
async function readBody(response, httpRequest) {
|
|
4198
|
+
const contentType = response.headers()['content-type'] ?? '';
|
|
4199
|
+
const format = httpRequest.as ?? formatFromContentType(contentType);
|
|
4200
|
+
return parseBody(format, await response.body(), response.url(), {
|
|
4201
|
+
...httpRequest,
|
|
4202
|
+
charset: charsetOf(contentType)
|
|
4203
|
+
});
|
|
2762
4204
|
}
|
|
2763
4205
|
/**
|
|
2764
|
-
* A `file:` URL, read from disk: a PDF
|
|
2765
|
-
* instead of a server. The
|
|
4206
|
+
* A `file:` URL, read from disk: a PDF, spreadsheet, presentation, CSV, YAML
|
|
4207
|
+
* or JSON a recipe gets from a folder instead of a server. The format is `as`,
|
|
4208
|
+
* else the file extension.
|
|
2766
4209
|
*/
|
|
2767
4210
|
async function readLocalFile(httpRequest) {
|
|
2768
4211
|
const path = fileURLToPath(httpRequest.url);
|
|
2769
4212
|
const bytes = await readFile(path);
|
|
4213
|
+
const {
|
|
4214
|
+
body,
|
|
4215
|
+
warnings,
|
|
4216
|
+
format
|
|
4217
|
+
} = await parseBody(httpRequest.as ?? formatFromExtension(extname(path)), bytes, httpRequest.url, httpRequest);
|
|
2770
4218
|
return {
|
|
2771
4219
|
status: 200,
|
|
2772
4220
|
url: httpRequest.url,
|
|
2773
4221
|
headers: {},
|
|
2774
|
-
body
|
|
4222
|
+
body,
|
|
4223
|
+
format,
|
|
4224
|
+
...(warnings.length > 0 && {
|
|
4225
|
+
warnings
|
|
4226
|
+
})
|
|
2775
4227
|
};
|
|
2776
4228
|
}
|
|
2777
|
-
async function parseBody(
|
|
2778
|
-
if (
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
4229
|
+
async function parseBody(format, bytes, url, reading) {
|
|
4230
|
+
if (format === 'yaml') {
|
|
4231
|
+
const {
|
|
4232
|
+
text
|
|
4233
|
+
} = decodeText(bytes, reading);
|
|
4234
|
+
const {
|
|
4235
|
+
data,
|
|
4236
|
+
warnings
|
|
4237
|
+
} = await readYaml(text, url, reading.scalars);
|
|
4238
|
+
return {
|
|
4239
|
+
body: {
|
|
2783
4240
|
kind: 'json',
|
|
2784
|
-
data
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
4241
|
+
data
|
|
4242
|
+
},
|
|
4243
|
+
warnings,
|
|
4244
|
+
format
|
|
4245
|
+
};
|
|
4246
|
+
}
|
|
4247
|
+
if (format === 'markdown') {
|
|
4248
|
+
const {
|
|
4249
|
+
text
|
|
4250
|
+
} = decodeText(bytes, reading);
|
|
4251
|
+
const {
|
|
4252
|
+
html,
|
|
4253
|
+
warnings
|
|
4254
|
+
} = await readMarkdown(text, url);
|
|
4255
|
+
return {
|
|
4256
|
+
body: {
|
|
4257
|
+
kind: 'html',
|
|
4258
|
+
html
|
|
4259
|
+
},
|
|
4260
|
+
warnings,
|
|
4261
|
+
format
|
|
4262
|
+
};
|
|
4263
|
+
}
|
|
4264
|
+
return {
|
|
4265
|
+
body: await parseFormat(format, bytes, url, reading),
|
|
4266
|
+
warnings: [],
|
|
4267
|
+
format
|
|
4268
|
+
};
|
|
4269
|
+
}
|
|
4270
|
+
async function parseFormat(format, bytes, url, reading) {
|
|
4271
|
+
if (format === 'pdf') return readPdf(bytes, url);
|
|
4272
|
+
if (format === 'xlsx') return readXlsxWorkbook(bytes, url);
|
|
4273
|
+
if (format === 'pptx') return readPptxDeck(bytes, url);
|
|
4274
|
+
const {
|
|
4275
|
+
text,
|
|
4276
|
+
encoding
|
|
4277
|
+
} = decodeText(bytes, reading);
|
|
4278
|
+
if (format === 'csv') return csvWorkbook(text, {
|
|
4279
|
+
name: sheetNameOf(url),
|
|
4280
|
+
encoding,
|
|
4281
|
+
delimiter: reading.delimiter
|
|
4282
|
+
});
|
|
4283
|
+
if (format === 'jsonl') return {
|
|
4284
|
+
kind: 'json',
|
|
4285
|
+
data: parseJsonLines(text, url)
|
|
4286
|
+
};
|
|
4287
|
+
if (format === 'json') {
|
|
4288
|
+
const parsed = parseJsonLike(text);
|
|
4289
|
+
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"' : ''}`, {
|
|
4290
|
+
cause: parsed.error
|
|
4291
|
+
});
|
|
4292
|
+
return {
|
|
4293
|
+
kind: 'json',
|
|
4294
|
+
data: parsed.value
|
|
4295
|
+
};
|
|
2791
4296
|
}
|
|
2792
|
-
return
|
|
4297
|
+
return format === 'html' ? {
|
|
2793
4298
|
kind: 'html',
|
|
2794
4299
|
html: text
|
|
2795
4300
|
} : {
|
|
@@ -2797,22 +4302,54 @@ async function parseBody(kind, bytes, url) {
|
|
|
2797
4302
|
text
|
|
2798
4303
|
};
|
|
2799
4304
|
}
|
|
2800
|
-
|
|
2801
|
-
|
|
4305
|
+
/** Several lines, the first of them JSON on its own. */
|
|
4306
|
+
function looksLikeJsonLines(text) {
|
|
4307
|
+
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '');
|
|
4308
|
+
if (lines.length < 2) return false;
|
|
4309
|
+
const first = parseJsonLike(lines[0]);
|
|
4310
|
+
return 'value' in first;
|
|
4311
|
+
}
|
|
4312
|
+
function formatFromContentType(contentType) {
|
|
4313
|
+
const type = contentType.toLowerCase().split(';', 1)[0].trim();
|
|
4314
|
+
if (CSV_TYPES.has(type)) return 'csv';
|
|
4315
|
+
if (JSON_LINES_TYPES.has(type)) return 'jsonl';
|
|
4316
|
+
// A legacy .xls or .ppt goes to the Office reader too, which says what to do with it.
|
|
4317
|
+
if (type.includes('spreadsheetml') || type.startsWith('application/vnd.ms-excel')) return 'xlsx';
|
|
4318
|
+
if (type.includes('presentationml') || type.startsWith('application/vnd.ms-powerpoint')) return 'pptx';
|
|
4319
|
+
if (YAML_TYPES.has(type)) return 'yaml';
|
|
4320
|
+
if (type === 'text/markdown' || type === 'text/x-markdown') return 'markdown';
|
|
2802
4321
|
if (type.includes('json')) return 'json';
|
|
2803
4322
|
if (type.includes('pdf')) return 'pdf';
|
|
2804
4323
|
if (type.includes('html') || type.includes('xml')) return 'html';
|
|
2805
4324
|
return 'text';
|
|
2806
4325
|
}
|
|
2807
|
-
|
|
2808
|
-
|
|
4326
|
+
const JSON_LINES_TYPES = new Set(['application/x-ndjson', 'application/ndjson', 'application/jsonl', 'application/x-jsonlines', 'application/jsonlines']);
|
|
4327
|
+
const YAML_TYPES = new Set(['application/yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml']);
|
|
4328
|
+
const CSV_TYPES = new Set(['text/csv', 'application/csv', 'text/x-csv', 'application/x-csv', 'text/comma-separated-values', 'text/tab-separated-values']);
|
|
4329
|
+
function formatFromExtension(extension) {
|
|
4330
|
+
const formats = {
|
|
2809
4331
|
'.json': 'json',
|
|
4332
|
+
'.jsonl': 'jsonl',
|
|
4333
|
+
'.ndjson': 'jsonl',
|
|
2810
4334
|
'.pdf': 'pdf',
|
|
4335
|
+
'.csv': 'csv',
|
|
4336
|
+
'.tsv': 'csv',
|
|
4337
|
+
'.xlsx': 'xlsx',
|
|
4338
|
+
'.xlsm': 'xlsx',
|
|
4339
|
+
'.xls': 'xlsx',
|
|
4340
|
+
'.pptx': 'pptx',
|
|
4341
|
+
'.pptm': 'pptx',
|
|
4342
|
+
'.ppsx': 'pptx',
|
|
4343
|
+
'.ppt': 'pptx',
|
|
4344
|
+
'.yaml': 'yaml',
|
|
4345
|
+
'.yml': 'yaml',
|
|
4346
|
+
'.md': 'markdown',
|
|
4347
|
+
'.markdown': 'markdown',
|
|
2811
4348
|
'.html': 'html',
|
|
2812
4349
|
'.htm': 'html',
|
|
2813
4350
|
'.xml': 'html'
|
|
2814
4351
|
};
|
|
2815
|
-
return
|
|
4352
|
+
return formats[extension.toLowerCase()] ?? 'text';
|
|
2816
4353
|
}
|
|
2817
4354
|
|
|
2818
4355
|
/**
|
|
@@ -2826,7 +4363,7 @@ function kindFromExtension(extension) {
|
|
|
2826
4363
|
* @param recipe - The recipe: its limits, block rule and id.
|
|
2827
4364
|
* @param gate - Spaces request starts by `delayMs`.
|
|
2828
4365
|
* @param events - Where to report the visit.
|
|
2829
|
-
* @throws BlockedError when the response is a block
|
|
4366
|
+
* @throws BlockedError when the response is a block, or a captcha page under `session.captcha`; HttpError for any other 4xx/5xx.
|
|
2830
4367
|
*/
|
|
2831
4368
|
async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
2832
4369
|
const lookup = path => scope.lookup(path);
|
|
@@ -2841,6 +4378,9 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
|
2841
4378
|
headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
|
|
2842
4379
|
body: renderDeep(step.body, lookup),
|
|
2843
4380
|
as: step.as,
|
|
4381
|
+
encoding: step.encoding,
|
|
4382
|
+
delimiter: step.delimiter,
|
|
4383
|
+
scalars: step.scalars,
|
|
2844
4384
|
timeoutMs: recipe.limits?.timeoutMs
|
|
2845
4385
|
});
|
|
2846
4386
|
} catch (error) {
|
|
@@ -2866,6 +4406,15 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
|
2866
4406
|
number: scope.pageState?.number ?? 1,
|
|
2867
4407
|
status: response.status
|
|
2868
4408
|
});
|
|
4409
|
+
const warnings = response.warnings ?? [];
|
|
4410
|
+
for (const warning of warnings) events.emit({
|
|
4411
|
+
type: 'warning',
|
|
4412
|
+
recipeId: recipe.id,
|
|
4413
|
+
message: `${response.url}: ${warning}`,
|
|
4414
|
+
meta: {
|
|
4415
|
+
url: response.url
|
|
4416
|
+
}
|
|
4417
|
+
});
|
|
2869
4418
|
const blocked = await detectBlock({
|
|
2870
4419
|
url: response.url,
|
|
2871
4420
|
status: response.status,
|
|
@@ -2873,15 +4422,22 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
|
2873
4422
|
text: async () => bodyText(response.body)
|
|
2874
4423
|
}, recipe.session?.blockedWhen);
|
|
2875
4424
|
if (blocked !== undefined) throw blocked;
|
|
4425
|
+
if (recipe.session?.captcha !== undefined && response.body.kind === 'html' && CAPTCHA_MARKUP.test(response.body.html)) {
|
|
4426
|
+
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');
|
|
4427
|
+
}
|
|
2876
4428
|
scope.setPage({
|
|
2877
4429
|
url: response.url,
|
|
2878
4430
|
document: response.body
|
|
2879
4431
|
});
|
|
2880
4432
|
if (step.id !== undefined) scope.set(step.id, documentValue(response.body));
|
|
2881
4433
|
}
|
|
4434
|
+
/** The class names of the widgets `session.captcha` solves. Checked only when a recipe declares it. */
|
|
4435
|
+
const CAPTCHA_MARKUP = /\b(?:g-recaptcha|h-captcha|cf-turnstile)\b/;
|
|
2882
4436
|
function bodyText(body) {
|
|
2883
4437
|
if (body.kind === 'json') return JSON.stringify(body.data);
|
|
2884
4438
|
if (body.kind === 'pdf') return pdfText(body);
|
|
4439
|
+
if (body.kind === 'workbook') return workbookText(body);
|
|
4440
|
+
if (body.kind === 'deck') return deckText(body);
|
|
2885
4441
|
return body.kind === 'html' ? body.html : body.text;
|
|
2886
4442
|
}
|
|
2887
4443
|
function renderMap(map, lookup) {
|
|
@@ -2902,11 +4458,11 @@ function resolveUrl(target, base) {
|
|
|
2902
4458
|
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
4459
|
}
|
|
2904
4460
|
}
|
|
2905
|
-
/** What a step id holds for a document: parsed JSON, the read PDF, or the markup / text. */
|
|
4461
|
+
/** What a step id holds for a document: parsed JSON, the read PDF, workbook or deck, or the markup / text. */
|
|
2906
4462
|
function documentValue(body) {
|
|
2907
4463
|
if (body.kind === 'json') return body.data;
|
|
2908
|
-
if (body.kind === '
|
|
2909
|
-
return body.kind === '
|
|
4464
|
+
if (body.kind === 'html') return body.html;
|
|
4465
|
+
return body.kind === 'text' ? body.text : body;
|
|
2910
4466
|
}
|
|
2911
4467
|
|
|
2912
4468
|
/** Runs api-mode leaf steps against an HTTP sender. */
|
|
@@ -3988,11 +5544,10 @@ async function extractFromPage(step, page, scope) {
|
|
|
3988
5544
|
extractFromDocument(step, scope);
|
|
3989
5545
|
return;
|
|
3990
5546
|
}
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
const values = take === 'text' ? raw.map(value => typeof value === 'string' ? collapse(value) : value) : raw;
|
|
5547
|
+
const values = step.kind === 'table' ? tablesIn({
|
|
5548
|
+
kind: 'html',
|
|
5549
|
+
html: await page.content()
|
|
5550
|
+
}, step, scope) : await readPage(step, page, scope);
|
|
3996
5551
|
if (step.many === true) {
|
|
3997
5552
|
if (step.id !== undefined) scope.set(step.id, values);
|
|
3998
5553
|
return;
|
|
@@ -4000,6 +5555,13 @@ async function extractFromPage(step, page, scope) {
|
|
|
4000
5555
|
if (values.length === 0) throw new NoMatchError(step.selector);
|
|
4001
5556
|
if (step.id !== undefined) scope.set(step.id, values[0]);
|
|
4002
5557
|
}
|
|
5558
|
+
/** A css, xpath or regex extract on the live page. */
|
|
5559
|
+
async function readPage(step, page, scope) {
|
|
5560
|
+
const rendered = renderSelector(step.selector, scope);
|
|
5561
|
+
const take = step.take ?? 'text';
|
|
5562
|
+
const raw = step.kind === 'regex' ? selectRegex(await page.content(), rendered) : await page.locator(step.kind === 'xpath' ? `xpath=${rendered}` : rendered).evaluateAll(readAll, take);
|
|
5563
|
+
return take === 'text' ? raw.map(value => typeof value === 'string' ? collapse(value) : value) : raw;
|
|
5564
|
+
}
|
|
4003
5565
|
/** Runs inside the page: one value per matched element. Keep it self-contained; it is serialised. */
|
|
4004
5566
|
function readAll(elements, take) {
|
|
4005
5567
|
return elements.map(element => {
|
|
@@ -4185,18 +5747,26 @@ async function navigate(step, page, scope, recipe, gate, events) {
|
|
|
4185
5747
|
}
|
|
4186
5748
|
|
|
4187
5749
|
const NEXT_LINK_TIMEOUT_MS = 2000;
|
|
4188
|
-
/**
|
|
5750
|
+
/** Steps after which a page may show a new captcha (`session.captcha`). */
|
|
5751
|
+
const CHALLENGING_STEPS = new Set(['click', 'press']);
|
|
5752
|
+
/**
|
|
5753
|
+
* Runs web-mode leaf steps on a browser page. With a captcha guard, a page a
|
|
5754
|
+
* navigation, click or key press leads to is checked for a challenge, solved
|
|
5755
|
+
* before the next step runs.
|
|
5756
|
+
*/
|
|
4189
5757
|
class WebStepRunner {
|
|
4190
5758
|
session;
|
|
4191
5759
|
recipe;
|
|
4192
5760
|
events;
|
|
4193
5761
|
gate;
|
|
5762
|
+
captcha;
|
|
4194
5763
|
page;
|
|
4195
|
-
constructor(session, recipe, events, gate = new RunGate(1, recipe.limits?.delayMs ?? 0)) {
|
|
5764
|
+
constructor(session, recipe, events, gate = new RunGate(1, recipe.limits?.delayMs ?? 0), captcha) {
|
|
4196
5765
|
this.session = session;
|
|
4197
5766
|
this.recipe = recipe;
|
|
4198
5767
|
this.events = events;
|
|
4199
5768
|
this.gate = gate;
|
|
5769
|
+
this.captcha = captcha;
|
|
4200
5770
|
this.page = session.page;
|
|
4201
5771
|
}
|
|
4202
5772
|
/** Clicks and key presses can navigate; keep `page.url` honest after every leaf step. */
|
|
@@ -4206,11 +5776,29 @@ class WebStepRunner {
|
|
|
4206
5776
|
url
|
|
4207
5777
|
});
|
|
4208
5778
|
}
|
|
5779
|
+
/** Navigates; a block page showing a captcha is solved under `onBlock.solve`, and a page reached is checked for one. */
|
|
5780
|
+
async visit(step, scope) {
|
|
5781
|
+
try {
|
|
5782
|
+
await navigate(step, this.page, scope, this.recipe, this.gate, this.events);
|
|
5783
|
+
} catch (error) {
|
|
5784
|
+
if (!(error instanceof BlockedError) || this.captcha?.solvesBlocks !== true) throw error;
|
|
5785
|
+
await this.captcha.solveBlock(this.page, error);
|
|
5786
|
+
return;
|
|
5787
|
+
}
|
|
5788
|
+
await this.captcha?.check(this.page);
|
|
5789
|
+
}
|
|
4209
5790
|
async runLeaf(step, scope) {
|
|
4210
5791
|
switch (step.type) {
|
|
4211
5792
|
case 'goto':
|
|
4212
5793
|
{
|
|
4213
|
-
|
|
5794
|
+
await this.visit(step, scope);
|
|
5795
|
+
break;
|
|
5796
|
+
}
|
|
5797
|
+
case 'captcha':
|
|
5798
|
+
{
|
|
5799
|
+
if (this.captcha === undefined) throw new Error('a captcha step needs a crawler with captcha solvers');
|
|
5800
|
+
await this.captcha.step(this.page, step);
|
|
5801
|
+
break;
|
|
4214
5802
|
}
|
|
4215
5803
|
case 'click':
|
|
4216
5804
|
{
|
|
@@ -4262,6 +5850,7 @@ class WebStepRunner {
|
|
|
4262
5850
|
throw new Error(`"${step.type}" is an api step; this recipe runs in web mode`);
|
|
4263
5851
|
}
|
|
4264
5852
|
}
|
|
5853
|
+
if (CHALLENGING_STEPS.has(step.type)) await this.captcha?.check(this.page);
|
|
4265
5854
|
this.trackUrl(scope);
|
|
4266
5855
|
}
|
|
4267
5856
|
async nextPage(next, scope) {
|
|
@@ -4269,9 +5858,10 @@ class WebStepRunner {
|
|
|
4269
5858
|
if ('url' in next) {
|
|
4270
5859
|
const target = renderText(next.url, path => scope.lookup(path));
|
|
4271
5860
|
if (target === '') return null;
|
|
4272
|
-
await
|
|
5861
|
+
await this.visit({
|
|
5862
|
+
type: 'goto',
|
|
4273
5863
|
url: target
|
|
4274
|
-
},
|
|
5864
|
+
}, scope);
|
|
4275
5865
|
return {
|
|
4276
5866
|
kind: 'url',
|
|
4277
5867
|
url: this.page.url()
|
|
@@ -4293,6 +5883,7 @@ class WebStepRunner {
|
|
|
4293
5883
|
url: this.page.url(),
|
|
4294
5884
|
number: (scope.pageState?.number ?? 1) + 1
|
|
4295
5885
|
});
|
|
5886
|
+
await this.captcha?.check(this.page);
|
|
4296
5887
|
return {
|
|
4297
5888
|
kind: 'url',
|
|
4298
5889
|
url: this.page.url()
|
|
@@ -4337,9 +5928,10 @@ function accessOptions(lease, headers) {
|
|
|
4337
5928
|
* @param recipe - The input recipe.
|
|
4338
5929
|
* @param deps - Browser, hooks, events.
|
|
4339
5930
|
* @param lease - The recipe run's access; direct when omitted.
|
|
5931
|
+
* @param captcha - Solves the bootstrap's captchas (a login form's).
|
|
4340
5932
|
* @returns The state, or `undefined` when the recipe declares none.
|
|
4341
5933
|
*/
|
|
4342
|
-
async function resolveStorageState(recipe, deps, lease) {
|
|
5934
|
+
async function resolveStorageState(recipe, deps, lease, captcha) {
|
|
4343
5935
|
const saved = await readSavedState(recipe, deps);
|
|
4344
5936
|
const session = recipe.session;
|
|
4345
5937
|
if (saved !== undefined || session?.bootstrap === undefined) return saved;
|
|
@@ -4351,7 +5943,7 @@ async function resolveStorageState(recipe, deps, lease) {
|
|
|
4351
5943
|
...accessOptions(lease, session.headers)
|
|
4352
5944
|
});
|
|
4353
5945
|
try {
|
|
4354
|
-
return await runBootstrap(recipe, browserSession, deps);
|
|
5946
|
+
return await runBootstrap(recipe, browserSession, deps, captcha);
|
|
4355
5947
|
} finally {
|
|
4356
5948
|
await browserSession.close();
|
|
4357
5949
|
}
|
|
@@ -4376,15 +5968,16 @@ async function readSavedState(recipe, deps) {
|
|
|
4376
5968
|
* @param recipe - An input recipe with `session.bootstrap`.
|
|
4377
5969
|
* @param browserSession - Where the steps run.
|
|
4378
5970
|
* @param deps - Hooks, events, `storageStateDir`.
|
|
5971
|
+
* @param captcha - Solves the bootstrap's captchas.
|
|
4379
5972
|
* @returns The kept state.
|
|
4380
5973
|
*/
|
|
4381
|
-
async function runBootstrap(recipe, browserSession, deps) {
|
|
5974
|
+
async function runBootstrap(recipe, browserSession, deps, captcha) {
|
|
4382
5975
|
const bootstrap = recipe.session?.bootstrap;
|
|
4383
5976
|
if (bootstrap === undefined) return {
|
|
4384
5977
|
cookies: [],
|
|
4385
5978
|
origins: []
|
|
4386
5979
|
};
|
|
4387
|
-
const runner = new WebStepRunner(browserSession, recipe, deps.events);
|
|
5980
|
+
const runner = new WebStepRunner(browserSession, recipe, deps.events, undefined, captcha);
|
|
4388
5981
|
const scope = new ExtractionScope();
|
|
4389
5982
|
scope.set('vars', recipe.vars ?? {});
|
|
4390
5983
|
scope.set('start', {
|
|
@@ -4555,6 +6148,11 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4555
6148
|
pages: 0,
|
|
4556
6149
|
durationMs: 0
|
|
4557
6150
|
};
|
|
6151
|
+
const captchas = {
|
|
6152
|
+
detected: 0,
|
|
6153
|
+
solved: 0,
|
|
6154
|
+
failed: 0
|
|
6155
|
+
};
|
|
4558
6156
|
const limits = input.limits ?? {};
|
|
4559
6157
|
// A web recipe drives one page, so only api mode runs iterations in parallel.
|
|
4560
6158
|
const gate = new RunGate(input.mode === 'web' ? 1 : limits.concurrency ?? 1, limits.delayMs ?? 0);
|
|
@@ -4563,6 +6161,9 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4563
6161
|
const unsubscribe = deps.events.subscribe(event => {
|
|
4564
6162
|
if (event.type === 'page:visit' && event.recipeId === input.id) report.pages += 1;
|
|
4565
6163
|
if (event.type === 'step:skip' && event.recipeId === input.id) report.stepsSkipped += 1;
|
|
6164
|
+
if (event.type === 'captcha:detected' && event.recipeId === input.id) captchas.detected += 1;
|
|
6165
|
+
if (event.type === 'captcha:solved' && event.recipeId === input.id) captchas.solved += 1;
|
|
6166
|
+
if (event.type === 'captcha:failed' && event.recipeId === input.id) captchas.failed += 1;
|
|
4566
6167
|
});
|
|
4567
6168
|
deps.events.emit({
|
|
4568
6169
|
type: 'recipe:start',
|
|
@@ -4573,11 +6174,18 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4573
6174
|
let runner;
|
|
4574
6175
|
try {
|
|
4575
6176
|
const onBlock = input.session?.onBlock;
|
|
6177
|
+
const solvers = deps.captchaSolvers ?? new CaptchaSolverRegistry();
|
|
6178
|
+
for (const name of captchaSolverNames(input)) solvers.resolve(name);
|
|
6179
|
+
const context = {
|
|
6180
|
+
gate,
|
|
6181
|
+
solvers,
|
|
6182
|
+
budget: new CaptchaBudget(input.session?.captcha?.maxSolves ?? DEFAULT_MAX_SOLVES)
|
|
6183
|
+
};
|
|
4576
6184
|
runner = await RotatingRunner.open({
|
|
4577
6185
|
recipe: input,
|
|
4578
6186
|
events: deps.events,
|
|
4579
6187
|
maxRotations: onBlock?.rotate === true ? onBlock.attempts ?? 2 : 0,
|
|
4580
|
-
open: attempt => openLeased(input, deps,
|
|
6188
|
+
open: attempt => openLeased(input, deps, context, attempt)
|
|
4581
6189
|
});
|
|
4582
6190
|
for (const point of input.start) {
|
|
4583
6191
|
const scope = new ExtractionScope();
|
|
@@ -4612,6 +6220,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
4612
6220
|
} finally {
|
|
4613
6221
|
await runner?.dispose();
|
|
4614
6222
|
unsubscribe();
|
|
6223
|
+
if (captchas.detected > 0) report.captchas = captchas;
|
|
4615
6224
|
report.durationMs = Date.now() - started;
|
|
4616
6225
|
deps.events.emit({
|
|
4617
6226
|
type: 'recipe:finish',
|
|
@@ -4737,11 +6346,11 @@ async function leaseAccess(input, deps, attempt) {
|
|
|
4737
6346
|
return lease;
|
|
4738
6347
|
}
|
|
4739
6348
|
/** A lease and a runner opened on it; the lease is released again when opening fails. */
|
|
4740
|
-
async function openLeased(input, deps,
|
|
6349
|
+
async function openLeased(input, deps, context, attempt) {
|
|
4741
6350
|
const lease = await leaseAccess(input, deps, attempt);
|
|
4742
6351
|
try {
|
|
4743
6352
|
return {
|
|
4744
|
-
runner: await openRunner(input, deps,
|
|
6353
|
+
runner: await openRunner(input, deps, context, lease),
|
|
4745
6354
|
lease
|
|
4746
6355
|
};
|
|
4747
6356
|
} catch (error) {
|
|
@@ -4749,9 +6358,19 @@ async function openLeased(input, deps, gate, attempt) {
|
|
|
4749
6358
|
throw error;
|
|
4750
6359
|
}
|
|
4751
6360
|
}
|
|
4752
|
-
async function openRunner(input, deps,
|
|
4753
|
-
|
|
4754
|
-
|
|
6361
|
+
async function openRunner(input, deps, context, lease) {
|
|
6362
|
+
const {
|
|
6363
|
+
gate
|
|
6364
|
+
} = context;
|
|
6365
|
+
const captcha = new CaptchaGuard({
|
|
6366
|
+
recipe: input,
|
|
6367
|
+
events: deps.events,
|
|
6368
|
+
solvers: context.solvers,
|
|
6369
|
+
budget: context.budget,
|
|
6370
|
+
lease
|
|
6371
|
+
});
|
|
6372
|
+
if (lease.cdp !== undefined) return openRemoteRunner(input, deps, context, lease, lease.cdp);
|
|
6373
|
+
const storageState = await resolveStorageState(input, deps, lease, captcha);
|
|
4755
6374
|
const session = input.session;
|
|
4756
6375
|
const access = accessOptions(lease, session?.headers);
|
|
4757
6376
|
if (input.mode === 'web') {
|
|
@@ -4763,7 +6382,7 @@ async function openRunner(input, deps, gate, lease) {
|
|
|
4763
6382
|
viewport: session?.viewport,
|
|
4764
6383
|
...access
|
|
4765
6384
|
});
|
|
4766
|
-
return new WebStepRunner(browserSession, input, deps.events, gate);
|
|
6385
|
+
return new WebStepRunner(browserSession, input, deps.events, gate, captcha);
|
|
4767
6386
|
}
|
|
4768
6387
|
const client = await HttpClient.open({
|
|
4769
6388
|
storageState,
|
|
@@ -4780,7 +6399,14 @@ async function openRunner(input, deps, gate, lease) {
|
|
|
4780
6399
|
* session as the crawl: providers tie the IP and fingerprint to the
|
|
4781
6400
|
* connection, so a login in one connection would not carry to another.
|
|
4782
6401
|
*/
|
|
4783
|
-
async function openRemoteRunner(input, deps,
|
|
6402
|
+
async function openRemoteRunner(input, deps, context, lease, cdp) {
|
|
6403
|
+
const captcha = new CaptchaGuard({
|
|
6404
|
+
recipe: input,
|
|
6405
|
+
events: deps.events,
|
|
6406
|
+
solvers: context.solvers,
|
|
6407
|
+
budget: context.budget,
|
|
6408
|
+
lease
|
|
6409
|
+
});
|
|
4784
6410
|
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`);
|
|
4785
6411
|
const session = input.session;
|
|
4786
6412
|
const storageState = await readSavedState(input, deps);
|
|
@@ -4791,12 +6417,12 @@ async function openRemoteRunner(input, deps, gate, lease, cdp) {
|
|
|
4791
6417
|
...accessOptions(lease, session?.headers)
|
|
4792
6418
|
}, input.limits?.timeoutMs);
|
|
4793
6419
|
try {
|
|
4794
|
-
if (storageState === undefined && session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps);
|
|
6420
|
+
if (storageState === undefined && session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps, captcha);
|
|
4795
6421
|
} catch (error) {
|
|
4796
6422
|
await browserSession.close();
|
|
4797
6423
|
throw error;
|
|
4798
6424
|
}
|
|
4799
|
-
return new WebStepRunner(browserSession, input, deps.events, gate);
|
|
6425
|
+
return new WebStepRunner(browserSession, input, deps.events, context.gate, captcha);
|
|
4800
6426
|
}
|
|
4801
6427
|
|
|
4802
6428
|
/**
|
|
@@ -4834,15 +6460,17 @@ async function runCrawl(set, deps, onRecipeError) {
|
|
|
4834
6460
|
* Creates a crawler. The browser is launched lazily, on the first recipe or
|
|
4835
6461
|
* bootstrap that needs it, and shared by every run until `close`.
|
|
4836
6462
|
*
|
|
4837
|
-
* @param options - Hooks, sink, events, browser settings, access, policies.
|
|
6463
|
+
* @param options - Hooks, sink, events, browser settings, access, captcha solvers, policies.
|
|
4838
6464
|
* @returns The crawler.
|
|
4839
6465
|
* @throws AccessConfigError when the access config cannot work.
|
|
6466
|
+
* @throws Error when two captcha solvers share a name.
|
|
4840
6467
|
*/
|
|
4841
6468
|
function createCrawler(options = {}) {
|
|
4842
6469
|
const sink = options.sink ?? memorySink();
|
|
4843
6470
|
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
6471
|
const access = new AccessBroker(options.access, options.accessPlugins);
|
|
4845
6472
|
const hooks = new HookRegistry(options.hooks);
|
|
6473
|
+
const captchaSolvers = new CaptchaSolverRegistry(options.captchaSolvers);
|
|
4846
6474
|
const events = new EventBus(options.onEvent);
|
|
4847
6475
|
let browser;
|
|
4848
6476
|
const launch = () => {
|
|
@@ -4860,6 +6488,7 @@ function createCrawler(options = {}) {
|
|
|
4860
6488
|
resume: options.resume === true,
|
|
4861
6489
|
debug: options.debug === true,
|
|
4862
6490
|
access,
|
|
6491
|
+
captchaSolvers,
|
|
4863
6492
|
ignoreHTTPSErrors: options.browser?.ignoreHTTPSErrors
|
|
4864
6493
|
}, options.onRecipeError ?? 'continue'),
|
|
4865
6494
|
async close() {
|
|
@@ -4877,7 +6506,8 @@ const CRAWL_MODES = ['web', 'api'];
|
|
|
4877
6506
|
const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
|
|
4878
6507
|
/** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
|
|
4879
6508
|
const TAKE_KINDS = ['text', 'html', 'value', 'json'];
|
|
4880
|
-
const BODY_KINDS = ['json', 'html', 'text', 'pdf'];
|
|
6509
|
+
const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown'];
|
|
6510
|
+
const YAML_SCALARS = ['typed', 'text'];
|
|
4881
6511
|
/** How a PDF table aligns a row's values against a cell wrapped over several lines. */
|
|
4882
6512
|
const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
|
|
4883
6513
|
const FIELD_TYPES = ['string', 'number', 'integer', 'boolean', 'date', 'datetime', 'currency', 'url', 'enum', 'array', 'object', 'json'];
|
|
@@ -4889,7 +6519,7 @@ const KEEP_KINDS = ['cookies', 'localStorage'];
|
|
|
4889
6519
|
const WAIT_UNTIL = ['load', 'domcontentloaded', 'networkidle', 'commit'];
|
|
4890
6520
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
|
|
4891
6521
|
/** Steps that only make sense with a live browser page. */
|
|
4892
|
-
const WEB_ONLY_STEPS = ['goto', 'click', 'fill', 'press', 'select', 'scroll', 'wait', 'evaluate', 'screenshot'];
|
|
6522
|
+
const WEB_ONLY_STEPS = ['goto', 'click', 'fill', 'press', 'select', 'scroll', 'wait', 'evaluate', 'screenshot', 'captcha'];
|
|
4893
6523
|
/** Steps that only make sense against an HTTP request context. */
|
|
4894
6524
|
const API_ONLY_STEPS = ['request'];
|
|
4895
6525
|
|
|
@@ -5054,9 +6684,18 @@ const requestStep = z.strictObject({
|
|
|
5054
6684
|
query: stringMap.optional(),
|
|
5055
6685
|
headers: stringMap.optional(),
|
|
5056
6686
|
body: z.unknown().optional(),
|
|
5057
|
-
as: z.enum(BODY_KINDS).optional()
|
|
6687
|
+
as: z.enum(BODY_KINDS).optional(),
|
|
6688
|
+
encoding: z.string().min(1).optional(),
|
|
6689
|
+
delimiter: z.string().length(1).optional(),
|
|
6690
|
+
scalars: z.enum(YAML_SCALARS).optional()
|
|
6691
|
+
}).refine(step => step.delimiter === undefined || step.as === undefined || step.as === 'csv', {
|
|
6692
|
+
message: '"delimiter" reads CSV only: drop it or set "as": "csv"',
|
|
6693
|
+
path: ['delimiter']
|
|
6694
|
+
}).refine(step => step.scalars === undefined || step.as === undefined || step.as === 'yaml', {
|
|
6695
|
+
message: '"scalars" reads YAML only: drop it or set "as": "yaml"',
|
|
6696
|
+
path: ['scalars']
|
|
5058
6697
|
});
|
|
5059
|
-
const tableOnly = ['columns', 'until', 'align'];
|
|
6698
|
+
const tableOnly = ['columns', 'until', 'align', 'sheet', 'headerRows', 'fillDown', 'includeHidden', 'slide', 'shapes'];
|
|
5060
6699
|
const extractStep = z.strictObject({
|
|
5061
6700
|
...base,
|
|
5062
6701
|
type: z.literal('extract'),
|
|
@@ -5067,7 +6706,13 @@ const extractStep = z.strictObject({
|
|
|
5067
6706
|
from: stepId.optional(),
|
|
5068
6707
|
columns: stringMap.optional(),
|
|
5069
6708
|
until: z.string().min(1).optional(),
|
|
5070
|
-
align: z.enum(TABLE_ALIGNS).optional()
|
|
6709
|
+
align: z.enum(TABLE_ALIGNS).optional(),
|
|
6710
|
+
sheet: z.string().min(1).optional(),
|
|
6711
|
+
headerRows: z.int().min(1).optional(),
|
|
6712
|
+
fillDown: z.array(z.string().min(1)).min(1).optional(),
|
|
6713
|
+
includeHidden: z.boolean().optional(),
|
|
6714
|
+
slide: z.string().min(1).optional(),
|
|
6715
|
+
shapes: z.boolean().optional()
|
|
5071
6716
|
}).check(context => {
|
|
5072
6717
|
if (context.value.kind === 'table') return;
|
|
5073
6718
|
for (const key of tableOnly) {
|
|
@@ -5101,6 +6746,19 @@ const hookStep = z.strictObject({
|
|
|
5101
6746
|
name: z.string().min(1),
|
|
5102
6747
|
args: z.record(z.string(), z.unknown()).optional()
|
|
5103
6748
|
});
|
|
6749
|
+
const captchaCheckSchema = z.strictObject({
|
|
6750
|
+
gone: z.boolean().optional(),
|
|
6751
|
+
selector: z.string().min(1).optional()
|
|
6752
|
+
});
|
|
6753
|
+
const captchaStep = z.strictObject({
|
|
6754
|
+
...base,
|
|
6755
|
+
type: z.literal('captcha'),
|
|
6756
|
+
solver: z.string().min(1).optional(),
|
|
6757
|
+
selector: z.string().min(1).optional(),
|
|
6758
|
+
verify: captchaCheckSchema.optional(),
|
|
6759
|
+
attempts: z.int().min(1).max(10).optional(),
|
|
6760
|
+
timeoutMs: z.int().min(1000).optional()
|
|
6761
|
+
});
|
|
5104
6762
|
const emitFlag = z.union([z.literal(true), z.strictObject({
|
|
5105
6763
|
output: z.string().min(1)
|
|
5106
6764
|
})]);
|
|
@@ -5129,7 +6787,7 @@ const paginateStep = z.strictObject({
|
|
|
5129
6787
|
maxPages: z.int().min(1).optional(),
|
|
5130
6788
|
steps
|
|
5131
6789
|
});
|
|
5132
|
-
const stepSchema = z.discriminatedUnion('type', [gotoStep, clickStep, fillStep, pressStep, selectStep, scrollStep, waitStep, evaluateStep, screenshotStep, requestStep, extractStep, assignStep, collectStep, emitStep, hookStep, forEachStep, ifStep, paginateStep]);
|
|
6790
|
+
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
6791
|
|
|
5134
6792
|
const args = z.record(z.string(), z.unknown());
|
|
5135
6793
|
const stringList = z.array(z.string());
|
|
@@ -5277,9 +6935,20 @@ const blockRuleSchema = z.strictObject({
|
|
|
5277
6935
|
text: regexSource.optional()
|
|
5278
6936
|
});
|
|
5279
6937
|
const blockRotationSchema = z.strictObject({
|
|
5280
|
-
rotate: z.boolean(),
|
|
6938
|
+
rotate: z.boolean().optional(),
|
|
6939
|
+
solve: z.boolean().optional(),
|
|
5281
6940
|
attempts: z.int().min(1).max(10).optional()
|
|
5282
6941
|
});
|
|
6942
|
+
const captchaSettingsSchema = z.strictObject({
|
|
6943
|
+
solver: z.string().min(1),
|
|
6944
|
+
detect: z.strictObject({
|
|
6945
|
+
selector: z.string().min(1)
|
|
6946
|
+
}).optional(),
|
|
6947
|
+
verify: captchaCheckSchema.optional(),
|
|
6948
|
+
attempts: z.int().min(1).max(10).optional(),
|
|
6949
|
+
timeoutMs: z.int().min(1000).optional(),
|
|
6950
|
+
maxSolves: z.int().nonnegative().optional()
|
|
6951
|
+
});
|
|
5283
6952
|
const sessionSpecSchema = z.strictObject({
|
|
5284
6953
|
headers: z.record(z.string(), z.string()).optional(),
|
|
5285
6954
|
cookies: z.array(cookieSchema).optional(),
|
|
@@ -5292,7 +6961,8 @@ const sessionSpecSchema = z.strictObject({
|
|
|
5292
6961
|
bootstrap: bootstrapSchema.optional(),
|
|
5293
6962
|
access: sessionAccessSchema.optional(),
|
|
5294
6963
|
blockedWhen: blockRuleSchema.optional(),
|
|
5295
|
-
onBlock: blockRotationSchema.optional()
|
|
6964
|
+
onBlock: blockRotationSchema.optional(),
|
|
6965
|
+
captcha: captchaSettingsSchema.optional()
|
|
5296
6966
|
});
|
|
5297
6967
|
const limitsSchema = z.strictObject({
|
|
5298
6968
|
maxRecords: z.int().positive().optional(),
|
|
@@ -5595,7 +7265,9 @@ const API_ONLY = new Set(API_ONLY_STEPS);
|
|
|
5595
7265
|
* - web-only steps appear only in web recipes or inside a bootstrap, api-only
|
|
5596
7266
|
* steps only in api recipes, and `next.selector` only in web mode;
|
|
5597
7267
|
* - exactly one emitting construct exists on any path (the two branches of an
|
|
5598
|
-
* `if` are separate paths)
|
|
7268
|
+
* `if` are separate paths);
|
|
7269
|
+
* - a `captcha` step, and `onBlock.solve`, have a solver: their own or
|
|
7270
|
+
* `session.captcha.solver`.
|
|
5599
7271
|
*
|
|
5600
7272
|
* @param input - A parsed input recipe.
|
|
5601
7273
|
* @param output - The parsed output recipe it names.
|
|
@@ -5613,17 +7285,22 @@ function validateBinding(input, output) {
|
|
|
5613
7285
|
const known = new Set(RESERVED);
|
|
5614
7286
|
const varNames = [input.vars ?? {}, ...input.start.map(point => point.vars ?? {})].flatMap(record => Object.keys(record));
|
|
5615
7287
|
for (const name of varNames) known.add(name);
|
|
7288
|
+
const solver = input.session?.captcha !== undefined;
|
|
5616
7289
|
walkSteps(input.steps, 'steps', input.mode, known, report, {
|
|
5617
7290
|
emitting: false,
|
|
5618
|
-
ids: new Set()
|
|
7291
|
+
ids: new Set(),
|
|
7292
|
+
solver
|
|
5619
7293
|
});
|
|
5620
7294
|
if (input.session?.bootstrap !== undefined) {
|
|
5621
7295
|
walkSteps(input.session.bootstrap.steps, 'session.bootstrap.steps', 'web', new Set(RESERVED), report, {
|
|
5622
7296
|
emitting: false,
|
|
5623
7297
|
ids: new Set(),
|
|
5624
|
-
bootstrap: true
|
|
7298
|
+
bootstrap: true,
|
|
7299
|
+
solver
|
|
5625
7300
|
});
|
|
5626
7301
|
}
|
|
7302
|
+
if (!solver && input.session?.onBlock?.solve === true) report('session.onBlock.solve', 'solving a block needs a solver: add session.captcha');
|
|
7303
|
+
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
7304
|
for (const [target, rule] of Object.entries(input.mapping)) {
|
|
5628
7305
|
const field = fieldAt(output.fields, target);
|
|
5629
7306
|
if (field === undefined) {
|
|
@@ -5655,7 +7332,11 @@ function walkStep(step, at, mode, known, report, state) {
|
|
|
5655
7332
|
known.add(step.id);
|
|
5656
7333
|
}
|
|
5657
7334
|
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)
|
|
7335
|
+
if (mode === 'web' && state.bootstrap !== true && step.type === 'extract' && step.kind === 'table' && step.from === undefined) {
|
|
7336
|
+
const foreign = ['sheet', 'slide', 'shapes', 'align', 'includeHidden'].filter(option => step[option] !== undefined);
|
|
7337
|
+
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)`);
|
|
7338
|
+
}
|
|
7339
|
+
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
7340
|
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
7341
|
const nested = () => ({
|
|
5661
7342
|
...state,
|
|
@@ -5818,5 +7499,5 @@ function isSameOutput(document, output) {
|
|
|
5818
7499
|
return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
|
|
5819
7500
|
}
|
|
5820
7501
|
|
|
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 };
|
|
7502
|
+
export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, CaptchaError, DEFAULT_CAPTCHA_SELECTOR, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, csvWorkbook, deckText, detectChallenge, detectDelimiter, fillDown, findDeckTables, findGridTables, findTables, htmlTableSheets, inputRecipeJsonSchema, inputRecipeSchema, isDeckDocument, isWorkbookDocument, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseCsv, parseInputRecipe, parseOutputRecipe, pdfText, readMarkdown, readPdf, readRecipeSource, readYaml, traceLine, tryParseJson, validateBinding, workbookText };
|
|
5822
7503
|
//# sourceMappingURL=index.esm.js.map
|